Compare commits

..
7 Commits
Author SHA1 Message Date
randy 369578a63a mcu-nspanel-office-j: dim the display at night like the bedroom panel
CI / ESPHome config validation (push) Failing after 8s
CI / YAML lint (push) Failing after 38s
Inactive display dim follows the sun (5 % by day, off after dusk at
-6 degrees) and stays at 5 % while light.office_j_lights is on.
Re-applied on time sync, at dawn/dusk and when the lights change.
2026-09-27 20:36:59 +02:00
randy bdf49db1c2 mcu-lvgl-trainer: six pages for the pain cave, fix touch rotation
- touch: drop calibration/transform, the driver defaults to the native
  320x480 panel and LVGL rotates touch points itself (taps were rotated
  by 90 degrees)
- pages: clock (default), basement lights with brightness and color
  temperature for the exercise room (sliders hidden while it is off),
  training start/stop, fan off/50-100 %, stopwatch, status screen with
  "Made for ESPHome" logo and IPv6 first
- light attributes read as text, 'None' while off is skipped
- unused fonts, colors and leftovers removed; ext_uart kept for a
  future ANT+ bridge
2026-09-27 20:31:37 +02:00
randy 349f699572 mcu-stackchan: "Made for ESPHome" logo, IPv6 on status screen
- status screen logo replaced by the official "Made for ESPHome" badge,
  the plain ESPHome logo is not meant for third-party devices
- status screen shows one global IPv6 and one IPv4 address (link-local
  skipped) instead of the IPv4 sensor value
2026-09-27 19:49:57 +02:00
randy 329830e553 mcu-m5paper: rework on M5Stack components, battery mode with forecast
Rewrite of the old config on the core ESPHome components (it8951, gt911,
bm8563, sht3xd):
- USB: always on, forecast screen redrawn every 15 min, forecast
  refreshed every hour and when Home Assistant connects
- battery: BM8563 timer wakes the board every 15 min (off 1:00-5:00),
  it draws the stored forecast and cuts power; WiFi only when the
  forecast is older than 6 h
- mode detected by trying to power off; battery voltage drop on USB
  triggers the battery cycle
- hourly forecast via weather.get_forecasts, 48 h kept in the FM24C02
  EEPROM (include/m5paper_weather.h)
- OTA image confirmed before power-off, battery wake-ups are shorter
  than boot_is_good_after
- status screen with "Made for ESPHome" logo, IPv6 addresses first
2026-09-27 19:49:57 +02:00
randy d813717d2c common: enable ha_time in common packages, devices extend it
Move the homeassistant time platform (id ha_time) into all common
packages. Devices with custom triggers now use '!extend ha_time';
plain duplicate time blocks are removed. mcu-lvgl's unused time_comp
is unified to ha_time.
2026-09-27 16:43:35 +02:00
randy eea872b930 mcu-stackchan: spectrum analyzer, head touch gestures, status bar
- Winamp style spectrum analyzer instead of the face while music plays,
  using audio_spectrum from randybb/esphome-components
- head touch: tap starts the assistant, stroking shows an in-love face;
  SI12T sensitivity raised so the pads actually trigger
- battery (AXP2101) and wifi indicators, ear icon while listening
2026-09-27 15:19:07 +02:00
randy 39e744900d mcu-stackchan: add M5Stack StackChan config
Voice assistant satellite with animated face on the CoreS3 display:
- power rails, IO expander resets and servo power as in the M5 BSP
- mic and speaker share one I2S bus, so the mic is released before playback
- AW88298 volume limited to the audible range (0.5-1.0)
- partial display redraws, status screen until Home Assistant connects
- battery and wifi indicators, listening icon
- camera converted to JPEG on demand, no periodic frames
2026-09-27 14:02:40 +02:00
36 changed files with 2253 additions and 310 deletions

No files matched your search

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_2" data-name="Layer 2" viewBox="0 0 681.98 201.98"><defs><style>.cls-1{fill:none;stroke:#1d2126;stroke-miterlimit:10;stroke-width:1.01px}.cls-2{fill:#1d2126}.cls-2,.cls-3,.cls-4{stroke-width:0}.cls-3{fill:#f2f4f9}.cls-4{fill:#18bcf2}</style></defs><g id="Layer_1-2" data-name="Layer 1"><g><g><rect width="680.97" height="200.97" x=".51" y=".51" class="cls-3" rx="40.48" ry="40.48"/><rect width="680.97" height="200.97" x=".51" y=".51" class="cls-1" rx="40.48" ry="40.48"/></g><g><path d="M236.13,43.11v26.53h-5.29v-14.5l-5.68,14.5h-5.08l-5.61-14.4v14.4h-5.12v-26.53h5.12l8.16,20.27,8.21-20.27h5.29Z" class="cls-4"/><path d="M259.54,63.84h-9.04l-2.02,5.8h-5.59l9.51-26.53h5.33l9.47,26.53h-5.7l-1.97-5.8ZM258,59.34l-2.95-8.7-2.99,8.7h5.94Z" class="cls-4"/><path d="M296.93,56.42c.02,1.89-.3,3.66-.98,5.31s-1.61,3.06-2.8,4.22-2.61,2.07-4.26,2.73-3.4.98-5.26.96h-9.65v-26.53h9.65c2.43-.04,4.66.52,6.71,1.68s3.67,2.76,4.86,4.82,1.78,4.33,1.74,6.81ZM291.25,56.42c0-2.46-.71-4.46-2.12-5.99s-3.27-2.29-5.58-2.29h-4.22v16.52h4.22c2.34,0,4.21-.75,5.61-2.26s2.09-3.5,2.09-5.99Z" class="cls-4"/><path d="M321.38,64.59v5.04h-16.7v-26.53h16.7v5.1h-11.34v5.66h10.07v4.75h-10.07v5.98h11.34Z" class="cls-4"/><path d="M348.35,48.17v6.1h9.58l-.04,4.82h-9.54v10.55h-5.36v-26.53h16.73l-.04,5.06h-11.34Z" class="cls-4"/><path d="M378.62,42.64c1.9,0,3.66.34,5.27,1.03s2.99,1.63,4.11,2.84,2,2.66,2.63,4.35.94,3.52.94,5.49c.02,1.95-.28,3.77-.91,5.48s-1.52,3.17-2.65,4.39-2.52,2.17-4.15,2.86-3.39,1.03-5.27,1c-2.5.04-4.74-.54-6.72-1.72s-3.52-2.82-4.6-4.92-1.6-4.45-1.56-7.05c-.02-1.95.28-3.77.91-5.48s1.51-3.18,2.65-4.39,2.51-2.17,4.13-2.87,3.37-1.03,5.23-1ZM378.65,64.82c2.24,0,4.01-.76,5.32-2.28s1.96-3.58,1.96-6.2-.65-4.71-1.95-6.22-3.08-2.27-5.33-2.27-4.03.76-5.33,2.27-1.95,3.59-1.95,6.22.65,4.71,1.95,6.21,3.08,2.26,5.33,2.26Z" class="cls-4"/><path d="M419.5,69.64h-6.01l-4.08-9.58h-4.89v9.58h-5.36v-26.53h10.21c3.28.01,5.76.74,7.44,2.19s2.51,3.55,2.51,6.3c0,1.79-.39,3.34-1.17,4.65s-1.89,2.28-3.33,2.91l4.68,10.48ZM413.63,51.6c0-1.15-.36-2.05-1.07-2.72s-1.78-.99-3.18-.99h-4.85v7.49h4.85c1.41,0,2.47-.34,3.18-1.01s1.07-1.6,1.07-2.77Z" class="cls-4"/></g><g><path d="M251.92,146.83v11.25h-43.5v-70.08h43.5v11.34h-31.59v18.14h28.22v10.55h-28.22v18.8h31.59Z" class="cls-2"/><path d="M285,86.74c6.41,0,11.73,1.56,15.98,4.69,4.25,3.12,6.86,7.3,7.83,12.52l-11.2,3.05c-.62-3-2.09-5.31-4.38-6.94-2.3-1.62-5.13-2.44-8.51-2.44-3.62,0-6.5.87-8.62,2.6s-3.19,4.04-3.19,6.91c0,4.53,2.81,7.47,8.44,8.81l11.44,2.86c5.91,1.53,10.33,3.96,13.27,7.29s4.41,7.43,4.41,12.3c0,6.25-2.27,11.3-6.8,15.16-4.53,3.86-10.59,5.79-18.19,5.79-6.84,0-12.62-1.61-17.34-4.83-4.66-3.34-7.41-7.75-8.25-13.22l11.2-2.91c.41,3.09,1.95,5.52,4.62,7.27,2.67,1.75,6.07,2.62,10.2,2.62s7.09-.84,9.35-2.51c2.27-1.67,3.4-3.94,3.4-6.82,0-4.5-2.81-7.47-8.44-8.91l-11.44-2.77c-5.88-1.34-10.29-3.73-13.24-7.15s-4.43-7.6-4.43-12.54c0-6.19,2.2-11.21,6.59-15.07,4.39-3.86,10.16-5.79,17.32-5.79Z" class="cls-2"/><path d="M371.55,110.83c0,6.88-2.09,12.44-6.26,16.69-4.17,4.25-9.79,6.38-16.85,6.38h-15.75v24.19h-11.91v-70.08h27.75c7.12,0,12.74,2.06,16.85,6.16,4.11,4.11,6.16,9.66,6.16,16.66ZM359.08,110.55c0-3.53-1.1-6.4-3.3-8.6s-5.32-3.3-9.35-3.3h-13.73v24.8h13.55c4.12,0,7.3-1.17,9.52-3.52s3.33-5.47,3.33-9.38Z" class="cls-2"/><path d="M381.23,88h11.91v29.3l30.89.09v-29.39h12v70.08h-12v-30.14l-30.89-.09v30.23h-11.91v-70.08Z" class="cls-2"/><path d="M470.88,109.43c7.25,0,13.16,2.33,17.72,6.98,4.56,4.66,6.84,10.64,6.84,17.95s-2.28,13.25-6.84,17.91c-4.56,4.66-10.47,6.98-17.72,6.98s-13.25-2.33-17.81-6.98c-4.56-4.66-6.84-10.62-6.84-17.91s2.28-13.34,6.84-17.98,10.5-6.96,17.81-6.96ZM470.88,149.04c3.88,0,7.02-1.37,9.45-4.1,2.42-2.73,3.63-6.29,3.63-10.66s-1.21-7.91-3.63-10.62c-2.42-2.7-5.57-4.05-9.45-4.05s-7.17,1.35-9.61,4.05c-2.44,2.7-3.66,6.24-3.66,10.62s1.22,7.93,3.66,10.66c2.44,2.73,5.64,4.1,9.61,4.1Z" class="cls-2"/><path d="M576.42,127.94v30.14h-11.02v-28.41c0-3.28-.84-5.84-2.53-7.69-1.69-1.84-3.98-2.77-6.89-2.77-3.09,0-5.56,1.01-7.41,3.02s-2.77,4.84-2.77,8.46v27.38h-11.16v-28.41c0-3.28-.82-5.84-2.46-7.69-1.64-1.84-3.91-2.77-6.82-2.77-3.09,0-5.58,1.01-7.45,3.02s-2.81,4.84-2.81,8.46v27.38h-11.34v-47.34h10.55l.38,4.55c2.75-4.03,7.23-6.05,13.45-6.05,3.62,0,6.77.75,9.42,2.25s4.67,3.69,6.05,6.56c1.16-2.75,3.07-4.91,5.74-6.47,2.67-1.56,5.85-2.34,9.54-2.34,5.38,0,9.64,1.66,12.8,4.97,3.16,3.31,4.73,7.89,4.73,13.73Z" class="cls-2"/><path d="M631.43,138.58h-36.28c.38,3.56,1.72,6.28,4.03,8.16s5.25,2.81,8.81,2.81c5.78,0,9.83-2.41,12.14-7.22l9.47,3.75c-1.78,4.16-4.59,7.41-8.41,9.75s-8.23,3.52-13.2,3.52c-7,0-12.68-2.29-17.04-6.87-4.36-4.58-6.54-10.59-6.54-18.02s2.2-13.48,6.59-18.14c4.39-4.66,10.13-6.98,17.23-6.98s12.58,2.3,16.83,6.89,6.38,10.64,6.38,18.14v4.22ZM595.34,130.61h24.56c-.22-3.84-1.38-6.77-3.49-8.79s-4.95-3.02-8.51-3.02-6.41,1.02-8.62,3.07c-2.22,2.05-3.53,4.96-3.94,8.74Z" class="cls-2"/></g><g><path d="M170.55,153.49c0,4.12-3.38,7.5-7.5,7.5H58.05c-4.12,0-7.5-3.38-7.5-7.5v-45c0-4.12,2.39-9.89,5.3-12Line truncated

After

Width:  |  Height:  |  Size: 5.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_2" data-name="Layer 2" viewBox="0 0 681.98 201.98"><defs><style>.cls-1{fill:#1d2126}.cls-1,.cls-2,.cls-3{stroke-width:0}.cls-2{fill:#f2f4f9}.cls-3{fill:#18bcf2}.cls-4{fill:none;stroke:#f2f4f9;stroke-miterlimit:10;stroke-width:1.01px}</style></defs><g id="Layer_1-2" data-name="Layer 1"><g><g><rect width="680.97" height="200.97" x=".51" y=".51" class="cls-1" rx="40.48" ry="40.48"/><rect width="680.97" height="200.97" x=".51" y=".51" class="cls-4" rx="40.48" ry="40.48"/></g><g><path d="M236.13,43.11v26.53h-5.29v-14.5l-5.68,14.5h-5.08l-5.61-14.4v14.4h-5.12v-26.53h5.12l8.16,20.27,8.21-20.27h5.29Z" class="cls-3"/><path d="M259.54,63.84h-9.04l-2.02,5.8h-5.59l9.51-26.53h5.33l9.47,26.53h-5.7l-1.97-5.8ZM258,59.34l-2.95-8.7-2.99,8.7h5.94Z" class="cls-3"/><path d="M296.93,56.42c.02,1.89-.3,3.66-.98,5.31s-1.61,3.06-2.8,4.22-2.61,2.07-4.26,2.73-3.4.98-5.26.96h-9.65v-26.53h9.65c2.43-.04,4.66.52,6.71,1.68s3.67,2.76,4.86,4.82,1.78,4.33,1.74,6.81ZM291.25,56.42c0-2.46-.71-4.46-2.12-5.99s-3.27-2.29-5.58-2.29h-4.22v16.52h4.22c2.34,0,4.21-.75,5.61-2.26s2.09-3.5,2.09-5.99Z" class="cls-3"/><path d="M321.38,64.59v5.04h-16.7v-26.53h16.7v5.1h-11.34v5.66h10.07v4.75h-10.07v5.98h11.34Z" class="cls-3"/><path d="M348.35,48.17v6.1h9.58l-.04,4.82h-9.54v10.55h-5.36v-26.53h16.73l-.04,5.06h-11.34Z" class="cls-3"/><path d="M378.62,42.64c1.9,0,3.66.34,5.27,1.03s2.99,1.63,4.11,2.84,2,2.66,2.63,4.35.94,3.52.94,5.49c.02,1.95-.28,3.77-.91,5.48s-1.52,3.17-2.65,4.39-2.52,2.17-4.15,2.86-3.39,1.03-5.27,1c-2.5.04-4.74-.54-6.72-1.72s-3.52-2.82-4.6-4.92-1.6-4.45-1.56-7.05c-.02-1.95.28-3.77.91-5.48s1.51-3.18,2.65-4.39,2.51-2.17,4.13-2.87,3.37-1.03,5.23-1ZM378.65,64.82c2.24,0,4.01-.76,5.32-2.28s1.96-3.58,1.96-6.2-.65-4.71-1.95-6.22-3.08-2.27-5.33-2.27-4.03.76-5.33,2.27-1.95,3.59-1.95,6.22.65,4.71,1.95,6.21,3.08,2.26,5.33,2.26Z" class="cls-3"/><path d="M419.5,69.64h-6.01l-4.08-9.58h-4.89v9.58h-5.36v-26.53h10.21c3.28.01,5.76.74,7.44,2.19s2.51,3.55,2.51,6.3c0,1.79-.39,3.34-1.17,4.65s-1.89,2.28-3.33,2.91l4.68,10.48ZM413.63,51.6c0-1.15-.36-2.05-1.07-2.72s-1.78-.99-3.18-.99h-4.85v7.49h4.85c1.41,0,2.47-.34,3.18-1.01s1.07-1.6,1.07-2.77Z" class="cls-3"/></g><g><path d="M251.92,146.83v11.25h-43.5v-70.08h43.5v11.34h-31.59v18.14h28.22v10.55h-28.22v18.8h31.59Z" class="cls-2"/><path d="M285,86.74c6.41,0,11.73,1.56,15.98,4.69,4.25,3.12,6.86,7.3,7.83,12.52l-11.2,3.05c-.62-3-2.09-5.31-4.38-6.94-2.3-1.62-5.13-2.44-8.51-2.44-3.62,0-6.5.87-8.62,2.6s-3.19,4.04-3.19,6.91c0,4.53,2.81,7.47,8.44,8.81l11.44,2.86c5.91,1.53,10.33,3.96,13.27,7.29s4.41,7.43,4.41,12.3c0,6.25-2.27,11.3-6.8,15.16-4.53,3.86-10.59,5.79-18.19,5.79-6.84,0-12.62-1.61-17.34-4.83-4.66-3.34-7.41-7.75-8.25-13.22l11.2-2.91c.41,3.09,1.95,5.52,4.62,7.27,2.67,1.75,6.07,2.62,10.2,2.62s7.09-.84,9.35-2.51c2.27-1.67,3.4-3.94,3.4-6.82,0-4.5-2.81-7.47-8.44-8.91l-11.44-2.77c-5.88-1.34-10.29-3.73-13.24-7.15s-4.43-7.6-4.43-12.54c0-6.19,2.2-11.21,6.59-15.07,4.39-3.86,10.16-5.79,17.32-5.79Z" class="cls-2"/><path d="M371.55,110.83c0,6.88-2.09,12.44-6.26,16.69-4.17,4.25-9.79,6.38-16.85,6.38h-15.75v24.19h-11.91v-70.08h27.75c7.12,0,12.74,2.06,16.85,6.16,4.11,4.11,6.16,9.66,6.16,16.66ZM359.08,110.55c0-3.53-1.1-6.4-3.3-8.6s-5.32-3.3-9.35-3.3h-13.73v24.8h13.55c4.12,0,7.3-1.17,9.52-3.52s3.33-5.47,3.33-9.38Z" class="cls-2"/><path d="M381.23,88h11.91v29.3l30.89.09v-29.39h12v70.08h-12v-30.14l-30.89-.09v30.23h-11.91v-70.08Z" class="cls-2"/><path d="M470.88,109.43c7.25,0,13.16,2.33,17.72,6.98,4.56,4.66,6.84,10.64,6.84,17.95s-2.28,13.25-6.84,17.91c-4.56,4.66-10.47,6.98-17.72,6.98s-13.25-2.33-17.81-6.98c-4.56-4.66-6.84-10.62-6.84-17.91s2.28-13.34,6.84-17.98,10.5-6.96,17.81-6.96ZM470.88,149.04c3.88,0,7.02-1.37,9.45-4.1,2.42-2.73,3.63-6.29,3.63-10.66s-1.21-7.91-3.63-10.62c-2.42-2.7-5.57-4.05-9.45-4.05s-7.17,1.35-9.61,4.05c-2.44,2.7-3.66,6.24-3.66,10.62s1.22,7.93,3.66,10.66c2.44,2.73,5.64,4.1,9.61,4.1Z" class="cls-2"/><path d="M576.42,127.94v30.14h-11.02v-28.41c0-3.28-.84-5.84-2.53-7.69-1.69-1.84-3.98-2.77-6.89-2.77-3.09,0-5.56,1.01-7.41,3.02s-2.77,4.84-2.77,8.46v27.38h-11.16v-28.41c0-3.28-.82-5.84-2.46-7.69-1.64-1.84-3.91-2.77-6.82-2.77-3.09,0-5.58,1.01-7.45,3.02s-2.81,4.84-2.81,8.46v27.38h-11.34v-47.34h10.55l.38,4.55c2.75-4.03,7.23-6.05,13.45-6.05,3.62,0,6.77.75,9.42,2.25s4.67,3.69,6.05,6.56c1.16-2.75,3.07-4.91,5.74-6.47,2.67-1.56,5.85-2.34,9.54-2.34,5.38,0,9.64,1.66,12.8,4.97,3.16,3.31,4.73,7.89,4.73,13.73Z" class="cls-2"/><path d="M631.43,138.58h-36.28c.38,3.56,1.72,6.28,4.03,8.16s5.25,2.81,8.81,2.81c5.78,0,9.83-2.41,12.14-7.22l9.47,3.75c-1.78,4.16-4.59,7.41-8.41,9.75s-8.23,3.52-13.2,3.52c-7,0-12.68-2.29-17.04-6.87-4.36-4.58-6.54-10.59-6.54-18.02s2.2-13.48,6.59-18.14c4.39-4.66,10.13-6.98,17.23-6.98s12.58,2.3,16.83,6.89,6.38,10.64,6.38,18.14v4.22ZM595.34,130.61h24.56c-.22-3.84-1.38-6.77-3.49-8.79s-4.95-3.02-8.51-3.02-6.41,1.02-8.62,3.07c-2.22,2.05-3.53,4.96-3.94,8.74Z" class="cls-2"/></g><g><path d="M170.55,153.49c0,4.12-3.38,7.5-7.5,7.5H58.05c-4.12,0-7.5-3.38-7.5-7.5v-45c0-4.12,2.39-9.89,5.3-12Line truncated

After

Width:  |  Height:  |  Size: 5.3 KiB

+2 -2
View File
@@ -24,8 +24,8 @@ text_sensor:
# - <<: !include text_sensor/uptime.yaml # - <<: !include text_sensor/uptime.yaml
- <<: !include text_sensor/wifi_info.yaml - <<: !include text_sensor/wifi_info.yaml
# time: time:
#. - <<: !include time/homeassistant.yaml - <<: !include time/homeassistant.yaml
network: network:
enable_ipv6: true enable_ipv6: true
+3
View File
@@ -24,5 +24,8 @@ text_sensor:
- <<: !include text_sensor/version.yaml - <<: !include text_sensor/version.yaml
- <<: !include text_sensor/wifi_info.yaml - <<: !include text_sensor/wifi_info.yaml
time:
- <<: !include time/homeassistant.yaml
network: network:
enable_ipv6: true enable_ipv6: true
+3
View File
@@ -23,5 +23,8 @@ text_sensor:
- <<: !include text_sensor/version.yaml - <<: !include text_sensor/version.yaml
- <<: !include text_sensor/wifi_info.yaml - <<: !include text_sensor/wifi_info.yaml
time:
- <<: !include time/homeassistant.yaml
network: network:
enable_ipv6: true enable_ipv6: true
+3
View File
@@ -21,5 +21,8 @@ text_sensor:
- <<: !include text_sensor/version.yaml - <<: !include text_sensor/version.yaml
- <<: !include text_sensor/ethernet_info.yaml - <<: !include text_sensor/ethernet_info.yaml
time:
- <<: !include time/homeassistant.yaml
network: network:
enable_ipv6: true enable_ipv6: true
+3
View File
@@ -27,3 +27,6 @@ text_sensor:
- <<: !include text_sensor/git_ref.yaml - <<: !include text_sensor/git_ref.yaml
- <<: !include text_sensor/version.yaml - <<: !include text_sensor/version.yaml
- <<: !include text_sensor/wifi_info.yaml - <<: !include text_sensor/wifi_info.yaml
time:
- <<: !include time/homeassistant.yaml
+176
View File
@@ -0,0 +1,176 @@
#pragma once
// Hourly weather forecast for mcu-m5paper: parsed from the weather.get_forecasts
// response and kept in the on-board FM24C02 EEPROM (256 B, I2C 0x50), so it
// survives the power-off between RTC wake-ups.
#include <cmath>
#include <cstdio>
#include <cstring>
#include "esphome/components/i2c/i2c_bus.h"
#include "esphome/components/json/json_util.h"
#include "esphome/core/hal.h"
namespace m5paper_weather {
static constexpr uint8_t EEPROM_ADDR = 0x50;
static constexpr uint8_t EEPROM_PAGE = 8;
static constexpr uint8_t MAGIC = 0xA5;
static constexpr uint8_t VERSION = 1;
static constexpr uint8_t MAX_HOURS = 48;
// magic, version, fetched_at (u32), first_ts (u32), count
static constexpr size_t HEADER = 11;
static constexpr size_t MAX_SIZE = HEADER + MAX_HOURS * 4;
static_assert(MAX_SIZE <= 256, "forecast does not fit into the EEPROM");
static constexpr uint8_t NO_VALUE = 0xFF;
static constexpr int8_t NO_TEMP = INT8_MIN;
struct Hour {
uint8_t cond; // index into CONDITIONS, NO_VALUE if unknown
int8_t temp2; // temperature in 0.5 °C, NO_TEMP if unknown
uint8_t wind; // km/h, NO_VALUE if unknown
uint8_t pop; // precipitation probability %, NO_VALUE if unknown
};
static_assert(sizeof(Hour) == 4, "Hour must stay 4 bytes");
struct Forecast {
uint32_t fetched_at{0}; // UTC epoch of the download
uint32_t first_ts{0}; // UTC epoch of hours[0]; entries are 1 h apart
uint8_t count{0};
Hour hours[MAX_HOURS]{};
bool valid() const { return this->count > 0; }
// hour covering the given UTC epoch, nullptr if outside the forecast
const Hour *at(uint32_t ts) const {
if (!this->valid() || ts < this->first_ts)
return nullptr;
uint32_t idx = (ts - this->first_ts) / 3600;
return idx < this->count ? &this->hours[idx] : nullptr;
}
};
// the forecast in RAM, loaded from the EEPROM at boot
inline Forecast &forecast() {
static Forecast fc;
return fc;
}
// Home Assistant weather conditions; the index is the stored condition code
static const char *const CONDITIONS[] = {
"clear-night", "cloudy", "exceptional", "fog", "hail", "lightning", "lightning-rainy", "partlycloudy",
"pouring", "rainy", "snowy", "snowy-rainy", "sunny", "windy", "windy-variant",
};
static const char *const CONDITION_NAMES[] = {
"Jasno", "Oblačno", "Extrémne počasie", "Hmla", "Krupobitie", "Búrka", "Búrka s dažďom", "Polooblačno",
"Lejak", "Dážď", "Sneženie", "Dážď so snehom", "Slnečno", "Veterno", "Veterno, oblačno",
};
static constexpr uint8_t NUM_CONDITIONS = sizeof(CONDITIONS) / sizeof(CONDITIONS[0]);
inline uint8_t condition_code(const char *name) {
for (uint8_t i = 0; i < NUM_CONDITIONS; i++) {
if (strcmp(name, CONDITIONS[i]) == 0)
return i;
}
return NO_VALUE;
}
inline const char *condition_name(uint8_t code) { return code < NUM_CONDITIONS ? CONDITION_NAMES[code] : "?"; }
// days since 1970-01-01 for a proleptic Gregorian date (H. Hinnant's algorithm)
inline int32_t days_from_civil(int y, int m, int d) {
y -= m <= 2;
const int era = (y >= 0 ? y : y - 399) / 400;
const int yoe = y - era * 400;
const int doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
const int doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + doe - 719468;
}
// "2026-09-27T16:00:00+00:00" -> UTC epoch, 0 on error
inline uint32_t parse_iso8601(const char *s) {
int y, mo, d, h, mi, sec = 0, oh = 0, om = 0;
char sign = '+';
int n = sscanf(s, "%d-%d-%dT%d:%d:%d%c%d:%d", &y, &mo, &d, &h, &mi, &sec, &sign, &oh, &om);
if (n < 5)
return 0;
int64_t ts = int64_t(days_from_civil(y, mo, d)) * 86400 + h * 3600 + mi * 60 + sec;
if (n >= 8 && (sign == '+' || sign == '-')) {
int64_t offset = oh * 3600 + om * 60;
ts += sign == '+' ? -offset : offset;
}
return ts > 0 ? uint32_t(ts) : 0;
}
// Parse the weather.get_forecasts response for the given entity;
// Home Assistant wraps the action response as {"response": {...}}
inline bool parse(ArduinoJson::JsonObjectConst response, const char *entity, uint32_t now, Forecast &fc) {
ArduinoJson::JsonObjectConst root = response["response"].is<ArduinoJson::JsonObjectConst>()
? response["response"].as<ArduinoJson::JsonObjectConst>()
: response;
ArduinoJson::JsonArrayConst items = root[entity]["forecast"];
if (items.isNull() || items.size() == 0)
return false;
Forecast out;
for (ArduinoJson::JsonObjectConst item : items) {
if (out.count >= MAX_HOURS)
break;
if (out.count == 0) {
out.first_ts = parse_iso8601(item["datetime"] | "");
if (out.first_ts == 0)
return false;
}
Hour &hour = out.hours[out.count++];
hour.cond = condition_code(item["condition"] | "");
float temp = item["temperature"] | NAN;
hour.temp2 = std::isnan(temp) ? NO_TEMP : int8_t(std::max(-127L, std::min(127L, lroundf(temp * 2))));
float wind = item["wind_speed"] | NAN;
hour.wind = std::isnan(wind) ? NO_VALUE : uint8_t(std::max(0L, std::min(254L, lroundf(wind))));
float pop = item["precipitation_probability"] | NAN;
hour.pop = std::isnan(pop) ? NO_VALUE : uint8_t(std::max(0L, std::min(100L, lroundf(pop))));
}
out.fetched_at = now;
fc = out;
return true;
}
inline bool load(esphome::i2c::I2CBus *bus, Forecast &fc) {
uint8_t buf[MAX_SIZE];
const uint8_t start = 0;
fc.count = 0;
if (bus->write_readv(EEPROM_ADDR, &start, 1, buf, sizeof(buf)) != esphome::i2c::ERROR_OK)
return false;
if (buf[0] != MAGIC || buf[1] != VERSION)
return false;
memcpy(&fc.fetched_at, buf + 2, 4);
memcpy(&fc.first_ts, buf + 6, 4);
fc.count = std::min(buf[10], MAX_HOURS);
memcpy(fc.hours, buf + HEADER, fc.count * sizeof(Hour));
return true;
}
inline bool save(esphome::i2c::I2CBus *bus, const Forecast &fc) {
uint8_t buf[MAX_SIZE];
buf[0] = MAGIC;
buf[1] = VERSION;
memcpy(buf + 2, &fc.fetched_at, 4);
memcpy(buf + 6, &fc.first_ts, 4);
buf[10] = fc.count;
memcpy(buf + HEADER, fc.hours, fc.count * sizeof(Hour));
const size_t len = HEADER + fc.count * sizeof(Hour);
// page writes must not cross an 8-byte page boundary; 5 ms write cycle per page
for (size_t off = 0; off < len; off += EEPROM_PAGE) {
uint8_t page[EEPROM_PAGE + 1];
const size_t n = std::min<size_t>(EEPROM_PAGE, len - off);
page[0] = uint8_t(off);
memcpy(page + 1, buf + off, n);
if (bus->write(EEPROM_ADDR, page, n + 1) != esphome::i2c::ERROR_OK)
return false;
esphome::delay(6);
}
return true;
}
} // namespace m5paper_weather
+1 -2
View File
@@ -491,8 +491,7 @@ sensor:
# name: Dallas Temp 4 # name: Dallas Temp 4
# one_wire_id: hub_1 # one_wire_id: hub_1
time: # time:
- !include common/time/homeassistant.yaml
# - platform: sntp # - platform: sntp
# id: sntp_time # id: sntp_time
# timezone: Europe/Bratislava # timezone: Europe/Bratislava
+1 -2
View File
@@ -139,8 +139,7 @@ switch:
id: relay_2 id: relay_2
time: time:
- platform: homeassistant - id: !extend ha_time
timezone: Europe/Bratislava
on_time: on_time:
- seconds: 0 - seconds: 0
minutes: /15 minutes: /15
-5
View File
@@ -31,11 +31,6 @@ packages:
debug: debug:
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Bratislava
i2c: i2c:
# - id: i2c0 # - id: i2c0
sda: 32 sda: 32
-5
View File
@@ -109,11 +109,6 @@ globals:
restore_value: no restore_value: no
initial_value: "false" initial_value: "false"
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Bratislava
display: display:
- platform: tm1637 - platform: tm1637
id: tm1637_display id: tm1637_display
-5
View File
@@ -51,11 +51,6 @@ globals:
restore_value: no restore_value: no
initial_value: "false" initial_value: "false"
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Bratislava
binary_sensor: binary_sensor:
- platform: gpio - platform: gpio
name: Button name: Button
-5
View File
@@ -242,11 +242,6 @@ binary_sensor:
# on_clockwise: # on_clockwise:
# - display_menu.down: # - display_menu.down:
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Bratislava
# display: # display:
# - platform: tm1637 # - platform: tm1637
# id: tm1637_display # id: tm1637_display
-5
View File
@@ -44,11 +44,6 @@ packages:
# Relay1 G22 # Relay1 G22
# Relay2 G19 # Relay2 G19
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Bratislava
i2c: i2c:
# - id: i2c0 # - id: i2c0
# sda: 26 # sda: 26
+1 -3
View File
@@ -129,9 +129,7 @@ globals:
initial_value: '20.5' initial_value: '20.5'
time: time:
- platform: homeassistant - id: !extend ha_time
id: ha_time
timezone: Europe/Bratislava
on_time: on_time:
- seconds: 0 - seconds: 0
minutes: /1 minutes: /1
-5
View File
@@ -55,11 +55,6 @@ api:
# Relay1 G22 # Relay1 G22
# Relay2 G19 # Relay2 G19
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Bratislava
i2c: i2c:
# - id: i2c0 # - id: i2c0
# sda: 26 # sda: 26
+556 -140
View File
@@ -4,6 +4,13 @@ substitutions:
name: Trainer Display name: Trainer Display
area: Basement area: Basement
comment: "${area} | Pain Cave" comment: "${area} | Pain Cave"
light_basement: light.mcu_basement_2_light
light_workbench: light.mcu_basement_2_workbench_light
light_exercise: light.exercise_room
fan: light.mcu_dimmer_exercise_room_fan
# Pages (480x320, navigation bar at the bottom):
# clock, lights, training, fan, stopwatch, status
esphome: esphome:
name: mcu-${device} name: mcu-${device}
@@ -17,7 +24,6 @@ esp32:
framework: framework:
type: esp-idf type: esp-idf
packages: packages:
common: !include common/common.yaml common: !include common/common.yaml
@@ -36,71 +42,17 @@ spi:
clk_pin: 47 clk_pin: 47
data_pins: [21, 48, 40, 39] data_pins: [21, 48, 40, 39]
color:
- id: my_red
red: 100%
green: 0%
blue: 0%
- id: my_orange
red: 100%
green: 50%
blue: 0%
- id: my_yellow
red: 100%
green: 100%
blue: 0%
- id: my_green
red: 0%
green: 100%
blue: 0%
- id: my_blue
red: 0%
green: 0%
blue: 100%
- id: my_teal
red: 0%
green: 100%
blue: 100%
- id: my_gray
red: 70%
green: 70%
blue: 70%
- id: my_white
red: 100%
green: 100%
blue: 100%
font:
- file: "gfonts://Roboto"
id: Roboto_48
size: 48
- file: "gfonts://Roboto"
id: Roboto_36
size: 36
- file: "gfonts://Roboto"
id: Roboto_24
size: 24
- file: "gfonts://Roboto"
id: Roboto_12
size: 12
- file: "gfonts://Roboto"
id: font_std
size: 40
glyphs: "!\"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/\\[]|&@#'"
- file: "gfonts://Roboto@700"
id: font_title
size: 40
glyphs: "!\"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/\\[]|&@#'"
globals: globals:
- id: bgcolor # stopwatch: accumulated time of finished runs + start of the current run
type: Color - id: sw_running
initial_value: "Color::random_color()" type: bool
initial_value: 'false'
# power_supply: - id: sw_start
# id: main_backlight type: uint32_t
# pin: 1 initial_value: '0'
# enable_on_boot: true - id: sw_accum
type: uint32_t
initial_value: '0'
output: output:
- platform: ledc - platform: ledc
@@ -119,7 +71,6 @@ display:
- platform: mipi_spi - platform: mipi_spi
model: AXS15231 model: AXS15231
data_rate: 40MHz #20mhz is default data_rate: 40MHz #20mhz is default
#spi_mode: MODE2
id: main_display id: main_display
spi_id: display_qspi spi_id: display_qspi
dimensions: dimensions:
@@ -127,11 +78,6 @@ display:
width: 320 width: 320
cs_pin: 45 cs_pin: 45
auto_clear_enabled: false auto_clear_enabled: false
# show_test_card: true
# lambda: |-
# it.fill(id(bgcolor));
# it.print(it.get_width()/2, it.get_height()/2-20, id(font_title), TextAlign::CENTER, "ESPHome");
# it.print(it.get_width()/2, it.get_height()/2+20, id(font_std), TextAlign::CENTER, "@UTBDK");
touchscreen: touchscreen:
- platform: axs15231 - platform: axs15231
@@ -142,27 +88,8 @@ touchscreen:
# interrupt_pin: # interrupt_pin:
# number: 3 # number: 3
# ignore_strapping_warning: true # ignore_strapping_warning: true
calibration: # no calibration/transform: the driver defaults to the native 320x480 panel and
x_min: 0 # LVGL applies its own rotation to touch points
x_max: 479
y_min: 0
y_max: 319
transform:
swap_xy: true
mirror_x: false
mirror_y: true
# on_touch:
# - lambda: |-
# Color newColor;
# do { newColor = Color::random_color(); } while (newColor == id(bgcolor));
# id(bgcolor) = newColor;
# ESP_LOGI("cal", "x=%d, y=%d, x_raw=%d, y_raw=%0d",
# touch.x,
# touch.y,
# touch.x_raw,
# touch.y_raw
# );
on_release: on_release:
- if: - if:
condition: lvgl.is_paused condition: lvgl.is_paused
@@ -183,107 +110,596 @@ uart:
binary_sensor: binary_sensor:
- platform: homeassistant - platform: homeassistant
id: exercise_room_light id: basement_light
entity_id: light.exercise_room entity_id: ${light_basement}
trigger_on_initial_state: true trigger_on_initial_state: true
on_state: on_state:
then: - lvgl.widget.update:
lvgl.widget.update: id: basement_light_btn
id: exercise_room_light_btn
state: state:
checked: !lambda return x; checked: !lambda return x;
- platform: homeassistant - platform: homeassistant
id: exercise_room_fan id: workbench_light
entity_id: light.mcu_dimmer_exercise_room_fan entity_id: ${light_workbench}
trigger_on_initial_state: true trigger_on_initial_state: true
on_state: on_state:
then: - lvgl.widget.update:
lvgl.widget.update: id: workbench_light_btn
id: exercise_room_fan_btn
state: state:
checked: !lambda return x; checked: !lambda return x;
- platform: homeassistant
id: exercise_light
entity_id: ${light_exercise}
trigger_on_initial_state: true
on_state:
- lvgl.widget.update:
id: exercise_light_btn
state:
checked: !lambda return x;
# brightness and color temperature only make sense while the light is on
- if:
condition:
lambda: return x;
then:
- lvgl.widget.show: exercise_controls
else:
- lvgl.widget.hide: exercise_controls
- platform: homeassistant
id: fan_on
entity_id: ${fan}
trigger_on_initial_state: true
on_state:
- script.execute: update_fan_buttons
sensor:
- platform: homeassistant
id: fan_brightness
entity_id: ${fan}
attribute: brightness
on_value:
- script.execute: update_fan_buttons
text_sensor:
# light attributes read as text: they are 'None' while the light is off
- platform: homeassistant
id: exercise_brightness
entity_id: ${light_exercise}
attribute: brightness
on_value:
- if:
condition:
lambda: return parse_number<float>(x).has_value();
then:
- lvgl.slider.update:
id: exercise_brightness_slider
value: !lambda return roundf(*parse_number<float>(x) * 100.0f / 255.0f);
- platform: homeassistant
id: exercise_color_temp
entity_id: ${light_exercise}
attribute: color_temp_kelvin
on_value:
- if:
condition:
lambda: return parse_number<float>(x).has_value();
then:
- lvgl.slider.update:
id: exercise_color_temp_slider
value: !lambda return *parse_number<float>(x);
# a running automation (e.g. its delay) has current > 0
- platform: homeassistant
id: training_start_running
entity_id: automation.zwift_enable_fan
attribute: current
on_value:
- script.execute: update_training_status
- platform: homeassistant
id: training_stop_running
entity_id: automation.zwift_disable_fan
attribute: current
on_value:
- script.execute: update_training_status
script:
- id: update_fan_buttons
then:
- lambda: |-
// fan level rounded to the 10 % steps of the buttons, 0 = off
int pct = 0;
if (id(fan_on).state && !std::isnan(id(fan_brightness).state))
pct = (int) roundf(id(fan_brightness).state * 10.0f / 255.0f) * 10;
const int levels[] = {0, 50, 60, 70, 80, 90, 100};
lv_obj_t *matrix = id(fan_matrix)->obj;
for (int i = 0; i < 7; i++) {
if (levels[i] == pct)
lv_buttonmatrix_set_button_ctrl(matrix, i, LV_BUTTONMATRIX_CTRL_CHECKED);
else
lv_buttonmatrix_clear_button_ctrl(matrix, i, LV_BUTTONMATRIX_CTRL_CHECKED);
}
- lvgl.label.update:
id: fan_status_label
text: !lambda |-
if (!id(fan_on).state)
return std::string("Ventilátor vypnutý");
if (std::isnan(id(fan_brightness).state))
return std::string("Ventilátor zapnutý");
return str_sprintf("Ventilátor %.0f %%", id(fan_brightness).state * 100.0f / 255.0f);
- id: update_training_status
then:
- lvgl.label.update:
id: training_status_label
text: !lambda |-
if (id(training_start_running).state != "0" && !id(training_start_running).state.empty())
return std::string("Tréning sa spúšťa, ventilátor sa zapne o 10 min");
if (id(training_stop_running).state != "0" && !id(training_stop_running).state.empty())
return std::string("Tréning sa ukončuje, ventilátor sa vypne o 2 min");
return std::string("");
- id: update_stopwatch
then:
- lvgl.label.update:
id: stopwatch_label
text: !lambda |-
const uint32_t ms = id(sw_accum) + (id(sw_running) ? millis() - id(sw_start) : 0);
const uint32_t s = ms / 1000;
if (s >= 3600)
return str_sprintf("%u:%02u:%02u", s / 3600, (s / 60) % 60, s % 60);
return str_sprintf("%02u:%02u.%u", s / 60, s % 60, (ms / 100) % 10);
- lvgl.label.update:
id: stopwatch_start_label
text: !lambda |-
return std::string(id(sw_running) ? "Stop" : "Štart");
- id: update_status
then:
- lvgl.label.update:
id: status_label
text: !lambda |-
std::string out;
if (wifi::global_wifi_component->is_connected()) {
out = str_sprintf("WiFi: connected (%d dBm)", wifi::global_wifi_component->wifi_rssi());
// IPv6 first (skip link-local), then IPv4
char buf[network::IP_ADDRESS_BUFFER_SIZE];
for (bool v6 : {true, false}) {
for (auto &ip : wifi::global_wifi_component->get_ip_addresses()) {
if (!ip.is_set() || ip.is_ip6() != v6)
continue;
ip.str_to(buf);
if (v6 && strncmp(buf, "fe80", 4) == 0)
continue;
out += "\n ";
out += buf;
}
}
} else {
out = "WiFi: connecting...";
}
out += api::global_api_server->is_connected_with_state_subscription() ? "\nHome Assistant: connected"
: "\nHome Assistant: waiting...";
const uint32_t up = millis() / 1000;
out += str_sprintf("\nESPHome %s · up %ud %02u:%02u", ESPHOME_VERSION, up / 86400, (up / 3600) % 24,
(up / 60) % 60);
return out;
interval:
- interval: 1s
then:
- lvgl.label.update:
id: clock_label
text: !lambda |-
auto now = id(ha_time).now();
if (!now.is_valid())
return std::string("--:--:--");
return now.strftime("%H:%M:%S");
- interval: 100ms
then:
- if:
condition:
and:
- lambda: return id(sw_running);
- lvgl.page.is_showing: stopwatch_page
then:
- script.execute: update_stopwatch
- interval: 2s
then:
- if:
condition:
lvgl.page.is_showing: status_page
then:
- script.execute: update_status
image:
- platform: file
file: assets/images/made-for-esphome-white-on-black.svg
id: esphome_logo
resize: 280x83
type: RGB565
transparency: alpha_channel
font:
- file: "gfonts://Roboto@500"
id: font_digits
size: 110
glyphs: "0123456789:.-"
- file: "gfonts://Roboto"
id: font_ui
size: 24
glyphs: &sk_glyphs "ÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽáäčďéíĺľňóôŕšťúýž°·"
glyphsets: [GF_Latin_Kernel]
- file: "gfonts://Roboto@500"
id: font_button
size: 34
glyphs: *sk_glyphs
glyphsets: [GF_Latin_Kernel]
- file: "gfonts://Roboto"
id: font_small
size: 18
glyphs: *sk_glyphs
glyphsets: [GF_Latin_Kernel]
lvgl: lvgl:
rotation: 90 rotation: 90
buffer_size: 100% buffer_size: 100%
log_level: WARN log_level: WARN
color_depth: 16 color_depth: 16
default_font: montserrat_18 default_font: font_ui
# on_idle: bg_color: 0x000000
# - timeout: !lambda "return (id(display_timeout).state * 1000);" text_color: 0xFFFFFF
# then: theme:
# - logger.log: "LVGL is idle" button:
# - light.turn_off: bg_color: 0x2B2F36
# id: display_backlight radius: 12
# transition_length: 2500ms checked:
# - lvgl.pause: bg_color: 0xE08A00
buttonmatrix:
bg_opa: TRANSP
border_width: 0
items:
bg_color: 0x2B2F36
text_color: 0xFFFFFF
radius: 12
checked:
bg_color: 0xE08A00
slider:
bg_color: 0x2B2F36
indicator:
bg_color: 0xE08A00
knob:
bg_color: 0xFFFFFF
top_layer: top_layer:
widgets: widgets:
- buttonmatrix: - buttonmatrix:
id: nav_bar
align: bottom_mid align: bottom_mid
# styles: header_footer width: 100%
pad_all: 0 height: 56
outline_width: 0 pad_all: 4
id: top_layer pad_column: 6
# items: items:
# styles: header_footer text_font: montserrat_28
rows: rows:
- buttons: - buttons:
- id: page_prev - id: page_prev
text: "\uF053" text: ""
on_press: on_press:
then: then:
lvgl.page.previous: lvgl.page.previous:
- id: page_home - id: page_home
text: "\uF015" text: ""
on_press: on_press:
then: then:
lvgl.page.show: main_page lvgl.page.show: clock_page
- id: page_next - id: page_next
text: "\uF054" text: ""
on_press: on_press:
then: then:
lvgl.page.next: lvgl.page.next:
pages: pages:
- id: main_page # 1: clock (default after boot)
- id: clock_page
widgets: widgets:
- label: - label:
text: "ESPHome LVGL Display" id: clock_label
align: CENTER align: CENTER
text_align: CENTER y: -28
text_color: 0xFFFFFF text_font: font_digits
- id: excercise_page text: "--:--:--"
# 2: basement lights
- id: lights_page
pad_all: 10
widgets: widgets:
- label:
align: CENTER
text: 'Hello World!'
- button: - button:
id: exercise_room_light_btn id: basement_light_btn
align: TOP_LEFT align: TOP_LEFT
width: 100 width: 148
height: 70 height: 80
checkable: true checkable: true
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: 'Light' text: "Pivnica"
on_click: on_click:
- homeassistant.action: - homeassistant.action:
action: light.toggle action: light.toggle
data: data:
entity_id: light.exercise_room entity_id: ${light_basement}
- button: - button:
id: exercise_room_fan_btn id: workbench_light_btn
align: TOP_RIGHT align: TOP_MID
width: 100 width: 148
height: 70 height: 80
checkable: true checkable: true
widgets: widgets:
- label: - label:
align: CENTER align: CENTER
text: 'Fan' text: "Pracovný stôl"
on_click: on_click:
- homeassistant.action: - homeassistant.action:
action: light.toggle action: light.toggle
data: data:
entity_id: light.mcu_dimmer_exercise_room_fan entity_id: ${light_workbench}
- button:
id: exercise_light_btn
align: TOP_RIGHT
width: 148
height: 80
checkable: true
widgets:
- label:
align: CENTER
text: "Posilka"
on_click:
- homeassistant.action:
action: light.toggle
data:
entity_id: ${light_exercise}
- obj:
id: exercise_controls
hidden: true
y: 110
width: 460
height: 120
pad_all: 0
bg_opa: TRANSP
border_width: 0
scrollbar_mode: "OFF"
widgets:
- label:
x: 4
y: 0
text_font: font_small
text: "Posilka jas"
- slider:
id: exercise_brightness_slider
x: 14
y: 32
width: 430
height: 16
min_value: 1
max_value: 100
value: 100
on_release:
- homeassistant.action:
action: light.turn_on
data:
entity_id: ${light_exercise}
brightness_pct: !lambda return (int) x;
- label:
x: 4
y: 62
text_font: font_small
text: "Posilka teplota (2700 – 6500 K)"
- slider:
id: exercise_color_temp_slider
x: 14
y: 94
width: 430
height: 16
min_value: 2700
max_value: 6500
value: 2700
on_release:
- homeassistant.action:
action: light.turn_on
data:
entity_id: ${light_exercise}
color_temp_kelvin: !lambda return (int) x;
# 3: training start / stop
- id: training_page
pad_all: 10
widgets:
- button:
align: TOP_LEFT
y: 10
width: 222
height: 150
bg_color: 0x1E7D32
widgets:
- label:
align: CENTER
text_font: font_button
text: "Štart"
on_click:
- homeassistant.action:
action: automation.trigger
data:
entity_id: automation.zwift_enable_fan
- button:
align: TOP_RIGHT
y: 10
width: 222
height: 150
bg_color: 0xB71C1C
widgets:
- label:
align: CENTER
text_font: font_button
text: "Stop"
on_click:
- homeassistant.action:
action: automation.trigger
data:
entity_id: automation.zwift_disable_fan
- label:
id: training_status_label
align: TOP_MID
y: 185
width: 440
text_align: CENTER
text_font: font_small
text: ""
# 4: fan
- id: fan_page
pad_all: 10
widgets:
- buttonmatrix:
id: fan_matrix
align: TOP_MID
width: 460
height: 180
pad_row: 8
pad_column: 8
one_checked: true
items:
text_font: font_button
rows:
- buttons:
- text: "Vyp"
control:
checkable: true
on_press:
- homeassistant.action:
action: light.turn_off
data:
entity_id: ${fan}
- text: "50"
control:
checkable: true
on_press:
- homeassistant.action:
action: light.turn_on
data:
entity_id: ${fan}
brightness_pct: "50"
- text: "60"
control:
checkable: true
on_press:
- homeassistant.action:
action: light.turn_on
data:
entity_id: ${fan}
brightness_pct: "60"
- text: "70"
control:
checkable: true
on_press:
- homeassistant.action:
action: light.turn_on
data:
entity_id: ${fan}
brightness_pct: "70"
- buttons:
- text: "80"
control:
checkable: true
on_press:
- homeassistant.action:
action: light.turn_on
data:
entity_id: ${fan}
brightness_pct: "80"
- text: "90"
control:
checkable: true
on_press:
- homeassistant.action:
action: light.turn_on
data:
entity_id: ${fan}
brightness_pct: "90"
- text: "100"
control:
checkable: true
on_press:
- homeassistant.action:
action: light.turn_on
data:
entity_id: ${fan}
brightness_pct: "100"
- label:
id: fan_status_label
align: TOP_MID
y: 200
text_font: font_small
text: ""
# 5: stopwatch
- id: stopwatch_page
pad_all: 10
widgets:
- label:
id: stopwatch_label
align: TOP_MID
y: 10
text_font: font_digits
text: "00:00.0"
- button:
align: TOP_LEFT
y: 150
width: 222
height: 90
widgets:
- label:
id: stopwatch_start_label
align: CENTER
text_font: font_button
text: "Štart"
on_click:
- lambda: |-
if (id(sw_running)) {
id(sw_accum) += millis() - id(sw_start);
id(sw_running) = false;
} else {
id(sw_start) = millis();
id(sw_running) = true;
}
- script.execute: update_stopwatch
- button:
align: TOP_RIGHT
y: 150
width: 222
height: 90
widgets:
- label:
align: CENTER
text_font: font_button
text: "Reset"
on_click:
- lambda: |-
id(sw_accum) = 0;
id(sw_start) = millis();
- script.execute: update_stopwatch
# 6: status
- id: status_page
on_load:
- script.execute: update_status
widgets:
- image:
src: esphome_logo
align: TOP_MID
y: 8
- label:
align: TOP_MID
y: 96
text: "${name}"
- label:
id: status_label
x: 20
y: 130
text_font: font_small
text: ""
-4
View File
@@ -158,10 +158,6 @@ text_sensor:
icon: mdi:clock-start icon: mdi:clock-start
internal: True internal: True
time:
- platform: homeassistant
id: time_comp
number: number:
- platform: template - platform: template
initial_value: 0 initial_value: 0
+564 -77
View File
@@ -4,94 +4,370 @@ substitutions:
name: M5Paper name: M5Paper
area: DEV area: DEV
comment: M5Paper comment: M5Paper
weather_entity: weather.openweathermap
# Power modes
# - USB: always on, WiFi + API, forecast refreshed every hour and redrawn every 15 min
# - battery: RTC wakes the board every 15 min (off 1:00-5:00), it draws the forecast
# from EEPROM and powers itself off; WiFi only to refresh the forecast every 6 h
# Screens: status (after power-on, until Home Assistant connects) and forecast
# The mode is detected by trying to power off: on USB the board keeps running.
esphome: esphome:
name: mcu-${device} name: mcu-${device}
friendly_name: ${name} friendly_name: ${name}
area: ${area} area: ${area}
comment: ${comment} comment: ${comment}
includes:
- include/m5paper_weather.h
on_boot:
- priority: 600
then:
- lambda: |-
// BM8563 CONTROL_STATUS_2: TF (bit 2) is set when the timer woke us up
const uint8_t reg = 0x01;
uint8_t status = 0;
if (id(i2c_main)->write_readv(0x51, &reg, 1, &status, 1) == i2c::ERROR_OK) {
id(rtc_wake) = status & 0x04;
// clear TF and TIE so the RTC releases the power latch
const uint8_t clear[2] = {reg, uint8_t(status & ~0x05)};
id(i2c_main)->write(0x51, clear, 2);
}
m5paper_weather::load(id(i2c_main), m5paper_weather::forecast());
ESP_LOGI("m5paper", "Wake-up: %s, forecast: %u h", id(rtc_wake) ? "RTC" : "power-on", m5paper_weather::forecast().count);
- priority: -100
then:
- if:
condition:
lambda: return id(rtc_wake);
then:
- script.execute: battery_cycle
else:
# power button / USB: stay awake for a while (logs, OTA), then decide the mode
- wifi.enable:
# IT8951 init runs asynchronously after setup
- delay: 3s
- component.update: m5paper_display
- delay: 2min
- script.execute: battery_cycle
esp32: esp32:
variant: esp32 variant: esp32
toolchain: esp-idf toolchain: esp-idf
framework: framework:
type: esp-idf type: esp-idf
advanced:
flash_chip: generic
minimum_chip_revision: 3.0
sram1_as_iram: true
psram: psram:
external_components:
- source: github://Passific/m5paper_esphome
packages: packages:
common: !include common/common.yaml common: !include common/common.yaml
time: globals:
- platform: homeassistant - id: show_status
id: ha_time type: bool
timezone: Europe/Bratislava initial_value: 'true'
on_time_sync: - id: usb_mode
- bm8563.write_time type: bool
- platform: bm8563 initial_value: 'false'
id: rtc_time - id: rtc_wake
sleep_duration: 3600000ms type: bool
initial_value: 'false'
# battery cycle running: forecast screen regardless of the connection state
- id: weather_page
type: bool
initial_value: 'false'
- id: fetch_done
type: bool
initial_value: 'false'
- id: sleep_s
type: int
initial_value: '900'
m5paper: wifi:
battery_power_pin: GPIO5 enable_on_boot: false
main_power_pin: GPIO2 on_connect:
# update_interval: 10s - if:
# battery_voltage: condition:
# name: "${name} Battery" lambda: return id(show_status) && !id(weather_page);
then:
- component.update: m5paper_display
api:
# status screen until Home Assistant connects (log viewers don't subscribe to states)
on_client_connected:
- delay: 2s
- if:
condition:
api.connected:
state_subscription_only: true
then:
- lambda: id(show_status) = false;
- if:
condition:
lambda: return !id(weather_page);
then:
- component.update: m5paper_display
# outside the battery cycle: refresh a forecast older than an hour
- if:
condition:
and:
- lambda: |-
const auto &fc = m5paper_weather::forecast();
const auto now = id(rtc_time).now();
return !id(weather_page) && (!fc.valid() || !now.is_valid() || now.timestamp - fc.fetched_at >= 3600);
- api.connected:
state_subscription_only: true
then:
- script.execute: fetch_forecast
on_client_disconnected:
- if:
condition:
not:
api.connected:
state_subscription_only: true
then:
- lambda: id(show_status) = true;
- if:
condition:
lambda: return !id(weather_page);
then:
- component.update: m5paper_display
# I2C devices (i2c_main)
# 0x44 SHT30 # 0x44 SHT30
# 0x50 # 0x50 FM24C02 EEPROM (forecast storage, see include/m5paper_weather.h)
# 0x51 # 0x51 BM8563 RTC
# 0x5D GT911 # 0x5D GT911 touch (docs say 0x14; the driver tries 0x5D, then 0x14)
i2c: i2c:
- id: i2c_main - id: i2c_main
sda: 21 sda: 21
scl: 22 scl: 22
scan: true #false scan: true
# Port A
# - id: i2c_a # - id: i2c_a
# sda: 25 # sda: 25
# scl: 32 # scl: 32
# scan: true #false # Port B
# - id: i2c_b # - id: i2c_b
# sda: 26 # sda: 26
# scl: 33 # scl: 33
# scan: true #false # Port C
# - id: i2c_c # - id: i2c_c
# sda: 18 # sda: 18
# scl: 19 # scl: 19
# scan: true #false
spi: spi:
clk_pin: 14 clk_pin: 14
miso_pin: 13 miso_pin: 13
mosi_pin: 12 mosi_pin: 12
switch:
# Power control MOS (no PMU on M5Paper); on battery the device powers off when this goes low
- platform: gpio
id: main_power
pin:
number: GPIO2
ignore_strapping_warning: true
restore_mode: ALWAYS_ON
internal: true
# External power (Grove ports)
- platform: gpio
id: ext_power
pin:
number: GPIO5
ignore_strapping_warning: true
restore_mode: ALWAYS_ON
internal: true
script:
# One wake-up on battery: refresh the forecast if it is old, draw it, power off
- id: battery_cycle
mode: single
then:
- lambda: id(weather_page) = true;
- wait_until:
condition:
lambda: return id(rtc_time).now().is_valid();
timeout: 10s
- if:
condition:
lambda: |-
const auto &fc = m5paper_weather::forecast();
const auto now = id(rtc_time).now();
return !fc.valid() || !now.is_valid() || now.timestamp - fc.fetched_at >= 6 * 3600 - 600;
then:
- wifi.enable:
- wait_until:
condition:
api.connected:
state_subscription_only: true
timeout: 45s
- if:
condition:
api.connected:
state_subscription_only: true
then:
- script.execute: fetch_forecast
- wait_until:
condition:
lambda: return id(fetch_done);
timeout: 15s
- wait_until:
condition:
lambda: return !std::isnan(id(current_temperature).state);
timeout: 5s
- component.update: m5paper_display
# let the e-paper refresh finish before cutting power
- delay: 4s
- script.execute: try_sleep
- id: fetch_forecast
mode: single
then:
- lambda: id(fetch_done) = false;
- homeassistant.action:
action: weather.get_forecasts
data:
entity_id: ${weather_entity}
type: hourly
capture_response: true
on_success:
- lambda: |-
auto &fc = m5paper_weather::forecast();
if (m5paper_weather::parse(response, "${weather_entity}", id(rtc_time).now().timestamp, fc)) {
const bool saved = m5paper_weather::save(id(i2c_main), fc);
ESP_LOGI("m5paper", "Forecast: %u h%s", fc.count, saved ? "" : " (EEPROM write failed)");
} else {
ESP_LOGW("m5paper", "Forecast: unexpected response");
}
id(fetch_done) = true;
# the battery cycle draws on its own
- if:
condition:
lambda: return !id(weather_page);
then:
- component.update: m5paper_display
on_error:
- lambda: |-
ESP_LOGW("m5paper", "Forecast: %s", error.c_str());
id(fetch_done) = true;
# Arm the RTC and cut power; if we are still running afterwards, we are on USB
- id: try_sleep
mode: single
then:
- lambda: |-
// next quarter hour, 1:00-5:00 in one go
int duration = 900;
const auto now = id(rtc_time).now();
if (now.is_valid()) {
const int sod = now.hour * 3600 + now.minute * 60 + now.second;
int next = (sod / 900 + 1) * 900;
if (next - sod < 90) // the 1/60 Hz RTC timer may wake us up to a minute early
next += 900;
const int next_hour = (next / 3600) % 24;
if (next_hour >= 1 && next_hour < 5)
next = (next / 86400) * 86400 + 5 * 3600;
duration = next - sod;
}
if (duration > 255) // longer timers count whole minutes
duration = (duration + 59) / 60 * 60;
id(sleep_s) = std::min(duration, 255 * 60);
ESP_LOGI("m5paper", "Powering off for %d s", id(sleep_s));
- bm8563.start_timer:
duration: !lambda return id(sleep_s);
# a battery wake-up is shorter than boot_is_good_after: confirm the OTA image
# before cutting power, otherwise the bootloader rolls back
- safe_mode.mark_successful:
- delay: 200ms
- switch.turn_off: main_power
- delay: 2s
# still alive -> USB power
- switch.turn_on: main_power
- lambda: |-
ESP_LOGI("m5paper", "Still running, USB power");
id(usb_mode) = true;
id(weather_page) = false;
id(show_status) = !api::global_api_server->is_connected_with_state_subscription();
- wifi.enable:
- component.update: m5paper_display
interval:
# USB mode: the battery voltage drops once USB is unplugged -> go to battery mode
- interval: 5min
then:
- if:
condition:
lambda: |-
static float last = NAN;
const float v = id(battery_voltage).state;
const bool dropped = id(usb_mode) && !std::isnan(last) && last - v > 0.03f;
last = v;
return dropped;
then:
- lambda: |-
ESP_LOGI("m5paper", "Battery voltage dropping, checking USB");
id(usb_mode) = false;
- script.execute: battery_cycle
time:
- id: !extend ha_time
on_time_sync:
- bm8563.write_time
- if:
condition:
lambda: return !id(weather_page);
then:
- component.update: m5paper_display
on_time:
- seconds: 0
minutes: /15
then:
- if:
condition:
lambda: return !id(show_status) && !id(weather_page);
then:
- component.update: m5paper_display
# USB mode: keep the stored forecast fresh for battery mode
- seconds: 0
minutes: 5
then:
- if:
condition:
and:
- lambda: return id(usb_mode);
- api.connected:
state_subscription_only: true
then:
- script.execute: fetch_forecast
- platform: bm8563
id: rtc_time
binary_sensor: binary_sensor:
- platform: gpio - platform: gpio
name: ${name} Button UP name: ${name} Button UP
id: button_up id: button_up
pin: pin:
number: 37 number: GPIO37
inverted: true inverted: true
on_press: on_press:
- component.update: m5paper_display - component.update: m5paper_display
on_release:
- component.update: m5paper_display
- platform: gpio - platform: gpio
name: ${name} Button Press name: ${name} Button Press
id: button_press id: button_press
pin: pin:
number: 38 number: GPIO38
inverted: true inverted: true
- platform: gpio - platform: gpio
name: ${name} Button Down name: ${name} Button Down
id: button_down id: button_down
pin: pin:
number: 39 number: GPIO39
inverted: true inverted: true
- platform: touchscreen - platform: touchscreen
name: ${name} Top Left Touch Button name: ${name} Top Left Touch Button
@@ -103,51 +379,19 @@ binary_sensor:
y_min: 0 y_min: 0
y_max: 100 y_max: 100
on_press: on_press:
# Example of applying the sleep duration and shutting down to the lowest power mode - script.execute: battery_cycle
- bm8563.apply_sleep_duration
- delay: 1s
- m5paper.shutdown_main_power
font: button:
- file: "gfonts://Roboto" - platform: template
id: font_default name: Refresh Display
size: 20 entity_category: config
- file: "gfonts://Roboto" on_press:
id: roboto_32 - component.update: m5paper_display
size: 32 - platform: template
- file: "gfonts://Roboto" name: Refresh Forecast
id: roboto_24 entity_category: config
size: 24 on_press:
- file: "gfonts://Roboto" - script.execute: fetch_forecast
id: roboto_12
size: 12
display:
- platform: it8951e
id: m5paper_display
cs_pin: GPIO15
reset_pin: GPIO23
busy_pin: GPIO27
rotation: 90
reversed: False
update_interval: "never"
lambda: |-
it.printf(25, 25, id(roboto_32), "%.1f°", id(current_temperature).state);
it.strftime(350, 25, id(roboto_32), "%H:%M:%S", id(rtc_time).now());
touchscreen:
- platform: gt911
display: m5paper_display
id: gt911_touchscreen
interrupt_pin: GPIO36
# display:
# - platform: waveshare_epaper
# cs_pin: 15
# model: 2.90in
# full_update_every: 30
# lambda: |-
# it.print(0, 0, id(font1), "Hello World!");
sensor: sensor:
- platform: sht3xd - platform: sht3xd
@@ -156,9 +400,252 @@ sensor:
id: current_temperature id: current_temperature
humidity: humidity:
name: Humidity name: Humidity
id: current_humidity
address: 0x44 address: 0x44
update_interval: 60s update_interval: 60s
- platform: adc
id: battery_voltage
name: Battery Voltage
pin: GPIO35
attenuation: 12db
samples: 16
update_interval: 60s
entity_category: diagnostic
filters:
- multiply: 2.0 # 1:2 voltage divider
- platform: copy
source_id: battery_voltage
id: battery_level
name: Battery
unit_of_measurement: "%"
device_class: battery
accuracy_decimals: 0
filters:
- calibrate_linear:
- 3.3 -> 0.0
- 4.15 -> 100.0
- clamp:
min_value: 0
max_value: 100
# touchscreen: .image_defaults:
# - platform: gt911 icon_large: &icon_large {platform: file, type: BINARY, transparency: chroma_key, resize: 150x150}
# interrupt_pin: 36 icon_small: &icon_small {platform: file, type: BINARY, transparency: chroma_key, resize: 44x44}
image:
- platform: file
file: assets/images/made-for-esphome-white-on-black.svg
id: esphome_logo
resize: 460x136
type: GRAYSCALE
transparency: alpha_channel
# weather icons, same order as m5paper_weather::CONDITIONS
- {<<: *icon_large, file: "mdi:weather-night", id: wl_clear_night}
- {<<: *icon_large, file: "mdi:weather-cloudy", id: wl_cloudy}
- {<<: *icon_large, file: "mdi:alert-circle-outline", id: wl_exceptional}
- {<<: *icon_large, file: "mdi:weather-fog", id: wl_fog}
- {<<: *icon_large, file: "mdi:weather-hail", id: wl_hail}
- {<<: *icon_large, file: "mdi:weather-lightning", id: wl_lightning}
- {<<: *icon_large, file: "mdi:weather-lightning-rainy", id: wl_lightning_rainy}
- {<<: *icon_large, file: "mdi:weather-partly-cloudy", id: wl_partlycloudy}
- {<<: *icon_large, file: "mdi:weather-pouring", id: wl_pouring}
- {<<: *icon_large, file: "mdi:weather-rainy", id: wl_rainy}
- {<<: *icon_large, file: "mdi:weather-snowy", id: wl_snowy}
- {<<: *icon_large, file: "mdi:weather-snowy-rainy", id: wl_snowy_rainy}
- {<<: *icon_large, file: "mdi:weather-sunny", id: wl_sunny}
- {<<: *icon_large, file: "mdi:weather-windy", id: wl_windy}
- {<<: *icon_large, file: "mdi:weather-windy-variant", id: wl_windy_variant}
- {<<: *icon_small, file: "mdi:weather-night", id: ws_clear_night}
- {<<: *icon_small, file: "mdi:weather-cloudy", id: ws_cloudy}
- {<<: *icon_small, file: "mdi:alert-circle-outline", id: ws_exceptional}
- {<<: *icon_small, file: "mdi:weather-fog", id: ws_fog}
- {<<: *icon_small, file: "mdi:weather-hail", id: ws_hail}
- {<<: *icon_small, file: "mdi:weather-lightning", id: ws_lightning}
- {<<: *icon_small, file: "mdi:weather-lightning-rainy", id: ws_lightning_rainy}
- {<<: *icon_small, file: "mdi:weather-partly-cloudy", id: ws_partlycloudy}
- {<<: *icon_small, file: "mdi:weather-pouring", id: ws_pouring}
- {<<: *icon_small, file: "mdi:weather-rainy", id: ws_rainy}
- {<<: *icon_small, file: "mdi:weather-snowy", id: ws_snowy}
- {<<: *icon_small, file: "mdi:weather-snowy-rainy", id: ws_snowy_rainy}
- {<<: *icon_small, file: "mdi:weather-sunny", id: ws_sunny}
- {<<: *icon_small, file: "mdi:weather-windy", id: ws_windy}
- {<<: *icon_small, file: "mdi:weather-windy-variant", id: ws_windy_variant}
font:
- file: "gfonts://Roboto"
id: font_title
size: 48
- file: "gfonts://Roboto"
id: font_status
size: 28
glyphs: &sk_glyphs "ÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽáäčďéíĺľňóôŕšťúýž°·"
glyphsets: [GF_Latin_Kernel]
- file: "gfonts://Roboto"
id: font_small
size: 24
glyphs: *sk_glyphs
glyphsets: [GF_Latin_Kernel]
- file: "gfonts://Roboto"
id: font_tiny
size: 18
glyphs: "0123456789% "
- file: "gfonts://Roboto@500"
id: font_date
size: 34
glyphs: *sk_glyphs
glyphsets: [GF_Latin_Kernel]
- file: "gfonts://Roboto@500"
id: font_hour
size: 28
glyphs: "0123456789-°"
- file: "gfonts://Roboto"
id: font_temp
size: 110
glyphs: "0123456789.,-°C "
color:
- id: ink
red: 0%
green: 0%
blue: 0%
- id: paper
red: 100%
green: 100%
blue: 100%
display:
- platform: it8951
id: m5paper_display
model: m5stack-m5paper
rotation: 270
update_interval: never
# 1bpp: updates use the fast DU waveform, full GC16 refresh every 30 updates
grayscale: false
dithering: false
lambda: |-
// 540x960 portrait
it.fill(id(paper));
const int w = it.get_width();
const int cx = w / 2;
const float batt = id(battery_level).state;
const float temp = id(current_temperature).state;
const float hum = id(current_humidity).state;
// status screen after power-on, until Home Assistant connects
if (id(show_status) && !id(weather_page)) {
it.image(cx, 200, id(esphome_logo), ImageAlign::CENTER);
it.print(cx, 320, id(font_title), id(ink), TextAlign::CENTER, "${name}");
const bool wifi_ok = wifi::global_wifi_component->is_connected();
if (wifi_ok) {
it.printf(40, 440, id(font_status), id(ink), "WiFi: connected (%d dBm)", wifi::global_wifi_component->wifi_rssi());
// IPv6 first (skip link-local), then IPv4
int y = 484;
char buf[network::IP_ADDRESS_BUFFER_SIZE];
for (bool v6 : {true, false}) {
for (auto &ip : wifi::global_wifi_component->get_ip_addresses()) {
if (!ip.is_set() || ip.is_ip6() != v6 || y > 590)
continue;
ip.str_to(buf);
if (v6 && strncmp(buf, "fe80", 4) == 0)
continue;
it.print(60, y, id(font_small), id(ink), buf);
y += 34;
}
}
} else {
it.print(40, 440, id(font_status), id(ink), "WiFi: connecting...");
}
const bool api_ok = api::global_api_server->is_connected_with_state_subscription();
it.print(40, 620, id(font_status), id(ink),
api_ok ? "Home Assistant: connected" : "Home Assistant: waiting...");
if (!std::isnan(batt))
it.printf(40, 670, id(font_status), id(ink), "Battery: %.0f%% (%.2f V)", batt, id(battery_voltage).state);
it.printf(40, 720, id(font_status), id(ink), "Forecast: %u h stored", m5paper_weather::forecast().count);
it.printf(40, 900, id(font_small), id(ink), "ESPHome %s", ESPHOME_VERSION);
return;
}
// forecast screen
using namespace m5paper_weather;
image::Image *large[] = {id(wl_clear_night), id(wl_cloudy), id(wl_exceptional), id(wl_fog),
id(wl_hail), id(wl_lightning), id(wl_lightning_rainy), id(wl_partlycloudy),
id(wl_pouring), id(wl_rainy), id(wl_snowy), id(wl_snowy_rainy),
id(wl_sunny), id(wl_windy), id(wl_windy_variant)};
image::Image *small[] = {id(ws_clear_night), id(ws_cloudy), id(ws_exceptional), id(ws_fog),
id(ws_hail), id(ws_lightning), id(ws_lightning_rainy), id(ws_partlycloudy),
id(ws_pouring), id(ws_rainy), id(ws_snowy), id(ws_snowy_rainy),
id(ws_sunny), id(ws_windy), id(ws_windy_variant)};
static const char *const DAYS[] = {"Nedeľa", "Pondelok", "Utorok", "Streda", "Štvrtok", "Piatok", "Sobota"};
const auto &fc = m5paper_weather::forecast();
const auto now = id(rtc_time).now();
// header: date, battery
if (now.is_valid())
it.printf(24, 16, id(font_date), id(ink), "%s %d. %d.", DAYS[(now.day_of_week + 6) % 7], now.day_of_month,
now.month);
if (!std::isnan(batt))
it.printf(w - 24, 22, id(font_small), id(ink), TextAlign::TOP_RIGHT, "%.0f %%", batt);
const Hour *cur = now.is_valid() ? fc.at(now.timestamp) : nullptr;
if (cur == nullptr) {
it.print(cx, 230, id(font_status), id(ink), TextAlign::CENTER, "Predpoveď nie je k dispozícii");
} else {
// current hour
if (cur->cond < NUM_CONDITIONS)
it.image(24, 80, large[cur->cond], id(ink));
if (cur->temp2 != NO_TEMP)
it.printf(200, 70, id(font_temp), id(ink), "%.0f°", cur->temp2 / 2.0f);
it.print(204, 200, id(font_status), id(ink), condition_name(cur->cond));
if (cur->wind != NO_VALUE)
it.printf(204, 245, id(font_small), id(ink), "vietor %u km/h", cur->wind);
if (cur->pop != NO_VALUE)
it.printf(204, 280, id(font_small), id(ink), "zrážky %u %%", cur->pop);
// next 24 hours: 3 columns x 8 rows
const uint32_t first = (now.timestamp / 3600 + 1) * 3600;
const int top = 440, row_h = 60, col_w = w / 3;
for (int i = 0; i < 24; i++) {
const Hour *h = fc.at(first + i * 3600);
if (h == nullptr)
break;
const int x = (i / 8) * col_w + 12;
const int y = top + (i % 8) * row_h;
const auto t = ESPTime::from_epoch_local(first + i * 3600);
it.printf(x, y + 14, id(font_hour), id(ink), "%02d", t.hour);
if (h->cond < NUM_CONDITIONS)
it.image(x + 42, y + 8, small[h->cond], id(ink));
if (h->temp2 != NO_TEMP)
it.printf(x + 94, y + 6, id(font_hour), id(ink), "%.0f°", h->temp2 / 2.0f);
if (h->pop != NO_VALUE && h->pop >= 10)
it.printf(x + 94, y + 38, id(font_tiny), id(ink), "%u %%", h->pop);
}
for (int c = 1; c < 3; c++)
it.vertical_line(c * col_w, top, 8 * row_h, id(ink));
}
// indoor
it.horizontal_line(24, 340, w - 48, id(ink));
it.printf(cx, 380, id(font_status), id(ink), TextAlign::CENTER, "Doma %.1f °C · %.0f %%", temp, hum);
it.horizontal_line(24, 420, w - 48, id(ink));
// footer
if (fc.valid()) {
const auto f = ESPTime::from_epoch_local(fc.fetched_at);
it.printf(24, 944, id(font_small), id(ink), TextAlign::BOTTOM_LEFT, "predpoveď %d. %d. %02d:%02d",
f.day_of_month, f.month, f.hour, f.minute);
}
if (now.is_valid())
it.printf(w - 24, 944, id(font_small), id(ink), TextAlign::BOTTOM_RIGHT, "%02d:%02d", now.hour, now.minute);
touchscreen:
- platform: gt911
id: gt911_touchscreen
display: m5paper_display
# GPIO36 is input-only; INT is only driven as output when reset_pin is set
interrupt_pin:
number: GPIO36
mode:
input: true
output: false
+1 -3
View File
@@ -228,9 +228,7 @@ button:
- pzemac.reset_energy: pzemac_l3 - pzemac.reset_energy: pzemac_l3
time: time:
- platform: homeassistant - id: !extend ha_time
id: ha_time
timezone: Europe/Bratislava
on_time: on_time:
- cron: '0 0 0 * * *' - cron: '0 0 0 * * *'
then: then:
+1 -3
View File
@@ -136,9 +136,7 @@ nspanel_lovelace:
thermo_entity_id: climate.bt_trv_bedroom thermo_entity_id: climate.bt_trv_bedroom
time: time:
- platform: homeassistant - id: !extend ha_time
id: ha_time
timezone: Europe/Bratislava
on_time_sync: on_time_sync:
- script.execute: apply_sleep_dim - script.execute: apply_sleep_dim
-5
View File
@@ -540,11 +540,6 @@ sensor:
- platform: template - platform: template
id: room_temperature_id id: room_temperature_id
time:
platform: homeassistant
id: ha_time
timezone: Europe/Bratislava
text_sensor: text_sensor:
- platform: homeassistant - platform: homeassistant
id: name_day id: name_day
-5
View File
@@ -185,11 +185,6 @@ nspanel_lovelace:
- name: Auto-zatváranie - name: Auto-zatváranie
entity_id: switch.gate_auto_close entity_id: switch.gate_auto_close
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Bratislava
binary_sensor: binary_sensor:
- platform: gpio - platform: gpio
name: Left Button name: Left Button
+33 -3
View File
@@ -38,6 +38,16 @@ external_components:
packages: packages:
common: !include common/common.yaml common: !include common/common.yaml
script:
- id: apply_sleep_dim
then:
- lambda: |-
uint8_t dim;
if (id(bedroom_lights).state) dim = 5; // sleepOverride
else if (id(sun_id).is_above_horizon(-6)) dim = 5; // day
else dim = 0; // night
id(nspanel_id).set_display_inactive_dim(dim);
api: api:
homeassistant_services: true homeassistant_services: true
homeassistant_states: true homeassistant_states: true
@@ -135,9 +145,9 @@ nspanel_lovelace:
entity_id: switch.gate_auto_close entity_id: switch.gate_auto_close
time: time:
- platform: homeassistant - id: !extend ha_time
id: ha_time on_time_sync:
timezone: Europe/Bratislava - script.execute: apply_sleep_dim
binary_sensor: binary_sensor:
- platform: gpio - platform: gpio
@@ -161,6 +171,12 @@ binary_sensor:
entity_id: light.bedside_left entity_id: light.bedside_left
- lambda: 'id(nspanel_id).render_default_page();' - lambda: 'id(nspanel_id).render_default_page();'
- platform: homeassistant # sleepOverride
id: bedroom_lights
entity_id: light.office_j_lights
on_state:
- script.execute: apply_sleep_dim
output: output:
- platform: ledc - platform: ledc
id: buzzer_out id: buzzer_out
@@ -186,6 +202,20 @@ light:
# # name: Relay 2 # # name: Relay 2
# # output: relay_2 # # output: relay_2
sun:
id: sun_id
time_id: ha_time
latitude: 48.15°
longitude: 17.11°
on_sunrise:
- elevation: -6°
then:
- script.execute: apply_sleep_dim
on_sunset:
- elevation: -6°
then:
- script.execute: apply_sleep_dim
switch: switch:
- platform: gpio - platform: gpio
name: Screen Power name: Screen Power
-5
View File
@@ -158,11 +158,6 @@ nspanel_lovelace:
- name: Auto-zatváranie - name: Auto-zatváranie
entity_id: switch.gate_auto_close entity_id: switch.gate_auto_close
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Bratislava
binary_sensor: binary_sensor:
- platform: gpio - platform: gpio
name: Left Button name: Left Button
-5
View File
@@ -214,11 +214,6 @@ nspanel_lovelace:
- name: Rast trávy - name: Rast trávy
entity_id: automation.irrigation_grow_grass entity_id: automation.irrigation_grow_grass
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Bratislava
binary_sensor: binary_sensor:
- platform: gpio - platform: gpio
name: Left Button name: Left Button
-5
View File
@@ -54,11 +54,6 @@ packages:
# Relay1 G22 # Relay1 G22
# Relay2 G19 # Relay2 G19
time:
- platform: homeassistant
id: ha_time
timezone: Europe/Bratislava
i2c: i2c:
# - id: i2c0 # - id: i2c0
# sda: 26 # sda: 26
-2
View File
@@ -187,5 +187,3 @@ switch:
- switch.turn_off: relay_off # bi-stable relay so no need to keep on - switch.turn_off: relay_off # bi-stable relay so no need to keep on
interlock: [relay_on] interlock: [relay_on]
time:
- !include common/time/homeassistant.yaml
+1 -4
View File
@@ -2,7 +2,7 @@
substitutions: substitutions:
device: pow-washer device: pow-washer
name: Washer POW name: Washer POW
comment: comment: "spare"
area: Washroom area: Washroom
update_interval: 10s update_interval: 10s
@@ -186,6 +186,3 @@ switch:
- delay: 500ms - delay: 500ms
- switch.turn_off: relay_off # bi-stable relay so no need to keep on - switch.turn_off: relay_off # bi-stable relay so no need to keep on
interlock: [relay_on] interlock: [relay_on]
time:
- !include common/time/homeassistant.yaml
-2
View File
@@ -109,5 +109,3 @@ switch:
number: 19 number: 19
mode: OUTPUT mode: OUTPUT
time:
- !include common/time/homeassistant.yaml
+1 -2
View File
@@ -121,8 +121,7 @@ switch:
# number: GPIO00 # number: GPIO00
# inverted: True # inverted: True
time: # time:
- !include common/time/homeassistant.yaml
# - platform: homeassistant # - platform: homeassistant
# timezone: Europe/Bratislava # timezone: Europe/Bratislava
# on_time: # on_time:
+1 -2
View File
@@ -122,8 +122,7 @@ switch:
on_turn_off: on_turn_off:
- light.turn_off: light_red - light.turn_off: light_red
time: # time:
- !include common/time/homeassistant.yaml
# - platform: homeassistant # - platform: homeassistant
# timezone: Europe/Bratislava # timezone: Europe/Bratislava
# on_time: # on_time:
+1 -2
View File
@@ -144,8 +144,7 @@ switch:
# number: GPIO00 # number: GPIO00
# inverted: True # inverted: True
time: # time:
- !include common/time/homeassistant.yaml
# - platform: homeassistant # - platform: homeassistant
# timezone: Europe/Bratislava # timezone: Europe/Bratislava
# on_time: # on_time:
+899
View File
@@ -0,0 +1,899 @@
substitutions:
device: stackchan
name: StackChan
area: DEV
comment: StackChan
esphome:
name: mcu-${device}
friendly_name: ${name}
area: ${area}
comment: ${comment}
on_boot:
- priority: -100
then:
- light.turn_on:
id: light_bar
brightness: 20%
red: 0%
green: 0%
blue: 100%
- component.update: display_tft
esp32:
variant: esp32s3
flash_size: 16MB
toolchain: esp-idf
framework:
type: esp-idf
psram:
mode: quad
speed: 80MHz
packages:
common: !include common/common.yaml
external_components:
- source: github://m5stack/esphome-yaml/components
components: [axp2101, aw88298, aw9523b, si12t, m5ioe1, ftservo, scs9009]
refresh: 0s
- source: github://randybb/esphome-components
components: [audio_spectrum]
refresh: 1d
globals:
- id: in_love
type: bool
initial_value: 'false'
- id: show_status
type: bool
initial_value: 'true'
- id: mouth_open
type: float
initial_value: '0.0'
- id: eyes_open
type: bool
initial_value: 'true'
- id: speaking
type: bool
initial_value: 'false'
- id: listening
type: bool
initial_value: 'false'
- id: gaze_x
type: int
initial_value: '0'
- id: gaze_y
type: int
initial_value: '0'
i2c:
- id: i2c_bsp
sda: 12
scl: 11
frequency: 100kHz
axp2101:
id: axp2101_pmu
i2c_id: i2c_bsp
aw9523b:
id: aw9523b_hub
i2c_id: i2c_bsp
p0_drive_mode: PUSH_PULL
m5ioe1:
id: m5ioe1_hub
i2c_id: i2c_bsp
reset: true
output:
- platform: axp2101
id: lcd_backlight_output
type: range
channel: DLDO1
min_voltage: 2600
max_voltage: 3300
- platform: axp2101
channel: ALDO1
voltage: 1800
- platform: axp2101
channel: ALDO2
voltage: 3300
- platform: axp2101
channel: ALDO3
voltage: 3300
- platform: axp2101
channel: ALDO4
voltage: 3300
- platform: axp2101
channel: BLDO1
voltage: 2800
- platform: axp2101
channel: BLDO2
voltage: 1500
- platform: axp2101
channel: DCDC1
voltage: 3300
- platform: axp2101
channel: DCDC4
voltage: 1800
- platform: axp2101
channel: DCDC3
voltage: 3300
- platform: axp2101
channel: DCDC5
voltage: 3300
switch:
- platform: gpio
id: boost_en
name: Boost
entity_category: config
pin:
aw9523b_id: aw9523b_hub
number: 15
restore_mode: RESTORE_DEFAULT_ON
- platform: gpio
id: usb_otg_en
name: USB OTG
entity_category: config
pin:
aw9523b_id: aw9523b_hub
number: 5
restore_mode: RESTORE_DEFAULT_OFF
- platform: gpio
id: bus_out_en
name: BUS Out
entity_category: config
pin:
aw9523b_id: aw9523b_hub
number: 1
restore_mode: RESTORE_DEFAULT_ON
- platform: gpio
id: light_bar_en
internal: true
pin:
m5ioe1_id: m5ioe1_hub
number: 13
mode:
output: true
pullup: true
restore_mode: ALWAYS_ON
- platform: gpio
id: aw_rst
internal: true
pin:
aw9523b_id: aw9523b_hub
number: 2
restore_mode: ALWAYS_ON
- platform: gpio
id: cam_rst
internal: true
pin:
aw9523b_id: aw9523b_hub
number: 8
restore_mode: ALWAYS_ON
- platform: gpio
id: servo_power
internal: true
pin:
m5ioe1_id: m5ioe1_hub
number: 0
mode:
output: true
pullup: true
restore_mode: ALWAYS_ON
i2s_audio:
- id: i2s_bus
i2s_lrclk_pin: 33
i2s_bclk_pin: 34
i2s_mclk_pin:
number: 0
ignore_strapping_warning: true
audio_adc:
- platform: es7210
id: es7210_adc
i2c_id: i2c_bsp
bits_per_sample: 16bit
sample_rate: 16000
mic_gain: 36
audio_dac:
- platform: aw88298
id: aw88298_dac
i2c_id: i2c_bsp
sample_rate: 48000
microphone:
- platform: i2s_audio
id: stackchan_microphone
i2s_audio_id: i2s_bus
i2s_din_pin: 14
adc_type: external
sample_rate: 16000
bits_per_sample: 16bit
speaker:
- platform: i2s_audio
id: speaker_id
i2s_audio_id: i2s_bus
dac_type: external
i2s_dout_pin: 13
audio_dac: aw88298_dac
sample_rate: 48000
buffer_duration: 100ms
# pass-through that computes the spectrum shown on the display while music plays
- platform: audio_spectrum
id: spectrum
output_speaker: speaker_id
bands: 20
- platform: mixer
id: mixer_speaker_id
output_speaker: spectrum
num_channels: 1
source_speakers:
- id: announcement_spk_mixer_input
- id: media_spk_mixer_input
- platform: resampler
id: media_spk_resampling_input
output_speaker: media_spk_mixer_input
- platform: resampler
id: announcement_spk_resampling_input
output_speaker: announcement_spk_mixer_input
media_player:
- platform: speaker
id: media_out
name: Player
buffer_size: 14000
# AW88298 maps volume linearly to -96..0 dB, the lower half is inaudible
volume_min: 0.5
volume_max: 1.0
media_pipeline:
speaker: media_spk_resampling_input
format: NONE
announcement_pipeline:
speaker: announcement_spk_resampling_input
format: NONE
on_announcement:
- script.execute: release_i2s_bus
on_play:
- script.execute: release_i2s_bus
on_idle:
- if:
condition:
not:
voice_assistant.is_running:
then:
- micro_wake_word.start:
micro_wake_word:
id: mww
microphone: stackchan_microphone
models:
- model: okay_nabu
on_wake_word_detected:
- voice_assistant.start:
wake_word: !lambda return wake_word;
voice_assistant:
id: va
microphone: stackchan_microphone
media_player: media_out
micro_wake_word: mww
noise_suppression_level: 2
auto_gain: 31dBFS
on_client_connected:
- micro_wake_word.start:
- delay: 2s
- lambda: id(show_status) = false;
- component.update: display_tft
on_client_disconnected:
- micro_wake_word.stop:
- lambda: id(show_status) = true;
- component.update: display_tft
on_listening:
- lambda: |-
id(listening) = true;
id(gaze_x) = 0;
id(gaze_y) = 0;
- component.update: display_tft
- light.turn_on: {id: light_bar, brightness: 60%, red: 0%, green: 60%, blue: 100%}
on_stt_end:
- lambda: id(listening) = false;
- component.update: display_tft
- light.turn_on: {id: light_bar, brightness: 40%, red: 80%, green: 50%, blue: 0%}
on_tts_start:
- lambda: id(speaking) = true;
- light.turn_on: {id: light_bar, brightness: 60%, red: 0%, green: 100%, blue: 20%}
on_end:
- wait_until:
not:
media_player.is_announcing: media_out
- lambda: |-
id(speaking) = false;
id(listening) = false;
id(mouth_open) = 0.0f;
- component.update: display_tft
- light.turn_on: {id: light_bar, brightness: 20%, red: 0%, green: 0%, blue: 100%}
- wait_until:
not: voice_assistant.is_running
- micro_wake_word.start:
on_error:
- light.turn_on: {id: light_bar, brightness: 60%, red: 100%, green: 0%, blue: 0%}
script:
# short tap on the head starts the assistant
- id: head_tap
then:
- if:
condition:
not: voice_assistant.is_running
then:
- voice_assistant.start:
# stroking the head shows the in-love face for a while
- id: head_stroke
mode: restart
then:
- lambda: id(in_love) = true;
- component.update: display_tft
- delay: 5s
- lambda: id(in_love) = false;
- component.update: display_tft
# mic and speaker share one I2S bus - the mic must be released before playback
- id: release_i2s_bus
mode: single
then:
- if:
condition:
microphone.is_capturing:
then:
- micro_wake_word.stop:
# stop VA only while it is listening (e.g. continued conversation), not during its own response
- if:
condition:
lambda: return id(listening);
then:
- voice_assistant.stop:
- wait_until:
condition:
not:
microphone.is_capturing:
timeout: 2s
interval:
- interval: 80ms # spectrum analyzer frame rate while music plays
then:
- if:
condition:
and:
- media_player.is_playing: media_out
- not: voice_assistant.is_running
then:
- component.update: display_tft
# head touch gestures: a touch episode ends after 250 ms without contact;
# tap = short episode, stroke = longer episode that reaches the pads one after another
- interval: 50ms
then:
- lambda: |-
static bool active = false;
static uint32_t start = 0, last_on = 0;
static uint32_t first_on[3];
const bool on[3] = {id(head_touch_1).state != "No touch", id(head_touch_2).state != "No touch",
id(head_touch_3).state != "No touch"};
const uint32_t now = millis();
if (on[0] || on[1] || on[2]) {
if (!active) {
active = true;
start = now;
first_on[0] = first_on[1] = first_on[2] = 0;
}
for (int i = 0; i < 3; i++)
if (on[i] && first_on[i] == 0)
first_on[i] = now;
last_on = now;
return;
}
if (!active || now - last_on < 250)
return;
active = false;
const uint32_t duration = last_on - start;
int pads = 0;
uint32_t earliest = UINT32_MAX, latest = 0;
for (int i = 0; i < 3; i++) {
if (first_on[i] == 0)
continue;
pads++;
earliest = std::min(earliest, first_on[i]);
latest = std::max(latest, first_on[i]);
}
const uint32_t spread = latest - earliest;
ESP_LOGD("head_touch", "duration %u ms, pads %d, spread %u ms, order 1:%d 2:%d 3:%d", (unsigned) duration,
pads, (unsigned) spread, first_on[0] ? (int) (first_on[0] - earliest) : -1,
first_on[1] ? (int) (first_on[1] - earliest) : -1, first_on[2] ? (int) (first_on[2] - earliest) : -1);
if (pads >= 2 && spread >= 150 && duration >= 400) {
id(head_stroke).execute();
} else if (duration <= 600) {
id(head_tap).execute();
}
- interval: 1s # status screen refresh until Home Assistant connects
then:
- if:
condition:
lambda: return id(show_status);
then:
- component.update: display_tft
- interval: 90ms
then:
- lambda: |-
if (id(speaking)) {
id(mouth_open) = 0.15f + (esp_random() % 85) / 100.0f;
id(display_tft).update();
} else if (id(mouth_open) > 0) {
id(mouth_open) = 0;
id(display_tft).update();
}
- interval: 3s # blinking
then:
- delay: !lambda return esp_random() % 3000;
- lambda: id(eyes_open) = false;
- component.update: display_tft
- delay: 150ms
- lambda: id(eyes_open) = true;
- component.update: display_tft
- interval: 7s # looking around while idle
then:
- lambda: |-
if (!id(speaking) && !id(listening)) {
id(gaze_x) = (int) (esp_random() % 21) - 10;
id(gaze_y) = (int) (esp_random() % 11) - 5;
id(display_tft).update();
}
spi:
- id: spi_bus
clk_pin: 36
mosi_pin: 37
image:
- platform: file
file: assets/images/made-for-esphome-white-on-black.svg
id: esphome_logo
resize: 230x68
type: RGB565
transparency: alpha_channel
- platform: file
file: mdi:ear-hearing
id: icon_listening
resize: 28x28
type: BINARY
font:
- file: "gfonts://Roboto"
id: font_title
size: 24
- file: "gfonts://Roboto"
id: font_status
size: 16
color:
- id: status_ok
red: 30%
green: 85%
blue: 40%
- id: status_wait
red: 100%
green: 70%
blue: 0%
- id: love_red
red: 100%
green: 25%
blue: 45%
- id: status_low
red: 100%
green: 20%
blue: 20%
- id: status_dim
red: 55%
green: 55%
blue: 55%
- id: face_fg
red: 100%
green: 100%
blue: 100%
- id: face_bg
red: 0%
green: 0%
blue: 0%
display:
- platform: mipi_spi
id: display_tft
model: M5CORE
dc_pin: 35
reset_pin:
aw9523b_id: aw9523b_hub
number: 9
cs_pin:
number: 3
ignore_strapping_warning: true
data_rate: 40MHz
invert_colors: true
update_interval: never
auto_clear_enabled: false
lambda: |-
static bool first_draw = true;
static bool dot_shown = false;
static bool bar_drawn = false;
static int last_bars = -1, last_batt = -2;
static bool last_charging = false;
const bool wifi_ok = wifi::global_wifi_component->is_connected();
const int8_t rssi = wifi_ok ? wifi::global_wifi_component->wifi_rssi() : -127;
const int wifi_bars = !wifi_ok ? 0 : rssi > -55 ? 4 : rssi > -65 ? 3 : rssi > -75 ? 2 : 1;
const int batt = std::isnan(id(battery_level).state) ? -1 : (int) roundf(id(battery_level).state);
const bool charging = id(battery_charging).state;
// status screen until Home Assistant connects
if (id(show_status)) {
first_draw = true;
it.fill(id(face_bg));
it.image(160, 46, id(esphome_logo), ImageAlign::CENTER);
it.print(160, 95, id(font_title), id(face_fg), TextAlign::CENTER, "${name}");
if (wifi_ok) {
it.printf(20, 114, id(font_status), id(status_ok), "WiFi: connected (%d dBm)", rssi);
// one global IPv6 (skip link-local) and one IPv4 address
int y = 133;
char buf[network::IP_ADDRESS_BUFFER_SIZE];
for (bool v6 : {true, false}) {
for (auto &ip : wifi::global_wifi_component->get_ip_addresses()) {
if (!ip.is_set() || ip.is_ip6() != v6)
continue;
ip.str_to(buf);
if (v6 && strncmp(buf, "fe80", 4) == 0)
continue;
it.print(30, y, id(font_status), id(status_dim), buf);
y += 19;
break;
}
}
} else {
it.print(20, 114, id(font_status), id(status_wait), "WiFi: connecting...");
}
const bool api_ok = api::global_api_server->is_connected();
it.print(20, 172, id(font_status), api_ok ? id(status_ok) : id(status_wait),
api_ok ? "Home Assistant: connected" : "Home Assistant: waiting...");
if (batt >= 0) {
it.printf(20, 191, id(font_status), batt <= 20 && !charging ? id(status_low) : id(status_dim),
"Battery: %d%%%s", batt, charging ? " (charging)" : "");
}
it.printf(20, 212, id(font_status), id(status_dim), "ESPHome %s", ESPHOME_VERSION);
it.printf(300, 212, id(font_status), id(status_dim), TextAlign::TOP_RIGHT, "up %us",
(unsigned) (millis() / 1000));
return;
}
// stack-chan face: 2 eyes + mouth, 320x240
// the whole screen is cleared only once, then just the face areas, so that only the
// changed region is sent over SPI instead of the whole 150 kB buffer
const bool love = id(in_love);
const bool music = id(media_out).state == media_player::MEDIA_PLAYER_STATE_PLAYING && !id(va).is_running();
// switching between face, in-love face and spectrum repaints everything
static int last_mode = -1;
const int mode = music ? 2 : love ? 1 : 0;
if (mode != last_mode) {
last_mode = mode;
first_draw = true;
}
// the in-love face is drawn outside the partially cleared areas, so it always repaints everything
static bool spectrum_reset = true;
if (first_draw || love) {
it.fill(id(face_bg));
first_draw = false;
bar_drawn = false;
spectrum_reset = true;
} else if (!music) {
it.filled_rectangle(70, 78, 181, 31, id(face_bg)); // eyes including gaze range
it.filled_rectangle(105, 130, 111, 38, id(face_bg)); // mouth
if (dot_shown && !id(listening)) {
it.filled_rectangle(254, 2, 28, 28, id(face_bg));
}
}
dot_shown = id(listening);
// status bar (battery top-left, wifi top-right), redrawn only when something changes
if (!bar_drawn || wifi_bars != last_bars || batt != last_batt || charging != last_charging) {
bar_drawn = true;
last_bars = wifi_bars;
last_batt = batt;
last_charging = charging;
it.filled_rectangle(4, 4, 90, 22, id(face_bg));
it.filled_rectangle(288, 4, 28, 22, id(face_bg));
// battery: outline, terminal nub and fill proportional to the charge
if (batt >= 0) {
const Color c = charging ? id(status_ok) : batt <= 20 ? id(status_low) : id(status_dim);
it.rectangle(8, 8, 26, 13, c);
it.filled_rectangle(34, 12, 2, 5, c);
it.filled_rectangle(10, 10, 22 * batt / 100, 9, c);
it.printf(40, 6, id(font_status), c, "%d%%", batt);
}
// wifi: 4 bars, unused ones dimmed; red when disconnected
for (int i = 0; i < 4; i++) {
const int h = 4 + 3 * i;
const Color c = !wifi_ok ? id(status_low) : i < wifi_bars ? id(status_dim) : Color(40, 40, 40);
it.filled_rectangle(290 + i * 6, 21 - h, 4, h, c);
}
}
// Winamp style spectrum analyzer instead of the face while music plays.
// Only the segments that changed since the last frame are drawn or cleared, which keeps both the
// pixel work and the SPI transfer small.
if (music) {
static float peak[32];
static uint32_t peak_time[32];
static int prev_segs[32], prev_cap[32];
const uint32_t now = millis();
const int bands = std::min<int>(id(spectrum).get_bands(), 32);
const int x0 = 12, x1 = 308, top = 40, bottom = 228;
const int slot = (x1 - x0) / bands, bar_w = slot - 3;
const int seg_h = 5; // 4 px LED segment + 1 px gap
const int max_segs = (bottom - top) / seg_h;
if (spectrum_reset) {
spectrum_reset = false;
for (int b = 0; b < 32; b++) {
peak[b] = 0.0f;
prev_segs[b] = 0;
prev_cap[b] = -1;
}
}
auto seg_y = [&](int s) { return bottom - (s + 1) * seg_h + 1; };
for (int b = 0; b < bands; b++) {
const float level = id(spectrum).get_level(b);
const int x = x0 + b * slot + 1;
const int segs = std::min(max_segs, (int) (level * max_segs));
// peak caps hold for a moment, then fall slowly
if (level >= peak[b]) {
peak[b] = level;
peak_time[b] = now;
} else if (now - peak_time[b] > 400) {
peak[b] = std::max(0.0f, peak[b] - 0.03f);
}
const int cap = peak[b] > 0.0f ? std::min(max_segs - 1, std::max(segs, (int) (peak[b] * max_segs))) : -1;
// the old cap is cleared first; bar segments drawn below repaint anything it overlapped
if (prev_cap[b] >= 0 && prev_cap[b] != cap)
it.filled_rectangle(x, seg_y(prev_cap[b]), bar_w, seg_h - 1, id(face_bg));
if (segs > prev_segs[b]) {
for (int s = prev_segs[b]; s < segs; s++) {
// green at the bottom, through yellow to red at the top
const float f = float(s) / float(max_segs - 1);
const Color c = f < 0.5f ? Color((uint8_t) (510 * f), 230, 0) : Color(255, (uint8_t) (460 * (1.0f - f)), 0);
it.filled_rectangle(x, seg_y(s), bar_w, seg_h - 1, c);
}
} else if (segs < prev_segs[b]) {
it.filled_rectangle(x, seg_y(prev_segs[b] - 1), bar_w, (prev_segs[b] - segs) * seg_h, id(face_bg));
}
prev_segs[b] = segs;
if (cap >= segs && cap >= 0 && (cap != prev_cap[b] || cap == segs)) {
const int cy = seg_y(cap) + seg_h - 3;
it.filled_rectangle(x, cy, bar_w, 2, id(status_dim));
}
prev_cap[b] = cap;
}
return;
}
const int ex = id(gaze_x), ey = id(gaze_y);
const int eye_y = 93 + ey, eye_r = 8;
const int lx = 90 + ex, rx = 230 + ex;
if (love) {
// heart eyes, blush and a wide smile
for (int cx : {lx, rx}) {
it.filled_circle(cx - 6, eye_y - 3, 7, id(love_red));
it.filled_circle(cx + 6, eye_y - 3, 7, id(love_red));
it.filled_triangle(cx - 13, eye_y - 1, cx + 13, eye_y - 1, cx, eye_y + 14, id(love_red));
}
it.filled_circle(60 + ex, 130 + ey, 12, Color(90, 20, 40));
it.filled_circle(260 + ex, 130 + ey, 12, Color(90, 20, 40));
if (!id(speaking)) {
it.filled_circle(160 + ex / 2, 132 + ey / 2, 30, id(face_fg));
it.filled_circle(160 + ex / 2, 122 + ey / 2, 30, id(face_bg));
}
} else if (id(eyes_open)) {
it.filled_circle(lx, eye_y, eye_r, id(face_fg));
it.filled_circle(rx, eye_y, eye_r, id(face_fg));
} else {
it.filled_rectangle(lx - eye_r, eye_y - 2, eye_r * 2, 4, id(face_fg));
it.filled_rectangle(rx - eye_r, eye_y - 2, eye_r * 2, 4, id(face_fg));
}
const float m = id(mouth_open);
if (!love || id(speaking)) {
const int mw = 90 - (int) (40 * m);
const int mh = 4 + (int) (26 * m);
it.filled_rectangle(160 - mw / 2 + ex / 2, 148 - mh / 2 + ey / 2, mw, mh, id(face_fg));
}
if (id(listening)) {
it.image(254, 2, id(icon_listening), id(face_fg), id(face_bg)); // left of the wifi bars
}
touchscreen:
- platform: ft63x6
id: touch_lcd
i2c_id: i2c_bsp
reset_pin:
aw9523b_id: aw9523b_hub
number: 0
on_touch:
- voice_assistant.start:
esp32_camera:
id: camera_id
name: Camera
i2c_id: i2c_bsp
setup_priority: -200 # after power rails and IO expanders
pixel_format: RGB565
max_framerate: 15 fps
frame_buffer_count: 1
# GC0308 has no JPEG, convert RGB565 -> JPEG for HA (as in BSP); runs in the main loop, so no periodic frames
jpeg_quality: 12
idle_framerate: 0 fps
vsync_pin:
number: 46
ignore_strapping_warning: true
href_pin: 38
external_clock:
pin: 2
frequency: 20MHz
pixel_clock_pin:
number: 45
ignore_strapping_warning: true
data_pins: [39, 40, 41, 42, 15, 16, 48, 47]
resolution: 320x240
frame_buffer_location: PSRAM
sensor:
- platform: axp2101
id: axp2101_sensor
battery_level:
id: battery_level
name: Battery Level
filters:
- delta: 1.0
on_value:
- component.update: display_tft
battery_charging:
id: battery_charging
name: Battery Charging
on_state:
- component.update: display_tft
- platform: ltr_als_ps
i2c_id: i2c_bsp
address: 0x23
type: ALS_PS
ambient_light:
id: ambient_light
name: Ambient Light
ps_counts:
id: proximity_counts
name: Proximity Counts
- platform: ina226
i2c_id: i2c_bsp
address: 0x41
shunt_resistance: 0.01 ohm
max_current: 3.2A
current:
id: ina226_current
name: Current
power:
id: ina226_power
name: Power
bus_voltage:
id: ina226_bus_voltage
name: Bus Voltage
si12t:
id: touch_hub
i2c_id: i2c_bsp
# report low-strength touches too, not only medium/high
intr_level: OUTPUT_ALL
text_sensor:
- platform: si12t
id: head_touch_1
name: Head Touch 1
channel: CH_1
# lower level = lower touch threshold: SENS_LEVEL_1 = 0.7 % capacitance change (default level 3 is 1.2 %),
# SENS_LEVEL_0 made neighbouring pads trigger together
sensitivity: SENS_LEVEL_1
extend_threshold: false
update_interval: 100ms
internal: true
- platform: si12t
id: head_touch_2
name: Head Touch 2
channel: CH_2
sensitivity: SENS_LEVEL_1
extend_threshold: false
update_interval: 100ms
internal: true
- platform: si12t
id: head_touch_3
name: Head Touch 3
channel: CH_3
sensitivity: SENS_LEVEL_1
extend_threshold: false
update_interval: 100ms
internal: true
uart:
- id: uart_servo
tx_pin: 6
rx_pin: 7
baud_rate: 1000000
scs9009:
ftservo:
- platform: scs9009
id: x_servo
address: 1
- platform: scs9009
id: y_servo
address: 2
number:
- platform: ftservo
ftservo_id: x_servo
angle:
id: servo_x_angle
name: Servo X Angle
min_value: -164
max_value: 164
use_raw_angle: false
angle_offset: 164
step: 5
light:
- platform: m5ioe1
id: light_bar
name: Light Bar
num_led: 12
- platform: monochromatic
id: lcd_backlight
name: LCD Backlight
output: lcd_backlight_output
restore_mode: ALWAYS_ON
remote_transmitter:
id: ir_tx
pin: 5
carrier_duty_percent: 50%
non_blocking: true
remote_receiver:
id: ir_rx
pin: 10
time:
- platform: bm8563
id: rtc_time
i2c_id: i2c_bsp
update_interval: never
-2
View File
@@ -358,5 +358,3 @@ valve:
close_action: close_action:
- switch.turn_off: irrigation_relay_8 - switch.turn_off: irrigation_relay_8
time:
- !include common/time/homeassistant.yaml