commit 61c37de884cd0c2e4e30ddd1102e58516a9f6009 Author: iosullivan Date: Sun Jul 19 16:49:55 2026 +0800 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5839147 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Secrets — never commit Wi-Fi / API / OTA credentials +secrets.yaml + +# ESPHome build cache & artifacts +.esphome/ +*.bin + +# Demo recordings (host README/wiki videos via GitHub attachments, not in-repo) +*.mp4 + +# Python +__pycache__/ +*.pyc + +# OS junk +.DS_Store +Thumbs.db +desktop.ini diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c396d6e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,190 @@ +# Changelog + +All notable changes to this project are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [2.2.6] - 2026-06-25 + +### Fixed +- Neon watchface: temperature degree sign showed as `º` (double-encoded in source); now renders `°` like Classic. + +## [2.2.5] - 2026-06-19 + +### Added +- New **Knobuss** game (optional carousel screen, a knob-steered tube shooter): your ship orbits the rim, you auto-fire inward, foes spiral out from a black-hole core. Turn the knob to aim; every foe that reaches the rim costs a life and bursts in an animated explosion. The ship is a pixel fighter pre-rotated into 16 frames so it always points inward, foes are glowing orbs, a rushing starfield + concentric rings give a cheap 3D-depth tube, and the menu has a "Knobuss" title screen. 3 lives, top-10 scores. Add it to your `screen_order` as `knobuss` (`gyruss` also works). (Art: Lunar Lander pack by Matt Walkden, CC0; see assets/sprites/knobuss/CREDITS.txt.) + +### Fixed +- Cool Cars ran choppier than the other games: its per-tick position updates were 8 separate widget actions (now one batched lambda, like Snake/Knobuss), and it ticked at 12.5fps (now 20fps with speeds scaled to keep the same pace) - so the motion is much smoother. + +## [2.2.4] - 2026-06-19 + +### Changed +- Sensors screen: each glance now gets its own accent colour (icon + position dots). +- Added a faint depth ring to the Sensors, Home (Classic) and Weather screens for a consistent framed look. + +## [2.2.3] - 2026-06-19 + +### Changed +- Thermostat screen polished: the whole arc is now colour-coded by action (heating / cooling / idle / off), a white head-dot marks the target on the gauge, and a faint depth ring was added. + +## [2.2.2] - 2026-06-19 + +### Changed +- Timer screen redesigned: colour-coded depleting ring (green → amber → red) with a head-dot, SET / RUNNING / PAUSED status, and tile-styled buttons with a soft glow. +- Alarm screen redesigned: red scrim, a big pulsing bell, "TIME'S UP!" and a tap-to-stop hint. + +### Fixed +- Returning to the home screen after music stops/pauses keeps the selected watchface (was forced to Classic). +- The Minecraft watchface now shows the active-timer badge. + +## [2.2.1] - 2026-06-19 + +### Added +- **Minecraft watchface** - a blocky scene with a day/night cycle (sun by day, moon + stars by night), a textured grass/dirt ground, a pixel-font clock, and a heart bar for the battery. Optional, like the other faces. + +### Changed +- Watchfaces redraw the clock only when the minute changes (not every second), so they stay light; faces slide in by swipe direction instead of cross-fading. + +### Fixed +- Silenced the font "missing glyphs" warnings (pixel font uses an explicit glyph list; the Roboto faces use `ignore_missing_glyphs`). + +## [2.2.0] - 2026-06-19 + +### Added +- **Watchfaces** - the home screen now has selectable looks. Classic is built in; enable the optional **Neon** face (big two-tone digits + neon rings) or copy the heavily-commented **Demo** template to build your own. Pick one in Settings → Home → Watchface with a live full-screen preview (knob switches, tap keeps). + +### Changed +- Settings: clearer names (Home screen, LED Ring, Voice Assistant, Show weather / Show climate, Screen auto-off) and the selected row is styled like the swipe-up control tiles. +- The rotary knob now wakes the screen from sleep, just like a tap. + +## [2.1.6] - 2026-06-18 + +### Changed +- Control tiles: the lit tile now has a soft amber glow, and tile names get a little letter spacing. + +## [2.1.5] - 2026-06-18 + +### Changed +- Weather screen redesigned: animated condition icons, colour-coded temperature, and a glow that slides to the selected day. +- Boot splash ring is now an azure comet instead of the rainbow. + +### Fixed +- LED ring no longer flashes a stray pixel during screen transitions (RMT now uses DMA). + +## [2.1.4] - 2026-06-18 + +### Added +- **Snake 360** (optional carousel screen). Steer a 360-degree snake with the knob; eat fruit (up to 3 at once) to grow, avoid the ring, your tail and up to two skulls. Keeps a top-10. + +### Changed +- In games the knob only changes volume on the menu/scores/how-to screens; while playing and on game-over it steers (no accidental volume changes). + +### Fixed +- Clock could stay on UTC: the `timezone` substitution now maps common IANA names (e.g. `Europe/Warsaw`) to the POSIX form the device needs. Use a POSIX string for unlisted zones. + +## [2.1.3] - 2026-06-17 + +### Added +- **Boot splash**. A short "HELLO!" greeting on startup (~5 s) with a spinning arc on the screen + and the LED ring animating, then it cross-fades to the clock. Touch is ignored until it hands off. +- **Hold to talk** toggle (Settings -> Assistant). Press and hold anywhere on the screen to start + the assistant; this can now be turned off (the wake word keeps working regardless). + +### Fixed +- Clock could show UTC (off by the timezone offset) after a restart until the next time sync. The + device now re-applies its resolved timezone every second, so the clock never drifts to UTC. +- OTA updates could roll back to the previous firmware if the device was restarted shortly after + flashing (on esp-idf the bootloader only keeps a new image once the boot is marked good). The + image is now committed as soon as the boot splash finishes, and the safe-mode window is shortened. + +## [2.1.2] - 2026-06-17 + +### Changed +- Per-screen settings are now declared in each screen's own package via a generic Widgets + options registry, so a screen is fully self-contained (its options, globals and reset + logic live in its file). Adding a screen with settings no longer touches the core. +- The games' "Reset scores" is now under separate **Cool Cars** and **Space Wars** groups + in Settings -> Widgets (previously a single "Games" group); each game owns its high scores. + +## [2.1.1] - 2026-06-17 + +### Added +- **Sensors screen** (optional). A glance of 1-6 configurable Home Assistant entities, shown + one at a time big and readable; turn the knob to cycle (slider dots show the position). + Pulls straight from HA (no helper), auto-skips unconfigured slots, and units with a micro + sign / superscripts are sanitized so they don't render as boxes. New `glanceN_entity` / + `glanceN_name` / `glanceN_icon` substitutions, plus the icons `air`, `plant`, `plex`, + `server` and `shield`. + +## [2.1.0] - 2026-06-17 + +### Added +- **Thermostat screen** (optional). A round dial for a Home Assistant `climate.*` entity: + the knob sets the target temperature (committed ~0.6 s after you stop turning), tap toggles + on/off, and the accent colour follows the action (heating / cooling / idle). Shows the + current temperature, humidity and HVAC mode. Set it with the `climate_entity` substitution. +- **Configurable wake word** via a `wake_word` substitution (default `alexa`; built-in: + `okay_nabu`, `hey_jarvis`, `hey_mycroft`). + +## [2.0.0] - 2026-06-17 + +Modular screens release. The firmware is now split into an always-on core plus optional +screen packages, and you keep a single thin config locally that pulls everything else +from GitHub at compile time. + +### Added +- **Modular screens.** Optional carousel screens live as separate packages under + `base/screens/`. Pick which ones compile in via the `files:` list and set their + left-to-right order with the `screen_order` substitution, all from one file. +- **Thin local config.** The root `guition-va.yaml` is the only firmware file you keep; + it pulls `base/core.yaml` and the selected `base/screens/*.yaml` as a remote package. +- **Weather screen.** Today plus a 7-day radial dial (Apple-Watch style); the knob + highlights a day, the centre shows the day name, temperatures and condition. Includes a + Celsius/Fahrenheit toggle and a Home Assistant helper sensor that feeds the forecast. +- **Example "demo" screen.** A small, heavily commented screen (TAP ME!) you can copy as a + starting point for your own screen. +- **Configurable control tiles.** The swipe-up page is now four tiles, each with its own HA + entity, icon (chosen by name from a 20-icon set, repeatable) and label. Works on any HA + domain via `homeassistant.toggle`. +- **Configurable clock timezone** via a `timezone` substitution (DST automatic). + +### Changed +- **Repository layout.** The core and screens moved into `base/`; the root holds the thin + `guition-va.yaml` you copy and edit. +- **Settings menu.** "Widgets" is now a per-screen group menu (Widgets -> Demo / Games / + Weather), each opening that screen's own options. A separate "Home" submenu owns the + home-screen widget toggles; "Display" keeps global brightness / night / screen-off. + "System" is always last, and device Restart lives only inside it. + +### Fixed +- Clock showing UTC: the timezone is now set explicitly (the Home Assistant time platform + does not sync the zone to the device). +- Control-tile icons rendering empty, and the "led" icon pointing at map-marker-outline + (now `led-strip`). +- Settings list collapsing "Restart" and "System" into one entry (a folded YAML newline). +- Demo LED ring staying lit on the home screen after a reboot. + +## [1.0.0] - 2026-06-16 + +### Added +- Initial release: ESPHome voice assistant for the Guition JC3636K718C round knob display. + Clock, music player (album art + transport), timer, two games (Cool Cars, Space Wars), + settings menu, WS2812 LED ring, on-device wake word and the Assist voice pipeline. +- Fonts, images and sounds fetched from GitHub at compile time (nothing to copy locally + except the config and the partition table). + +[2.2.5]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.2.5 +[2.2.4]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.2.4 +[2.2.3]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.2.3 +[2.2.2]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.2.2 +[2.2.1]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.2.1 +[2.2.0]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.2.0 +[2.1.6]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.1.6 +[2.1.5]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.1.5 +[2.1.4]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.1.4 +[2.1.3]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.1.3 +[2.1.2]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.1.2 +[2.1.1]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.1.1 +[2.1.0]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.1.0 +[2.0.0]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v2.0.0 +[1.0.0]: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/releases/tag/v1.0.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..442ee4a --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ + + +![ESPHome + Home Assistant Voice Assistant on the Guition JC3636K718C](assets/header.jpg) + +# ESPHome Voice Assistant for the Guition JC3636K718C (round knob display) + +A full-featured **Home Assistant Voice Assistant** running on the **Guition +JC3636K718C** - a 1.8" round 360×360 touch display with a rotary knob, speaker, +microphone and an **addressable LED ring**. It's pure ESPHome (no custom C firmware): +an always-on core plus optional screen packages you pick from one thin config. + +It started as "my kid needs a physical timer" and turned into a whole puck. 🙂 + +## Demo + +
+ +
+ +## What it does + +- **Voice assistant** - on-device wake word ("Alexa") via `micro_wake_word`, full + Home Assistant Assist pipeline (STT / LLM / TTS), wake beep + music ducking. You can also + press and hold the screen to talk (toggleable in Settings). +- **Boot splash** - a short "HELLO!" greeting with a spinning ring on startup, then the clock. +- **Music player** - `speaker` media player visible in HA / Music Assistant, with + album art, title/artist, transport buttons and a progress bar. +- **Timers** - set by knob or by voice; big countdown with a depleting ring, + pause/stop, and an alarm (sound + on-screen + LED) when it finishes. +- **Device control** - a tiles screen toggling your lights/switches. +- **Home screen** - clock, date, battery, weather + room temp/humidity, with a **selectable watchface** look (Classic, Neon, or your own). +- **More screens** (all optional, knob-driven) - a weather forecast dial, a thermostat for + any `climate` entity, and a configurable multi-sensor glance. +- **LED ring** - controllable from HA *and* reactive: assistant (comet/spinner/wave), + timer countdown, alarm flash, volume bar - each reaction toggleable in Settings. +- **Four built-in arcade games** (a lane racer, a vertical shooter, a 360-degree snake and a Gyruss-style tube shooter) for the kid. + +Everything is navigated with **swipes + taps on the screen** and the **rotary knob**. + +## Screens + +| Screen | View | +|:---:|---| +| | **Home / Clock**
Time, date, battery (a bolt while charging), outdoor weather and room temperature + humidity. Pick the watchface look in Settings → Home - see [Watchfaces](#watchfaces) below. | +| | **Player**
Album art, title & artist, prev / play-pause / next and a progress bar. Auto-shows when playback starts. | +| | **Control tiles** (swipe up)
Four configurable tiles toggling any HA entity; each has its own icon and label, and the colour follows the live on/off state. | +| | **Weather**
A 7-day dial; turn the knob to scroll days. Animated condition icon, colour-coded temperature, and a glow that slides to the selected day. | +| | **Thermostat**
A dial for a `climate.*` entity; the knob sets the target (a head-dot rides the gauge), tap toggles on/off. The whole arc is colour-coded by action - heating / cooling / idle / off. | +| | **Sensors**
A glance of 1-6 configurable Home Assistant entities, shown big one at a time; turn the knob to cycle (dots show the position). Each gets its own accent colour. Any entity, pulled straight from HA. | +| | **Timer**
Set by knob or voice; big countdown with a colour-coded depleting ring (green → amber → red) and a head-dot, SET / RUNNING / PAUSED status, pause/stop, and a fancy alarm screen when it finishes. | +| | **Cool Cars**
A lane-racing arcade game - the knob steers, dodge traffic and grab coins. | +| | **Space Wars**
A vertical space shooter - the knob steers, auto-fire, survive the waves. | +| | **Snake 360**
A smooth-steering 360-degree snake - turn the knob to steer the head and the body trails behind, across the whole round screen. | +| | **Knobuss**
A **Gyruss clone**: your ship orbits the rim and auto-fires inward while foes spiral out of a black-hole core - turn the knob to aim and shoot them down. **Every foe that reaches the rim costs a life** (it bursts in an explosion). 3 lives, top-10 scores. | +| | **Settings** (swipe down)
Display, Home screen, Widgets, LED Ring, Voice Assistant, System; turn the knob to scroll, tap to enter. | +| | **Demo**
A small, heavily commented example screen (tap flips black ↔ white) to copy when building your own. | + +> Optional screens (player, timer, games, weather, thermostat, sensors, demo) are pickable - choose which compile in and their order; see [Configuration](https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/wiki/Configuration). + +## Watchfaces + +The home screen has a **selectable look**. Open **Settings → Home → Watchface** and the knob previews each face live, full-screen - tap to keep. **Classic** is built in; **Neon** (big neon digits), **Minecraft** (a blocky day/night scene) and a heavily-commented **Demo** template are optional files you switch on in your config, and you can copy Demo to build your own (see [Configuration](https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/wiki/Configuration)). + +| Classic (built-in) | Neon | Minecraft | Demo (template) | +|:---:|:---:|:---:|:---:| +| | | | | + +## Documentation + +Full docs live in the **[wiki](https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/wiki)**: + +| Page | What's inside | +|---|---| +| [Hardware](https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/wiki/Hardware) | Board specs, full pinout, what to buy on AliExpress | +| [Installation](https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/wiki/Installation) | Requirements, first flash (USB), OTA, the bundled sounds | +| [Usage](https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/wiki/Usage) | Gestures, screens, the settings menu, the LED ring | +| [Configuration](https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/wiki/Configuration) | Change entities, tiles, wake word, run without Music Assistant | +| [Troubleshooting](https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/wiki/Troubleshooting) | Known issues (battery %, GPIO0 strapping, performance, the knob) | + +Release history: [CHANGELOG.md](CHANGELOG.md). + +## Buying one (AliExpress) + +Search for the exact model number and verify the listing matches: + +- Model: **Guition JC3636K718** (look for this in the title/photos) +- ESP32-S3, **16 MB flash**, **octal PSRAM** +- 1.8" round **360×360**, driver **ST77916** +- touch **CST816**, rotary **knob**, speaker + mic, **LED ring** + +⚠️ **Heads-up:** there's a near-identical board, the **JC3636W518**, with a *different +pinout*. Make sure the listing says **K718**, not W518. More detail on the +[Hardware](https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/wiki/Hardware) wiki page. + +## Quick start + +> **Requires ESPHome 2025.5.0+** - the LED ring uses `use_dma`, added in that release. Older versions fail to compile with `[use_dma] is an invalid option for [light.esp32_rmt_led_strip]`. + +1. Copy `secrets.example.yaml` → `secrets.yaml` and fill in your Wi-Fi. +2. Copy **`guition-va.yaml`** and **`partitions.csv`** so they sit together with `secrets.yaml`. Edit the `substitutions:` at the top of `guition-va.yaml` (HA URL + your entity IDs + the four control tiles). That thin file is the only firmware file you keep - the core and all screens are **pulled from GitHub at compile time** (see its `packages:` block), as are the fonts, images and sounds. +3. Choose which screens compile in via the `files:` list and their left-to-right order via `screen_order`, both in `guition-va.yaml`. +4. **First flash over USB** - easiest via the **ESPHome dashboard** (GUI) or the CLI; the 16 MB partition table can't be set over OTA, so the first flash is USB, then updates go wireless. +5. In Home Assistant: open the new ESPHome device → assign an Assist pipeline. + +To pull the latest changes later: `esphome clean guition-va.yaml` (clears the package cache) then `esphome run guition-va.yaml`. + +Full details on the [Installation](https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/wiki/Installation) wiki page. + +## Repository layout + +``` +guition-va.yaml # YOUR config: copy + edit this (pulls everything else from GitHub) +partitions.csv # 16 MB partition table (keep next to guition-va.yaml) +secrets.example.yaml # copy to secrets.yaml +base/ # pulled as a remote package at compile time (no need to copy) + core.yaml # always-on core: clock, controls (swipe-up tiles), settings menu + screens/ # optional carousel screens (toggle each in guition-va.yaml) + player.yaml # music player (album art + transport) + timer.yaml # timer screen in the carousel + cool-cars.yaml # "Cool Cars" game + space-wars.yaml # "Space Wars" game + snake.yaml # "Snake 360" game (knob steers) + knobuss.yaml # "Knobuss" game (Gyruss-style tube shooter, knob aims) + weather.yaml # weather (today + 7-day radial dial) + thermostat.yaml # thermostat (climate.* dial; knob sets target, tap on/off) + sensors.yaml # sensors glance (1-6 HA entities, knob cycles) + demo.yaml # commented example screen + weather.ha-helper.yaml # HA template sensor that feeds the weather screen + watchfaces/ # optional home-screen looks (Classic is built into core) + neon.yaml # "Neon" watchface - big two-tone digits + neon rings + minecraft.yaml # "Minecraft" watchface - blocky day/night scene + pixel clock + demo.yaml # "Demo" watchface - minimal, heavily-commented template to copy +assets/ # fetched from GitHub at compile time (no need to copy locally) + header.jpg # banner + fonts/pixel-font.ttf # pixel font (Minecraft watchface) + sounds/ # wake.wav + alarm.wav + sprites/cool-cars/ # "Cool Cars" game graphics + sprites/space-wars/ # "Space Wars" game graphics + sprites/snake/ # "Snake" menu logo + sprites/knobuss/ # "Knobuss" game graphics (ship/foes/explosion/core/logo) + sprites/minecraft/ # "Minecraft" watchface graphics (sun/moon/ground/flower) + sprites/weather/ # animated weather icon frames +scripts/ + make_sounds.py # (re)generate the wav sounds + gen_weather.py # (re)generate the animated weather icon frames + gen_snake.py # (re)generate the snake sprites + esplog.py # stream device logs over the native API +skill/ # Claude Code skill: hardware spec + gotchas +``` + +## Claude Code skill + +This repo ships a [Claude Code](https://claude.com/claude-code) skill at +[`skill/guition-jc3636k718c/`](skill/guition-jc3636k718c/SKILL.md). It gives the +assistant the correct pinout, ESPHome component choices, and the hard-won gotchas +(the knob isn't quadrature, GPIO0 ring strapping, 16 MB partitions need a USB flash, +LVGL performance limits, lambda/string pitfalls, the battery heuristic). + +### Install it + +So Claude can use it on any project: + +- **User-wide** - copy the folder into `~/.claude/skills/`: + ```bash + cp -r skill/guition-jc3636k718c ~/.claude/skills/ + ``` +- **Per-project** - copy it into that project's `.claude/skills/`. + +Start a new Claude Code session and ask anything about this board; the skill loads +automatically. See the [wiki](https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/wiki/Claude-Code-Skill) for details. + +## Credits / notes + +- Pinout and the display `init_sequence` come from the **official manufacturer demo** + (`JC3636K718_knob_EN`) - this is the correct pinout for the **K718C** board, which + differs from the otherwise-similar **JC3636W518**. +- Built with [ESPHome](https://esphome.io/) + Home Assistant. diff --git a/assets/fonts/pixel-font.ttf b/assets/fonts/pixel-font.ttf new file mode 100644 index 0000000..85c1472 Binary files /dev/null and b/assets/fonts/pixel-font.ttf differ diff --git a/assets/header.jpg b/assets/header.jpg new file mode 100644 index 0000000..f6fe149 Binary files /dev/null and b/assets/header.jpg differ diff --git a/assets/screens/controls.png b/assets/screens/controls.png new file mode 100644 index 0000000..9bff76f Binary files /dev/null and b/assets/screens/controls.png differ diff --git a/assets/screens/cool-cars.png b/assets/screens/cool-cars.png new file mode 100644 index 0000000..b0673fd Binary files /dev/null and b/assets/screens/cool-cars.png differ diff --git a/assets/screens/demo.png b/assets/screens/demo.png new file mode 100644 index 0000000..9225ad5 Binary files /dev/null and b/assets/screens/demo.png differ diff --git a/assets/screens/home.png b/assets/screens/home.png new file mode 100644 index 0000000..876cf80 Binary files /dev/null and b/assets/screens/home.png differ diff --git a/assets/screens/knobuss.png b/assets/screens/knobuss.png new file mode 100644 index 0000000..53b5257 Binary files /dev/null and b/assets/screens/knobuss.png differ diff --git a/assets/screens/player.png b/assets/screens/player.png new file mode 100644 index 0000000..6113eea Binary files /dev/null and b/assets/screens/player.png differ diff --git a/assets/screens/sensors.png b/assets/screens/sensors.png new file mode 100644 index 0000000..4eb1fe3 Binary files /dev/null and b/assets/screens/sensors.png differ diff --git a/assets/screens/settings.png b/assets/screens/settings.png new file mode 100644 index 0000000..c07bf91 Binary files /dev/null and b/assets/screens/settings.png differ diff --git a/assets/screens/snake.png b/assets/screens/snake.png new file mode 100644 index 0000000..c691da5 Binary files /dev/null and b/assets/screens/snake.png differ diff --git a/assets/screens/space-wars.png b/assets/screens/space-wars.png new file mode 100644 index 0000000..e120884 Binary files /dev/null and b/assets/screens/space-wars.png differ diff --git a/assets/screens/thermostat.png b/assets/screens/thermostat.png new file mode 100644 index 0000000..6590bba Binary files /dev/null and b/assets/screens/thermostat.png differ diff --git a/assets/screens/timer.png b/assets/screens/timer.png new file mode 100644 index 0000000..5bee901 Binary files /dev/null and b/assets/screens/timer.png differ diff --git a/assets/screens/watchface-demo.png b/assets/screens/watchface-demo.png new file mode 100644 index 0000000..1fc4e84 Binary files /dev/null and b/assets/screens/watchface-demo.png differ diff --git a/assets/screens/watchface-minecraft.png b/assets/screens/watchface-minecraft.png new file mode 100644 index 0000000..711c238 Binary files /dev/null and b/assets/screens/watchface-minecraft.png differ diff --git a/assets/screens/watchface-neon.png b/assets/screens/watchface-neon.png new file mode 100644 index 0000000..5ba4203 Binary files /dev/null and b/assets/screens/watchface-neon.png differ diff --git a/assets/screens/weather.png b/assets/screens/weather.png new file mode 100644 index 0000000..179e162 Binary files /dev/null and b/assets/screens/weather.png differ diff --git a/assets/sounds/alarm.wav b/assets/sounds/alarm.wav new file mode 100644 index 0000000..f19edb2 Binary files /dev/null and b/assets/sounds/alarm.wav differ diff --git a/assets/sounds/wake.wav b/assets/sounds/wake.wav new file mode 100644 index 0000000..d73c87c Binary files /dev/null and b/assets/sounds/wake.wav differ diff --git a/assets/sprites/cool-cars/coin.png b/assets/sprites/cool-cars/coin.png new file mode 100644 index 0000000..66fd9da Binary files /dev/null and b/assets/sprites/cool-cars/coin.png differ diff --git a/assets/sprites/cool-cars/cone.png b/assets/sprites/cool-cars/cone.png new file mode 100644 index 0000000..29ab108 Binary files /dev/null and b/assets/sprites/cool-cars/cone.png differ diff --git a/assets/sprites/cool-cars/logo.png b/assets/sprites/cool-cars/logo.png new file mode 100644 index 0000000..027c802 Binary files /dev/null and b/assets/sprites/cool-cars/logo.png differ diff --git a/assets/sprites/cool-cars/ocop2.png b/assets/sprites/cool-cars/ocop2.png new file mode 100644 index 0000000..01619ed Binary files /dev/null and b/assets/sprites/cool-cars/ocop2.png differ diff --git a/assets/sprites/cool-cars/omus2.png b/assets/sprites/cool-cars/omus2.png new file mode 100644 index 0000000..0d9a8c0 Binary files /dev/null and b/assets/sprites/cool-cars/omus2.png differ diff --git a/assets/sprites/cool-cars/osed2.png b/assets/sprites/cool-cars/osed2.png new file mode 100644 index 0000000..cd3aa58 Binary files /dev/null and b/assets/sprites/cool-cars/osed2.png differ diff --git a/assets/sprites/cool-cars/pcar2.png b/assets/sprites/cool-cars/pcar2.png new file mode 100644 index 0000000..3b94bb8 Binary files /dev/null and b/assets/sprites/cool-cars/pcar2.png differ diff --git a/assets/sprites/cool-cars/road2.png b/assets/sprites/cool-cars/road2.png new file mode 100644 index 0000000..1da4573 Binary files /dev/null and b/assets/sprites/cool-cars/road2.png differ diff --git a/assets/sprites/knobuss/CREDITS.txt b/assets/sprites/knobuss/CREDITS.txt new file mode 100644 index 0000000..0ed90bb --- /dev/null +++ b/assets/sprites/knobuss/CREDITS.txt @@ -0,0 +1,20 @@ +Gyruss sprite credits +===================== + +ship0..15.png, life.png, expl0..7.png + Derived from the "Lunar Lander" asset pack by Matt Walkden + (mattwalkden.itch.io / mattwalkden.com), released into the Public Domain + under the Creative Commons CC0 dedication. No attribution required; credited + here with thanks. Ship sprite pre-rotated into 16 frames (+ an upright life + icon); explosion sliced from the 8-frame sheet. + +knobuss-logo.png (menu title screen) + "KNOBUSS by Zan" - made by the project owner (Zan). + +core.png (black-hole portal), enemy0..3.png (glowing orbs) + Cropped/scaled from a "celestial objects" pixel-art set. Used with the + project owner's confirmation that its license permits redistribution. + TODO(owner): fill in the exact source/author + license for this set. + +bullet.png + Original to this project. diff --git a/assets/sprites/knobuss/bullet.png b/assets/sprites/knobuss/bullet.png new file mode 100644 index 0000000..8c117f8 Binary files /dev/null and b/assets/sprites/knobuss/bullet.png differ diff --git a/assets/sprites/knobuss/core.png b/assets/sprites/knobuss/core.png new file mode 100644 index 0000000..5fb5d79 Binary files /dev/null and b/assets/sprites/knobuss/core.png differ diff --git a/assets/sprites/knobuss/enemy0.png b/assets/sprites/knobuss/enemy0.png new file mode 100644 index 0000000..5402b98 Binary files /dev/null and b/assets/sprites/knobuss/enemy0.png differ diff --git a/assets/sprites/knobuss/enemy1.png b/assets/sprites/knobuss/enemy1.png new file mode 100644 index 0000000..e29ead5 Binary files /dev/null and b/assets/sprites/knobuss/enemy1.png differ diff --git a/assets/sprites/knobuss/enemy2.png b/assets/sprites/knobuss/enemy2.png new file mode 100644 index 0000000..e44170d Binary files /dev/null and b/assets/sprites/knobuss/enemy2.png differ diff --git a/assets/sprites/knobuss/enemy3.png b/assets/sprites/knobuss/enemy3.png new file mode 100644 index 0000000..d37ac84 Binary files /dev/null and b/assets/sprites/knobuss/enemy3.png differ diff --git a/assets/sprites/knobuss/expl0.png b/assets/sprites/knobuss/expl0.png new file mode 100644 index 0000000..caf39b5 Binary files /dev/null and b/assets/sprites/knobuss/expl0.png differ diff --git a/assets/sprites/knobuss/expl1.png b/assets/sprites/knobuss/expl1.png new file mode 100644 index 0000000..1df056c Binary files /dev/null and b/assets/sprites/knobuss/expl1.png differ diff --git a/assets/sprites/knobuss/expl2.png b/assets/sprites/knobuss/expl2.png new file mode 100644 index 0000000..3cde8ad Binary files /dev/null and b/assets/sprites/knobuss/expl2.png differ diff --git a/assets/sprites/knobuss/expl3.png b/assets/sprites/knobuss/expl3.png new file mode 100644 index 0000000..9027494 Binary files /dev/null and b/assets/sprites/knobuss/expl3.png differ diff --git a/assets/sprites/knobuss/expl4.png b/assets/sprites/knobuss/expl4.png new file mode 100644 index 0000000..808a57a Binary files /dev/null and b/assets/sprites/knobuss/expl4.png differ diff --git a/assets/sprites/knobuss/expl5.png b/assets/sprites/knobuss/expl5.png new file mode 100644 index 0000000..c9ed2e5 Binary files /dev/null and b/assets/sprites/knobuss/expl5.png differ diff --git a/assets/sprites/knobuss/expl6.png b/assets/sprites/knobuss/expl6.png new file mode 100644 index 0000000..5be5cc3 Binary files /dev/null and b/assets/sprites/knobuss/expl6.png differ diff --git a/assets/sprites/knobuss/expl7.png b/assets/sprites/knobuss/expl7.png new file mode 100644 index 0000000..da936cc Binary files /dev/null and b/assets/sprites/knobuss/expl7.png differ diff --git a/assets/sprites/knobuss/knobuss-logo.png b/assets/sprites/knobuss/knobuss-logo.png new file mode 100644 index 0000000..e5a5a21 Binary files /dev/null and b/assets/sprites/knobuss/knobuss-logo.png differ diff --git a/assets/sprites/knobuss/life.png b/assets/sprites/knobuss/life.png new file mode 100644 index 0000000..eb577af Binary files /dev/null and b/assets/sprites/knobuss/life.png differ diff --git a/assets/sprites/knobuss/ship0.png b/assets/sprites/knobuss/ship0.png new file mode 100644 index 0000000..7c51bfb Binary files /dev/null and b/assets/sprites/knobuss/ship0.png differ diff --git a/assets/sprites/knobuss/ship1.png b/assets/sprites/knobuss/ship1.png new file mode 100644 index 0000000..b12b779 Binary files /dev/null and b/assets/sprites/knobuss/ship1.png differ diff --git a/assets/sprites/knobuss/ship10.png b/assets/sprites/knobuss/ship10.png new file mode 100644 index 0000000..8f00ec0 Binary files /dev/null and b/assets/sprites/knobuss/ship10.png differ diff --git a/assets/sprites/knobuss/ship11.png b/assets/sprites/knobuss/ship11.png new file mode 100644 index 0000000..c6f815f Binary files /dev/null and b/assets/sprites/knobuss/ship11.png differ diff --git a/assets/sprites/knobuss/ship12.png b/assets/sprites/knobuss/ship12.png new file mode 100644 index 0000000..eb5b1af Binary files /dev/null and b/assets/sprites/knobuss/ship12.png differ diff --git a/assets/sprites/knobuss/ship13.png b/assets/sprites/knobuss/ship13.png new file mode 100644 index 0000000..0d4088f Binary files /dev/null and b/assets/sprites/knobuss/ship13.png differ diff --git a/assets/sprites/knobuss/ship14.png b/assets/sprites/knobuss/ship14.png new file mode 100644 index 0000000..87d847b Binary files /dev/null and b/assets/sprites/knobuss/ship14.png differ diff --git a/assets/sprites/knobuss/ship15.png b/assets/sprites/knobuss/ship15.png new file mode 100644 index 0000000..45b9504 Binary files /dev/null and b/assets/sprites/knobuss/ship15.png differ diff --git a/assets/sprites/knobuss/ship2.png b/assets/sprites/knobuss/ship2.png new file mode 100644 index 0000000..fee550e Binary files /dev/null and b/assets/sprites/knobuss/ship2.png differ diff --git a/assets/sprites/knobuss/ship3.png b/assets/sprites/knobuss/ship3.png new file mode 100644 index 0000000..04678d5 Binary files /dev/null and b/assets/sprites/knobuss/ship3.png differ diff --git a/assets/sprites/knobuss/ship4.png b/assets/sprites/knobuss/ship4.png new file mode 100644 index 0000000..ef8b14e Binary files /dev/null and b/assets/sprites/knobuss/ship4.png differ diff --git a/assets/sprites/knobuss/ship5.png b/assets/sprites/knobuss/ship5.png new file mode 100644 index 0000000..948fdfb Binary files /dev/null and b/assets/sprites/knobuss/ship5.png differ diff --git a/assets/sprites/knobuss/ship6.png b/assets/sprites/knobuss/ship6.png new file mode 100644 index 0000000..2ebb323 Binary files /dev/null and b/assets/sprites/knobuss/ship6.png differ diff --git a/assets/sprites/knobuss/ship7.png b/assets/sprites/knobuss/ship7.png new file mode 100644 index 0000000..92c903e Binary files /dev/null and b/assets/sprites/knobuss/ship7.png differ diff --git a/assets/sprites/knobuss/ship8.png b/assets/sprites/knobuss/ship8.png new file mode 100644 index 0000000..50b6521 Binary files /dev/null and b/assets/sprites/knobuss/ship8.png differ diff --git a/assets/sprites/knobuss/ship9.png b/assets/sprites/knobuss/ship9.png new file mode 100644 index 0000000..eebe7fe Binary files /dev/null and b/assets/sprites/knobuss/ship9.png differ diff --git a/assets/sprites/minecraft/flower.png b/assets/sprites/minecraft/flower.png new file mode 100644 index 0000000..2d5771d Binary files /dev/null and b/assets/sprites/minecraft/flower.png differ diff --git a/assets/sprites/minecraft/ground.png b/assets/sprites/minecraft/ground.png new file mode 100644 index 0000000..fa6b8d7 Binary files /dev/null and b/assets/sprites/minecraft/ground.png differ diff --git a/assets/sprites/minecraft/moon.png b/assets/sprites/minecraft/moon.png new file mode 100644 index 0000000..7abcada Binary files /dev/null and b/assets/sprites/minecraft/moon.png differ diff --git a/assets/sprites/minecraft/sun.png b/assets/sprites/minecraft/sun.png new file mode 100644 index 0000000..27ee31f Binary files /dev/null and b/assets/sprites/minecraft/sun.png differ diff --git a/assets/sprites/snake/fruit0.png b/assets/sprites/snake/fruit0.png new file mode 100644 index 0000000..2c30a00 Binary files /dev/null and b/assets/sprites/snake/fruit0.png differ diff --git a/assets/sprites/snake/fruit1.png b/assets/sprites/snake/fruit1.png new file mode 100644 index 0000000..3d1baab Binary files /dev/null and b/assets/sprites/snake/fruit1.png differ diff --git a/assets/sprites/snake/fruit10.png b/assets/sprites/snake/fruit10.png new file mode 100644 index 0000000..a2eb668 Binary files /dev/null and b/assets/sprites/snake/fruit10.png differ diff --git a/assets/sprites/snake/fruit11.png b/assets/sprites/snake/fruit11.png new file mode 100644 index 0000000..fd83081 Binary files /dev/null and b/assets/sprites/snake/fruit11.png differ diff --git a/assets/sprites/snake/fruit2.png b/assets/sprites/snake/fruit2.png new file mode 100644 index 0000000..1174e28 Binary files /dev/null and b/assets/sprites/snake/fruit2.png differ diff --git a/assets/sprites/snake/fruit3.png b/assets/sprites/snake/fruit3.png new file mode 100644 index 0000000..bf12102 Binary files /dev/null and b/assets/sprites/snake/fruit3.png differ diff --git a/assets/sprites/snake/fruit4.png b/assets/sprites/snake/fruit4.png new file mode 100644 index 0000000..15e5243 Binary files /dev/null and b/assets/sprites/snake/fruit4.png differ diff --git a/assets/sprites/snake/fruit5.png b/assets/sprites/snake/fruit5.png new file mode 100644 index 0000000..4e346c7 Binary files /dev/null and b/assets/sprites/snake/fruit5.png differ diff --git a/assets/sprites/snake/fruit6.png b/assets/sprites/snake/fruit6.png new file mode 100644 index 0000000..f3f2bae Binary files /dev/null and b/assets/sprites/snake/fruit6.png differ diff --git a/assets/sprites/snake/fruit7.png b/assets/sprites/snake/fruit7.png new file mode 100644 index 0000000..c5f2b14 Binary files /dev/null and b/assets/sprites/snake/fruit7.png differ diff --git a/assets/sprites/snake/fruit8.png b/assets/sprites/snake/fruit8.png new file mode 100644 index 0000000..4e6d7ca Binary files /dev/null and b/assets/sprites/snake/fruit8.png differ diff --git a/assets/sprites/snake/fruit9.png b/assets/sprites/snake/fruit9.png new file mode 100644 index 0000000..f10c1ee Binary files /dev/null and b/assets/sprites/snake/fruit9.png differ diff --git a/assets/sprites/snake/skull.png b/assets/sprites/snake/skull.png new file mode 100644 index 0000000..7f95088 Binary files /dev/null and b/assets/sprites/snake/skull.png differ diff --git a/assets/sprites/snake/snake-logo.png b/assets/sprites/snake/snake-logo.png new file mode 100644 index 0000000..a32fd58 Binary files /dev/null and b/assets/sprites/snake/snake-logo.png differ diff --git a/assets/sprites/space-wars/enemy1.png b/assets/sprites/space-wars/enemy1.png new file mode 100644 index 0000000..301710c Binary files /dev/null and b/assets/sprites/space-wars/enemy1.png differ diff --git a/assets/sprites/space-wars/enemy2.png b/assets/sprites/space-wars/enemy2.png new file mode 100644 index 0000000..fb3ec06 Binary files /dev/null and b/assets/sprites/space-wars/enemy2.png differ diff --git a/assets/sprites/space-wars/exp0.png b/assets/sprites/space-wars/exp0.png new file mode 100644 index 0000000..26c578a Binary files /dev/null and b/assets/sprites/space-wars/exp0.png differ diff --git a/assets/sprites/space-wars/exp1.png b/assets/sprites/space-wars/exp1.png new file mode 100644 index 0000000..e66a72e Binary files /dev/null and b/assets/sprites/space-wars/exp1.png differ diff --git a/assets/sprites/space-wars/exp2.png b/assets/sprites/space-wars/exp2.png new file mode 100644 index 0000000..33a394e Binary files /dev/null and b/assets/sprites/space-wars/exp2.png differ diff --git a/assets/sprites/space-wars/exp3.png b/assets/sprites/space-wars/exp3.png new file mode 100644 index 0000000..45e35e5 Binary files /dev/null and b/assets/sprites/space-wars/exp3.png differ diff --git a/assets/sprites/space-wars/exp4.png b/assets/sprites/space-wars/exp4.png new file mode 100644 index 0000000..48f51c0 Binary files /dev/null and b/assets/sprites/space-wars/exp4.png differ diff --git a/assets/sprites/space-wars/exp5.png b/assets/sprites/space-wars/exp5.png new file mode 100644 index 0000000..00ce0b3 Binary files /dev/null and b/assets/sprites/space-wars/exp5.png differ diff --git a/assets/sprites/space-wars/logo2.png b/assets/sprites/space-wars/logo2.png new file mode 100644 index 0000000..20a5c7a Binary files /dev/null and b/assets/sprites/space-wars/logo2.png differ diff --git a/assets/sprites/space-wars/player.png b/assets/sprites/space-wars/player.png new file mode 100644 index 0000000..f352e62 Binary files /dev/null and b/assets/sprites/space-wars/player.png differ diff --git a/assets/sprites/space-wars/spacebg.png b/assets/sprites/space-wars/spacebg.png new file mode 100644 index 0000000..e9353fa Binary files /dev/null and b/assets/sprites/space-wars/spacebg.png differ diff --git a/assets/sprites/weather/cloud0.png b/assets/sprites/weather/cloud0.png new file mode 100644 index 0000000..71b671f Binary files /dev/null and b/assets/sprites/weather/cloud0.png differ diff --git a/assets/sprites/weather/cloud1.png b/assets/sprites/weather/cloud1.png new file mode 100644 index 0000000..efeef8f Binary files /dev/null and b/assets/sprites/weather/cloud1.png differ diff --git a/assets/sprites/weather/cloud2.png b/assets/sprites/weather/cloud2.png new file mode 100644 index 0000000..3d19252 Binary files /dev/null and b/assets/sprites/weather/cloud2.png differ diff --git a/assets/sprites/weather/cloud3.png b/assets/sprites/weather/cloud3.png new file mode 100644 index 0000000..efeef8f Binary files /dev/null and b/assets/sprites/weather/cloud3.png differ diff --git a/assets/sprites/weather/cloud4.png b/assets/sprites/weather/cloud4.png new file mode 100644 index 0000000..71b671f Binary files /dev/null and b/assets/sprites/weather/cloud4.png differ diff --git a/assets/sprites/weather/cloud5.png b/assets/sprites/weather/cloud5.png new file mode 100644 index 0000000..805b73e Binary files /dev/null and b/assets/sprites/weather/cloud5.png differ diff --git a/assets/sprites/weather/cloud6.png b/assets/sprites/weather/cloud6.png new file mode 100644 index 0000000..71b395f Binary files /dev/null and b/assets/sprites/weather/cloud6.png differ diff --git a/assets/sprites/weather/cloud7.png b/assets/sprites/weather/cloud7.png new file mode 100644 index 0000000..805b73e Binary files /dev/null and b/assets/sprites/weather/cloud7.png differ diff --git a/assets/sprites/weather/moon0.png b/assets/sprites/weather/moon0.png new file mode 100644 index 0000000..e66e62d Binary files /dev/null and b/assets/sprites/weather/moon0.png differ diff --git a/assets/sprites/weather/moon1.png b/assets/sprites/weather/moon1.png new file mode 100644 index 0000000..6a31cd2 Binary files /dev/null and b/assets/sprites/weather/moon1.png differ diff --git a/assets/sprites/weather/moon2.png b/assets/sprites/weather/moon2.png new file mode 100644 index 0000000..4e88a3d Binary files /dev/null and b/assets/sprites/weather/moon2.png differ diff --git a/assets/sprites/weather/moon3.png b/assets/sprites/weather/moon3.png new file mode 100644 index 0000000..6a31cd2 Binary files /dev/null and b/assets/sprites/weather/moon3.png differ diff --git a/assets/sprites/weather/moon4.png b/assets/sprites/weather/moon4.png new file mode 100644 index 0000000..e66e62d Binary files /dev/null and b/assets/sprites/weather/moon4.png differ diff --git a/assets/sprites/weather/moon5.png b/assets/sprites/weather/moon5.png new file mode 100644 index 0000000..38f5804 Binary files /dev/null and b/assets/sprites/weather/moon5.png differ diff --git a/assets/sprites/weather/moon6.png b/assets/sprites/weather/moon6.png new file mode 100644 index 0000000..26e0949 Binary files /dev/null and b/assets/sprites/weather/moon6.png differ diff --git a/assets/sprites/weather/moon7.png b/assets/sprites/weather/moon7.png new file mode 100644 index 0000000..38f5804 Binary files /dev/null and b/assets/sprites/weather/moon7.png differ diff --git a/assets/sprites/weather/partly0.png b/assets/sprites/weather/partly0.png new file mode 100644 index 0000000..adc63a3 Binary files /dev/null and b/assets/sprites/weather/partly0.png differ diff --git a/assets/sprites/weather/partly1.png b/assets/sprites/weather/partly1.png new file mode 100644 index 0000000..32880f5 Binary files /dev/null and b/assets/sprites/weather/partly1.png differ diff --git a/assets/sprites/weather/partly2.png b/assets/sprites/weather/partly2.png new file mode 100644 index 0000000..632b2da Binary files /dev/null and b/assets/sprites/weather/partly2.png differ diff --git a/assets/sprites/weather/partly3.png b/assets/sprites/weather/partly3.png new file mode 100644 index 0000000..63cc11c Binary files /dev/null and b/assets/sprites/weather/partly3.png differ diff --git a/assets/sprites/weather/partly4.png b/assets/sprites/weather/partly4.png new file mode 100644 index 0000000..fad07bc Binary files /dev/null and b/assets/sprites/weather/partly4.png differ diff --git a/assets/sprites/weather/partly5.png b/assets/sprites/weather/partly5.png new file mode 100644 index 0000000..5ce6011 Binary files /dev/null and b/assets/sprites/weather/partly5.png differ diff --git a/assets/sprites/weather/partly6.png b/assets/sprites/weather/partly6.png new file mode 100644 index 0000000..cb436af Binary files /dev/null and b/assets/sprites/weather/partly6.png differ diff --git a/assets/sprites/weather/partly7.png b/assets/sprites/weather/partly7.png new file mode 100644 index 0000000..23dc472 Binary files /dev/null and b/assets/sprites/weather/partly7.png differ diff --git a/assets/sprites/weather/rain0.png b/assets/sprites/weather/rain0.png new file mode 100644 index 0000000..f184778 Binary files /dev/null and b/assets/sprites/weather/rain0.png differ diff --git a/assets/sprites/weather/rain1.png b/assets/sprites/weather/rain1.png new file mode 100644 index 0000000..5039ffe Binary files /dev/null and b/assets/sprites/weather/rain1.png differ diff --git a/assets/sprites/weather/rain2.png b/assets/sprites/weather/rain2.png new file mode 100644 index 0000000..dfbceea Binary files /dev/null and b/assets/sprites/weather/rain2.png differ diff --git a/assets/sprites/weather/rain3.png b/assets/sprites/weather/rain3.png new file mode 100644 index 0000000..34c3372 Binary files /dev/null and b/assets/sprites/weather/rain3.png differ diff --git a/assets/sprites/weather/rain4.png b/assets/sprites/weather/rain4.png new file mode 100644 index 0000000..30abf8c Binary files /dev/null and b/assets/sprites/weather/rain4.png differ diff --git a/assets/sprites/weather/rain5.png b/assets/sprites/weather/rain5.png new file mode 100644 index 0000000..42e6d7d Binary files /dev/null and b/assets/sprites/weather/rain5.png differ diff --git a/assets/sprites/weather/rain6.png b/assets/sprites/weather/rain6.png new file mode 100644 index 0000000..78b6a5f Binary files /dev/null and b/assets/sprites/weather/rain6.png differ diff --git a/assets/sprites/weather/rain7.png b/assets/sprites/weather/rain7.png new file mode 100644 index 0000000..7968086 Binary files /dev/null and b/assets/sprites/weather/rain7.png differ diff --git a/assets/sprites/weather/snow0.png b/assets/sprites/weather/snow0.png new file mode 100644 index 0000000..9085e51 Binary files /dev/null and b/assets/sprites/weather/snow0.png differ diff --git a/assets/sprites/weather/snow1.png b/assets/sprites/weather/snow1.png new file mode 100644 index 0000000..06dd573 Binary files /dev/null and b/assets/sprites/weather/snow1.png differ diff --git a/assets/sprites/weather/snow2.png b/assets/sprites/weather/snow2.png new file mode 100644 index 0000000..746ac46 Binary files /dev/null and b/assets/sprites/weather/snow2.png differ diff --git a/assets/sprites/weather/snow3.png b/assets/sprites/weather/snow3.png new file mode 100644 index 0000000..c97cd2e Binary files /dev/null and b/assets/sprites/weather/snow3.png differ diff --git a/assets/sprites/weather/snow4.png b/assets/sprites/weather/snow4.png new file mode 100644 index 0000000..27cbeee Binary files /dev/null and b/assets/sprites/weather/snow4.png differ diff --git a/assets/sprites/weather/snow5.png b/assets/sprites/weather/snow5.png new file mode 100644 index 0000000..e08babf Binary files /dev/null and b/assets/sprites/weather/snow5.png differ diff --git a/assets/sprites/weather/snow6.png b/assets/sprites/weather/snow6.png new file mode 100644 index 0000000..84d2fa3 Binary files /dev/null and b/assets/sprites/weather/snow6.png differ diff --git a/assets/sprites/weather/snow7.png b/assets/sprites/weather/snow7.png new file mode 100644 index 0000000..97c4fd6 Binary files /dev/null and b/assets/sprites/weather/snow7.png differ diff --git a/assets/sprites/weather/storm0.png b/assets/sprites/weather/storm0.png new file mode 100644 index 0000000..e1de8c0 Binary files /dev/null and b/assets/sprites/weather/storm0.png differ diff --git a/assets/sprites/weather/storm1.png b/assets/sprites/weather/storm1.png new file mode 100644 index 0000000..cdd823a Binary files /dev/null and b/assets/sprites/weather/storm1.png differ diff --git a/assets/sprites/weather/storm2.png b/assets/sprites/weather/storm2.png new file mode 100644 index 0000000..768af6d Binary files /dev/null and b/assets/sprites/weather/storm2.png differ diff --git a/assets/sprites/weather/storm3.png b/assets/sprites/weather/storm3.png new file mode 100644 index 0000000..c6b5ba1 Binary files /dev/null and b/assets/sprites/weather/storm3.png differ diff --git a/assets/sprites/weather/storm4.png b/assets/sprites/weather/storm4.png new file mode 100644 index 0000000..ab4232e Binary files /dev/null and b/assets/sprites/weather/storm4.png differ diff --git a/assets/sprites/weather/storm5.png b/assets/sprites/weather/storm5.png new file mode 100644 index 0000000..b40404f Binary files /dev/null and b/assets/sprites/weather/storm5.png differ diff --git a/assets/sprites/weather/storm6.png b/assets/sprites/weather/storm6.png new file mode 100644 index 0000000..8853bd3 Binary files /dev/null and b/assets/sprites/weather/storm6.png differ diff --git a/assets/sprites/weather/storm7.png b/assets/sprites/weather/storm7.png new file mode 100644 index 0000000..826b269 Binary files /dev/null and b/assets/sprites/weather/storm7.png differ diff --git a/assets/sprites/weather/sun0.png b/assets/sprites/weather/sun0.png new file mode 100644 index 0000000..219a7c1 Binary files /dev/null and b/assets/sprites/weather/sun0.png differ diff --git a/assets/sprites/weather/sun1.png b/assets/sprites/weather/sun1.png new file mode 100644 index 0000000..7e3e12d Binary files /dev/null and b/assets/sprites/weather/sun1.png differ diff --git a/assets/sprites/weather/sun2.png b/assets/sprites/weather/sun2.png new file mode 100644 index 0000000..d07a8c1 Binary files /dev/null and b/assets/sprites/weather/sun2.png differ diff --git a/assets/sprites/weather/sun3.png b/assets/sprites/weather/sun3.png new file mode 100644 index 0000000..089e051 Binary files /dev/null and b/assets/sprites/weather/sun3.png differ diff --git a/assets/sprites/weather/sun4.png b/assets/sprites/weather/sun4.png new file mode 100644 index 0000000..0330b27 Binary files /dev/null and b/assets/sprites/weather/sun4.png differ diff --git a/assets/sprites/weather/sun5.png b/assets/sprites/weather/sun5.png new file mode 100644 index 0000000..1c7d8ac Binary files /dev/null and b/assets/sprites/weather/sun5.png differ diff --git a/assets/sprites/weather/sun6.png b/assets/sprites/weather/sun6.png new file mode 100644 index 0000000..abf5108 Binary files /dev/null and b/assets/sprites/weather/sun6.png differ diff --git a/assets/sprites/weather/sun7.png b/assets/sprites/weather/sun7.png new file mode 100644 index 0000000..c7440c0 Binary files /dev/null and b/assets/sprites/weather/sun7.png differ diff --git a/base/core.yaml b/base/core.yaml new file mode 100644 index 0000000..0d09ba0 --- /dev/null +++ b/base/core.yaml @@ -0,0 +1,2932 @@ +############################################################################### +# Guition JC3636K718C (1.8" 360x360 ROUND KNOB, ESP32-S3) - core config +# Pins and init_sequence from the OFFICIAL manufacturer demo (JC3636K718_knob_EN), +# files pincfg.h + scr_st77916.h. This is the correct pinout for THIS board +# (different from the JC3636W518!). +# +# Always-on core: display (ST77916/QSPI) + touch (CST816) + knob + audio + voice assistant + +# LED ring + the clock home screen, control tiles and settings. Optional carousel screens +# (player, timer, games, weather, demo) live as packages in base/screens/ - see base/screens/README.md. +############################################################################### + +substitutions: + name: voice-knob + friendly_name: "Voice Knob" + # Clock timezone. HA does not push its zone to the device, so set yours here. Use a friendly IANA + # name (e.g. Europe/Warsaw, America/New_York) - core maps the common ones to the POSIX form the + # device needs (it has no IANA database). For a zone not in that table, paste its POSIX string + # instead (see https://github.com/nayarsystems/posix_tz_db/blob/master/zones.csv). DST automatic. + timezone: "Australia/Brisbane" + # For album art: base URL of your HA + the media_player entity (change if different) + ha_base_url: "http://ha.iosull.com" + player_entity: media_player.voice_knob_player + # On-device wake word. Built-in models: alexa, okay_nabu, hey_jarvis, hey_mycroft. + wake_word: "hey_jarvis" + # Entities for the home screen (toggle each in Settings -> Home). Change if different. + weather_entity: weather.home + room_temp_entity: sensor.room_temperature + room_hum_entity: sensor.room_humidity + climate_entity: climate.home # thermostat screen target (a climate.* entity) + # Control tiles (swipe up): 4 toggleable HA entities, each with an icon + a name. Any domain. + # tileN_icon = a name from the built-in set: bulb lamp led power plug switch fan ac heater + # thermometer lock unlock door blinds tv speaker bell wifi home flash. The same icon may + # repeat across tiles. (For an icon outside the set, paste its glyph or a \U000Fxxxx code.) + tile1_entity: light.desk_lamp + tile1_icon: "bulb" + tile1_name: "Lamp" + tile2_entity: switch.neon + tile2_icon: "flash" + tile2_name: "Neon" + tile3_entity: light.desk_lamp + tile3_icon: "power" + tile3_name: "Tile " + # Sensors screen (knob cycles): up to 6 read-only HA entities, one shown big at a time. + # Set as many glanceN_entity as you want (+ a name, optional icon name); leave the rest at + # their default (a non-existent entity) and they're skipped. + glance1_entity: sensor.power + glance1_name: "Power" + glance1_icon: "flash" + glance2_entity: sensor.va_glance_unused2 + glance2_name: "" + glance2_icon: "" + glance3_entity: sensor.va_glance_unused3 + glance3_name: "" + glance3_icon: "" + glance4_entity: sensor.va_glance_unused4 + glance4_name: "" + glance4_icon: "" + glance5_entity: sensor.va_glance_unused5 + glance5_name: "" + glance5_icon: "" + glance6_entity: sensor.va_glance_unused6 + glance6_name: "" + glance6_icon: "" + forecast_entity: sensor.va_weekly_forecast # weather screen forecast (HA helper, see base/screens/weather.ha-helper.yaml) + # Carousel screen order (left-to-right). Screens not included in the build are skipped. + screen_order: "clock,player,timer,cars,space,snake,knobuss,weather,thermostat,sensors,demo" + +esphome: + name: ${name} + friendly_name: ${friendly_name} + platformio_options: + board_build.flash_mode: dio + board_build.f_flash: 80000000L + board_build.f_cpu: 240000000L + on_boot: + - priority: 800 + then: + - lambda: |- + id(g_order_n) = 0; + id(g_set_n) = 0; + id(g_set_labels) = ""; + for (int i = 0; i < 16; i++) id(g_present)[i] = false; + id(g_present)[0] = true; // clock is always present + id(g_wgrp_n) = 0; id(g_wgrp_labels) = ""; id(g_wgrp_idx) = 0; + id(g_wopt_n) = 0; id(g_wcur_n) = 0; id(g_wopt_idx) = 0; + - priority: 790 + then: + - lambda: |- + // core settings list: label + the g_mode each opens. (Screen packages add their + // own entries under Widgets via the g_wgrp_* / g_wopt_* registry, not here.) + id(g_set_labels) += "Display"; id(g_set_items)[id(g_set_n)++] = 11; + id(g_set_labels) += "\nHome screen"; id(g_set_items)[id(g_set_n)++] = 12; + id(g_set_labels) += "\nWidgets"; id(g_set_items)[id(g_set_n)++] = 17; + id(g_set_labels) += "\nVoice Assistant"; id(g_set_items)[id(g_set_n)++] = 13; + - priority: 760 + then: + - lambda: |- + // Resolve a friendly icon name to its MDI glyph. Unknown -> use the value as-is. + auto ic = [](const std::string &s) -> std::string { + if (s == "bulb" || s == "light") return "\U000f0335"; + if (s == "lamp") return "\U000f06b5"; + if (s == "led" || s == "strip") return "\U000f07d6"; + if (s == "power") return "\U000f0425"; + if (s == "plug") return "\U000f06a5"; + if (s == "switch") return "\U000f0521"; + if (s == "fan") return "\U000f0210"; + if (s == "ac" || s == "aircon") return "\U000f001b"; + if (s == "heater" || s == "radiator") return "\U000f0438"; + if (s == "thermometer" || s == "temp") return "\U000f050f"; + if (s == "lock") return "\U000f033e"; + if (s == "unlock") return "\U000f033f"; + if (s == "door") return "\U000f081a"; + if (s == "blinds") return "\U000f00ac"; + if (s == "tv") return "\U000f0502"; + if (s == "speaker") return "\U000f04c3"; + if (s == "bell") return "\U000f009a"; + if (s == "wifi") return "\U000f05a9"; + if (s == "home") return "\U000f02dc"; + if (s == "flash") return "\U000f0241"; + if (s == "air" || s == "pm" || s == "airquality") return "\U000f0d44"; + if (s == "plant" || s == "sprout" || s == "moisture") return "\U000f0e66"; + if (s == "plex") return "\U000f06ba"; + if (s == "server") return "\U000f048b"; + if (s == "shield" || s == "alarm" || s == "security") return "\U000f068a"; + return s; + }; + id(g_tile1_glyph) = ic("${tile1_icon}"); + id(g_tile2_glyph) = ic("${tile2_icon}"); + id(g_tile3_glyph) = ic("${tile3_icon}"); + - priority: 595 + then: + - lambda: 'id(g_set_labels) += "\nSystem"; id(g_set_items)[id(g_set_n)++] = 15;' # always last + - priority: 600 + then: + - lambda: |- + // build the carousel order from ${screen_order}, skipping screens not in this build + std::string s = "${screen_order}"; + size_t pos = 0; + while (true) { + size_t comma = s.find(',', pos); + std::string tok = (comma == std::string::npos) ? s.substr(pos) : s.substr(pos, comma - pos); + while (!tok.empty() && tok.front() == ' ') tok.erase(tok.begin()); + while (!tok.empty() && tok.back() == ' ') tok.pop_back(); + int id = -1; + if (tok == "clock") id = 0; + else if (tok == "player") id = 1; + else if (tok == "timer") id = 2; + else if (tok == "cars") id = 3; + else if (tok == "space") id = 4; + else if (tok == "weather") id = 5; + else if (tok == "demo") id = 6; + else if (tok == "thermostat") id = 7; + else if (tok == "sensors") id = 8; + else if (tok == "snake") id = 9; + else if (tok == "gyruss" || tok == "knobuss") id = 10; + if (id >= 0 && id < 16 && id(g_present)[id]) id(g_order)[id(g_order_n)++] = id; + if (comma == std::string::npos) break; + pos = comma + 1; + } + if (id(g_order_n) == 0) id(g_order)[id(g_order_n)++] = 0; // safety: keep the clock + - priority: -50 + then: + - if: + condition: { lambda: 'return id(g_factory_reset);' } + then: + - delay: 2s # give the game packages' interval watchers time to clear their scores + - lambda: 'id(g_factory_reset) = false;' + - priority: -100 + then: + - lambda: |- + // The device has no IANA tz database, so the clock needs a POSIX TZ string. To keep the + // `timezone` substitution friendly, map common IANA names -> POSIX here and force the + // result into the environment ourselves (on some builds the homeassistant time platform + // never applies the zone and the clock sticks on UTC). An explicit POSIX string is used + // as-is; an IANA name not in the table falls back to whatever the time platform resolved. + // The 1s interval re-applies g_tz_posix so a runtime reset to UTC can't stick. + static const char* TZMAP[][2] = { + {"Europe/Warsaw","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/London","GMT0BST,M3.5.0/1,M10.5.0"}, + {"Europe/Berlin","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Paris","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Madrid","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Rome","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Amsterdam","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Brussels","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Vienna","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Prague","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Zurich","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Copenhagen","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Stockholm","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Oslo","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Budapest","CET-1CEST,M3.5.0,M10.5.0/3"}, + {"Europe/Lisbon","WET0WEST,M3.5.0/1,M10.5.0"}, + {"Europe/Athens","EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Helsinki","EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Bucharest","EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Sofia","EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Kyiv","EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"Europe/Moscow","MSK-3"}, + {"Europe/Istanbul","<+03>-3"}, + {"Europe/Dublin","IST-1GMT0,M10.5.0,M3.5.0/1"}, + {"America/New_York","EST5EDT,M3.2.0,M11.1.0"}, + {"America/Chicago","CST6CDT,M3.2.0,M11.1.0"}, + {"America/Denver","MST7MDT,M3.2.0,M11.1.0"}, + {"America/Los_Angeles","PST8PDT,M3.2.0,M11.1.0"}, + {"America/Phoenix","MST7"}, + {"America/Toronto","EST5EDT,M3.2.0,M11.1.0"}, + {"America/Sao_Paulo","<-03>3"}, + {"America/Mexico_City","CST6"}, + {"Asia/Tokyo","JST-9"}, + {"Asia/Shanghai","CST-8"}, + {"Asia/Hong_Kong","HKT-8"}, + {"Asia/Singapore","<+08>-8"}, + {"Asia/Dubai","<+04>-4"}, + {"Asia/Kolkata","IST-5:30"}, + {"Asia/Seoul","KST-9"}, + {"Asia/Jerusalem","IST-2IDT,M3.4.4/26,M10.5.0"}, + {"Australia/Sydney","AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {"Australia/Perth","AWST-8"}, + {"Pacific/Auckland","NZST-12NZDT,M9.5.0,M4.1.0/3"}, + {"UTC","UTC0"}, + }; + std::string cfg = "${timezone}"; + std::string tz = cfg; + bool found = false; + for (auto &e : TZMAP) if (cfg == e[0]) { tz = e[1]; found = true; break; } + if (!found && cfg.find('/') != std::string::npos) { + const char* en = getenv("TZ"); if (en && en[0]) tz = en; // unknown IANA -> platform's resolved zone + } + id(g_tz_posix) = tz; + if (!tz.empty()) { setenv("TZ", tz.c_str(), 1); tzset(); } + - script.execute: night_apply # brightness per g_bright / night mode + - script.execute: disp_apply # show/hide weather + temp/humidity per settings + - script.execute: settings_build # push the dynamic settings list into the roller + - lambda: 'id(g_wf_present)[0] = true; id(g_wf_name)[0] = "Classic";' # register the built-in Classic watchface (id 0) + - lvgl.label.update: { id: tile1_ic, text: !lambda 'return id(g_tile1_glyph);' } # tiles get their icon glyph now (set at priority 760) + - lvgl.label.update: { id: tile2_ic, text: !lambda 'return id(g_tile2_glyph);' } + - lvgl.label.update: { id: tile3_ic, text: !lambda 'return id(g_tile3_glyph);' } + # Watchface finalize - runs after every face package has registered (priority < 650). + # If the saved face isn't present (its file was removed), fall back to Classic, then build + # the Home settings roller line. The face itself is shown at the end of the boot splash. + - priority: 500 + then: + - lambda: 'if (id(g_watchface) < 0 || id(g_watchface) >= 8 || !id(g_wf_present)[id(g_watchface)]) id(g_watchface) = 0;' + - script.execute: home_roller_build + +esp32: + board: esp32-s3-devkitc-1 + flash_size: 16MB # 16 MB flash + partitions: partitions.csv # MAX app partitions (~7.9 MB/slot) - see partitions.csv. Requires USB flashing! + framework: + type: esp-idf + sdkconfig_options: + CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y" + CONFIG_ESP32S3_DATA_CACHE_64KB: "y" + CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y" + CONFIG_SPIRAM_FETCH_INSTRUCTIONS: y + CONFIG_SPIRAM_RODATA: y + +psram: + mode: octal + speed: 80MHz + +logger: + level: INFO # less logging = less overhead (DEBUG was very noisy, esp. during VA) + +api: + +ota: + - platform: esphome + +# OTA rollback safety. On esp-idf the bootloader rolls back to the PREVIOUS firmware if the device +# resets before the boot is marked "good" (default 1 min). That is why a quick "Restart" from the +# menu right after flashing booted the OLD build again. We shorten that window AND mark the boot good +# as soon as the splash finishes (see the boot interval -> safe_mode.mark_successful). +safe_mode: + boot_is_good_after: 30s + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + ssid: "${friendly_name} Setup" + +captive_portal: + +time: + - platform: homeassistant + id: ha_time + # The homeassistant time platform syncs the epoch from HA but NOT the timezone, so + # without this the clock falls back to the compile host's zone (often UTC). Set your + # IANA zone in the `timezone` substitution (top of file / thin config). DST is handled. + timezone: "${timezone}" + +############################################################################### +# AUDIO - DAC PCM5100A (out) + microphone + voice assistant (HA Assist) +# DAC: BCK=3 WS=45 DO=42 (no MCK) MUTE=46 (LOW=mute -> we hold HIGH) +# MIC: data(SD)=4 clk(SCK)=5 -> configured as PDM +############################################################################### +switch: + - platform: gpio + pin: GPIO0 + name: "DAC unmute" + id: dac_unmute + restore_mode: ALWAYS_ON # HIGH = not muted + +i2s_audio: + - id: i2s_out # DAC PCM5100A + i2s_bclk_pin: GPIO39 + i2s_lrclk_pin: GPIO40 + - id: i2s_in # PDM microphone: clock=GPIO5 + i2s_lrclk_pin: GPIO45 + +speaker: + # Raw I2S output to the DAC + - platform: i2s_audio + id: i2s_speaker_raw + i2s_audio_id: i2s_out + i2s_dout_pin: GPIO41 + dac_type: external + channel: stereo + # Mixer: combines the music channel and the announcement (TTS) channel on one speaker + - platform: mixer + id: mixing_speaker + output_speaker: i2s_speaker_raw + source_speakers: + - id: announcement_mixing_input + - id: media_mixing_input + # Resamplers (match source sample rates to the mixer) + - platform: resampler + id: media_resampling_speaker + output_speaker: media_mixing_input + - platform: resampler + id: announcement_resampling_speaker + output_speaker: announcement_mixing_input + +# Player visible in HA / Music Assistant (album art etc.) +media_player: + - platform: speaker + name: "Player" + id: media_player_speaker + announcement_pipeline: + speaker: announcement_resampling_speaker + media_pipeline: + speaker: media_resampling_speaker + files: + - id: wake_sound + file: https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sounds/wake.wav # beep after the wake word + - id: alarm_sound + file: https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sounds/alarm.wav # timer alarm (generated with make_sounds.py) + +microphone: + - platform: i2s_audio + id: i2s_microphone + i2s_audio_id: i2s_in + i2s_din_pin: GPIO46 + adc_type: external + pdm: true + sample_rate: 16000 + bits_per_sample: 16bit + +voice_assistant: + id: va + microphone: i2s_microphone + speaker: announcement_resampling_speaker # TTS via the announcement channel (ducks the music) + noise_suppression_level: 2 + auto_gain: 31dBFS + volume_multiplier: 2.0 + # Wake word works only when connected to HA (Assist pipeline) + on_client_connected: + - micro_wake_word.start: + on_client_disconnected: + - micro_wake_word.stop: + on_listening: + - lambda: |- + id(g_va_active) = true; + id(g_tts_played) = false; + id(g_va_phase) = 1; + - mixer_speaker.apply_ducking: + id: media_mixing_input + decibel_reduction: 20 + duration: 0.2s + - lvgl.label.update: { id: va_text, text: "Listening..." } + - script.execute: va_show + on_stt_end: + - lambda: 'id(g_va_phase) = 2;' + - lvgl.label.update: { id: va_text, text: "Thinking..." } + on_tts_start: + - lambda: |- + id(g_tts_played) = true; + id(g_va_phase) = 3; + - lvgl.label.update: { id: va_text, text: "Speaking..." } + # TTS actually stopped -> clear the flag, hide the overlay, restore music volume + on_tts_stream_end: + - lambda: 'id(g_va_active) = false;' + - script.execute: va_hide + - mixer_speaker.apply_ducking: + id: media_mixing_input + decibel_reduction: 0 + duration: 0.5s + on_end: + # Cleanup only when there was NO TTS (command without a response); otherwise on_tts_stream_end handles it + - if: + condition: + lambda: 'return !id(g_tts_played);' + then: + - lambda: 'id(g_va_active) = false;' + - script.execute: va_hide + - mixer_speaker.apply_ducking: + id: media_mixing_input + decibel_reduction: 0 + duration: 0.3s + - micro_wake_word.start: # resume listening for the wake word + on_error: + - lambda: 'id(g_va_active) = false;' + - script.execute: va_hide + - mixer_speaker.apply_ducking: + id: media_mixing_input + decibel_reduction: 0 + duration: 0.3s + - micro_wake_word.start: + # --- Timers set by VOICE (HA counts down, the puck shows the same screen as the knob timer) --- + on_timer_started: + - lambda: |- + id(g_timer_secs) = (int) timer.seconds_left; + id(g_timer_total) = (int) timer.seconds_left; + id(g_timer_active) = true; + id(g_timer_paused) = false; + id(g_timer_local) = false; // HA counts down and will call finished; locally just smooth display + - script.execute: timer_render + on_timer_updated: + - lambda: |- + id(g_timer_secs) = (int) timer.seconds_left; + if (timer.is_active) { id(g_timer_active) = true; id(g_timer_paused) = false; } + - script.execute: timer_render + on_timer_finished: + - script.execute: alarm_start + on_timer_cancelled: + - lambda: 'id(g_timer_active) = false; id(g_timer_paused) = false; id(g_timer_secs) = 0;' + - script.execute: timer_render + +############################################################################### +# WAKE WORD on-device (built-in micro_wake_word, no external component; model = ${wake_word}) +############################################################################### +micro_wake_word: + microphone: i2s_microphone + vad: + models: + - model: ${wake_word} + on_wake_word_detected: + - lambda: |- + id(g_va_active) = true; + id(g_va_phase) = 1; + - mixer_speaker.apply_ducking: + id: media_mixing_input + decibel_reduction: 20 + duration: 0.15s + - lvgl.label.update: { id: va_text, text: "Yes?" } + - script.execute: va_show + - if: + condition: { lambda: 'return id(g_beep_ww);' } + then: + - media_player.speaker.play_on_device_media_file: + id: media_player_speaker + media_file: wake_sound + announcement: true + - voice_assistant.start: + + +# Variables for swipe gestures: dx/dy relative to touch start (x_org/y_org) +globals: + - id: g_va_active + type: bool + restore_value: no + initial_value: 'false' + - id: g_tts_played + type: bool + restore_value: no + initial_value: 'false' + - id: g_x0 + type: int + restore_value: no + initial_value: '0' + - id: g_y0 + type: int + restore_value: no + initial_value: '0' + - id: g_t0 + type: uint32_t + restore_value: no + initial_value: '0' + - id: g_dx + type: int + restore_value: no + initial_value: '0' + - id: g_dy + type: int + restore_value: no + initial_value: '0' + # --- UI mode system --- 0=normal 1=menu 2=timer_set 3=timer_run 4=alarm + - id: g_mode + type: int + restore_value: no + initial_value: '0' + - id: g_mode0 # snapshot of the mode at gesture time (routing), so one gesture doesn't cascade + type: int + restore_value: no + initial_value: '0' + - id: g_gesture # 0=tap 1=hold 2=left 3=right 4=up 5=down -1=ignore + type: int + restore_value: no + initial_value: '0' + - id: g_vol_shown # true = volume overlay on top (tap hides it and consumes the click) + type: bool + restore_value: no + initial_value: 'false' + - id: g_cur_page # current page: 0=base 1=page_mode 2=page_settings 3=page_controls + type: int + restore_value: no + initial_value: '0' + - id: g_exit_anim # one-shot direction of the mode-exit animation: 0=right, 1=down + type: int + restore_value: no + initial_value: '0' + - id: g_show_weather # Display: show weather on the home screen (persistent) + type: bool + restore_value: yes + initial_value: 'true' + - id: g_show_climate # Display: show room temperature+humidity (persistent) + type: bool + restore_value: yes + initial_value: 'true' + - id: g_watchface # Home watchface: 0 = Classic, 1 = Neon (persistent) + type: int + restore_value: yes + initial_value: '0' + - id: g_wf_prev # watchface picker: face to restore if the user cancels + type: int + restore_value: no + initial_value: '0' + - id: g_wf_pick # watchface picker: face currently being previewed + type: int + restore_value: no + initial_value: '0' + - id: g_wf_present # registry: which watchface ids exist (0=Classic in core; others self-register) + type: bool[8] + restore_value: no + - id: g_wf_name # registry: display name per watchface id + type: std::string[8] + restore_value: no + - id: g_wf_req # request flag: a non-Classic face self-shows its page when this is set + type: bool + restore_value: no + initial_value: 'false' + - id: g_home_last_min # Classic clock: last drawn minute (perf - redraw the clock only on change) + type: int + restore_value: no + initial_value: '-1' + - id: g_home_upd_min + type: bool + restore_value: no + initial_value: 'false' + - id: g_nf_glyph # current weather MDI glyph (set when the weather state changes; reused by the Neon face) + type: std::string + restore_value: no + initial_value: '""' + - id: g_nf_cond # current weather condition, friendly UPPERCASE (Neon face) + type: std::string + restore_value: no + initial_value: '""' + - id: g_night # Night mode: screen dimming (persistent) + type: bool + restore_value: yes + initial_value: 'false' + - id: g_disp_idx # selection in the Display submenu (roller) + type: int + restore_value: no + initial_value: '0' + - id: g_base # current carousel screen id (fixed: 0=clock 1=player 2=timer 3=cars 4=space 5=weather 6=demo) + type: int + restore_value: no + initial_value: '0' + - id: g_timer_active # timer has an active session (running or paused) - regardless of screen + type: bool + restore_value: no + initial_value: 'false' + - id: g_timer_paused + type: bool + restore_value: no + initial_value: 'false' + - id: g_timer_total # total timer time (s) - for the progress ring + type: int + restore_value: no + initial_value: '300' + - id: g_set_min # default timer minutes (after restart); the knob changes it + type: int + restore_value: no + initial_value: '60' + - id: g_timer_secs # remaining seconds (display + local countdown) + type: int + restore_value: no + initial_value: '0' + - id: g_timer_local # true = knob timer (we count down locally); false = from VA (HA counts down) + type: bool + restore_value: no + initial_value: 'false' + - id: g_alarm_cnt + type: int + restore_value: no + initial_value: '0' + - id: g_alarm_pulse # alarm screen: toggles each tick for the pulsing bell/scrim + type: bool + restore_value: no + initial_value: 'false' + - id: g_bright # screen brightness in % (settings) + type: int + restore_value: no + initial_value: '100' + - id: g_batt # battery level in % (-1 = no reading yet -> show "-") + type: int + restore_value: no + initial_value: '-1' + - id: g_charging # true = charging/on USB (voltage heuristic - no STAT pin on GPIO) + type: bool + restore_value: no + initial_value: 'false' + - id: g_v_med # last median VCC_OUT voltage (to detect rising = charging) + type: float + restore_value: no + initial_value: '0.0' + - id: g_chg_anim # frame of the battery "filling" animation while charging (0..4) + type: int + restore_value: no + initial_value: '0' + - id: g_night_from + type: int + restore_value: yes + initial_value: '22' + - id: g_night_to + type: int + restore_value: yes + initial_value: '7' + - id: g_blank_idx + type: int + restore_value: yes + initial_value: '0' + - id: g_idle_ticks + type: int + restore_value: no + initial_value: '0' + - id: g_disp_off + type: bool + restore_value: no + initial_value: 'false' + - id: g_want_off + type: bool + restore_value: no + initial_value: 'false' + - id: g_wake_consume + type: bool + restore_value: no + initial_value: 'false' + - id: g_sub_idx # selection inside a settings submenu (Home/Assistant/Ring/System/Demo/Weather) + type: int + restore_value: no + initial_value: '0' + - id: g_beep_ww # beep on wake word detection (Sound category) + type: bool + restore_value: yes + initial_value: 'true' + - id: g_hold_talk # press-and-hold the screen to start the assistant + type: bool + restore_value: yes + initial_value: 'true' + - id: g_tz_posix # resolved POSIX timezone captured at boot (to re-apply if runtime resets it to UTC) + type: std::string + restore_value: no + - id: g_set_idx # selected item in the settings list (roller) + type: int + restore_value: no + initial_value: '0' + - id: g_ww_on # wake word enabled + type: bool + restore_value: no + initial_value: 'true' + - id: g_va_phase # assistant phase for the ring: 1=listening 2=thinking 3=speaking + type: int + restore_value: no + initial_value: '0' + - id: g_vol # last volume from HA (0..1) - for the ring volume bar + type: float + restore_value: no + initial_value: '0.0' + - id: g_vol_show # true = show volume on the ring (briefly, ~2 s after turning) + type: bool + restore_value: no + initial_value: 'false' + + # --- Modular carousel contract: shared globals so the core never names a screen's symbols --- + - id: g_order + type: int[16] + restore_value: no + - id: g_order_n + type: int + restore_value: no + initial_value: '0' + - id: g_nav_req + type: bool + restore_value: no + initial_value: 'false' + - id: g_nav_anim + type: int + restore_value: no + initial_value: '1' + - id: g_knob_capture + type: bool + restore_value: no + initial_value: 'false' + - id: g_knob_delta + type: int + restore_value: no + initial_value: '0' + # --- Dynamic settings registry: each screen package can add its own entries --- + - id: g_set_items # g_mode each settings row opens + type: int[12] + restore_value: no + - id: g_set_n + type: int + restore_value: no + initial_value: '0' + - id: g_set_labels # newline-joined roller labels, applied at boot + type: std::string + restore_value: no + - id: g_present # which screens registered (by base id); carousel is built from screen_order + type: bool[16] + restore_value: no + - id: g_tap_seq # bumped on every normal-mode tap; screens watch it to react (shared infra) + type: int + restore_value: no + initial_value: '0' + - id: g_boot # boot splash active (gates input until it hands off to the clock) + type: bool + restore_value: no + initial_value: 'true' + - id: g_boot_t # boot splash tick counter + type: int + restore_value: no + initial_value: '0' + - id: g_tile1_glyph + type: std::string + restore_value: no + - id: g_tile2_glyph + type: std::string + restore_value: no + - id: g_tile3_glyph + type: std::string + restore_value: no + - id: g_wgrp_labels # Widgets submenu: group names, appended by each screen package at boot + type: std::string + restore_value: no + - id: g_wopt_n # generic per-screen settings registry (filled by screen packages at boot) + type: int + restore_value: no + initial_value: '0' + - id: g_wopt_group # which Widgets group each option belongs to + type: int[16] + restore_value: no + - id: g_wopt_label # option label + type: std::string[16] + restore_value: no + - id: g_wopt_kind # 0 = toggle, 1 = action (confirm) + type: int[16] + restore_value: no + - id: g_wopt_ptr # &bool for toggles / &flag for actions + type: "std::array" + restore_value: no + - id: g_wopt_idx # selection within the current group's options + type: int + restore_value: no + initial_value: '0' + - id: g_wcur_opts # registry indices of the current group's options + type: int[16] + restore_value: no + - id: g_wcur_n + type: int + restore_value: no + initial_value: '0' + - id: g_wcur_name # current group name (header) + type: std::string + restore_value: no + - id: g_factory_reset # one-shot (persists a reboot); game packages clear their scores when set + type: bool + restore_value: yes + initial_value: 'false' + - id: g_wgrp_n + type: int + restore_value: no + initial_value: '0' + - id: g_wgrp_idx + type: int + restore_value: no + initial_value: '0' + +# Shows the volume arc for ~2 s while turning the knob, then hides it (mode: restart) +script: + # Carousel router: show the screen for g_base. Core pages directly; optional screens + # (cars/space) show themselves via g_nav_req. g_nav_anim: 0=left 1=right 2=top 3=bottom. + - id: show_base + then: + # Home base: Classic is core's page_home (directional); other faces self-show via g_wf_req. + - if: { condition: { lambda: 'return id(g_base)==0 && id(g_watchface)==0 && id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_home, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_base)==0 && id(g_watchface)==0 && id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_home, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_base)==0 && id(g_watchface)==0 && id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_home, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_base)==0 && id(g_watchface)==0 && id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_home, animation: move_bottom, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_base)==0 && id(g_watchface)!=0;' }, then: [ lambda: 'id(g_wf_req) = true;' ] } + - if: { condition: { lambda: 'return id(g_base)==1;' }, then: [ lambda: 'id(g_nav_req) = true;' ] } + - if: { condition: { lambda: 'return id(g_base)==2 && id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_timer, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_base)==2 && id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_timer, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_base)==2 && id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_timer, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_base)==2 && id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_timer, animation: move_bottom, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_base) >= 3;' }, then: [ lambda: 'id(g_nav_req) = true;' ] } + # Apply the dynamically-built settings list to the roller (called once at boot). + - id: settings_build + then: + - lambda: 'lv_roller_set_options(id(settings_roller)->obj, id(g_set_labels).c_str(), LV_ROLLER_MODE_NORMAL);' + - lambda: 'lv_roller_set_options(id(wid_roller)->obj, id(g_wgrp_labels).c_str(), LV_ROLLER_MODE_NORMAL);' + # Home watchface: make the active face the current home view. Classic (id 0) is core's + # page_home; other faces self-show their own page when g_wf_req is set. Each face owns its + # own weather/room visibility (classic via disp_apply; others gate themselves). + - id: face_apply + then: + - if: + condition: { lambda: 'return id(g_watchface) == 0;' } + then: + - lambda: 'id(g_wf_req) = false;' # Classic shows directly - clear any pending request + - lvgl.page.show: { id: page_home, animation: fade_in, time: 250ms } + - script.execute: disp_apply + else: + - lambda: 'id(g_wf_req) = true;' + # Rebuild the Home settings roller so its 3rd line shows the current watchface name. + - id: home_roller_build + then: + - lambda: |- + int wf = id(g_watchface); if (wf < 0 || wf >= 8 || !id(g_wf_present)[wf]) wf = 0; + std::string opts = std::string("Show weather\nShow climate\nWatchface: ") + id(g_wf_name)[wf]; + lv_roller_set_options(id(home_roller)->obj, opts.c_str(), LV_ROLLER_MODE_NORMAL); + # Watchface picker (mode 33): update + show the overlay hint with the previewed face name. + - id: facepick_render + then: + - lvgl.label.update: + id: pick_name + text: !lambda |- + int wf = id(g_wf_pick); if (wf < 0 || wf >= 8) wf = 0; + std::string s = id(g_wf_name)[wf]; + for (auto &c : s) { if (c >= 'a' && c <= 'z') c -= 32; } + return s; + - lvgl.widget.show: pick_bar + # Draws the battery indicator on the home screen. Charging: lightning bolt + green + + # "filling" animation (icon cycles empty→full). On battery: static level icon. + - id: batt_render + then: + - lvgl.label.update: + id: lbl_batt + text: !lambda |- + int p = id(g_batt); + if (p < 0) return std::string(""); // no reading yet (after boot) - empty instead of tofu (montserrat has no "-") + if (id(g_charging)) { // charging: lightning bolt + animation, WITHOUT % (voltage reads high) + const char* fr[5] = {"","","","",""}; + return std::string("") + fr[id(g_chg_anim) % 5]; + } + std::string ic = p > 87 ? "" : p > 62 ? "" : p > 37 ? "" : p > 12 ? "" : ""; + char bf[12]; snprintf(bf, sizeof(bf), " %d%%", p); + return ic + bf; + - if: + condition: + lambda: 'return id(g_charging);' + then: + - lvgl.label.update: { id: lbl_batt, text_color: 0x35c759 } + else: + - lvgl.label.update: { id: lbl_batt, text_color: 0x6f9a92 } + - id: flash_volume + mode: restart + then: + - lvgl.widget.show: vol_dim + - lvgl.widget.show: vol_arc + - lvgl.widget.show: vol_label + - lambda: 'id(g_vol_shown) = true;' + - delay: 2000ms + - lvgl.widget.hide: vol_dim + - lvgl.widget.hide: vol_arc + - lvgl.widget.hide: vol_label + - lambda: 'id(g_vol_shown) = false;' + # Immediately hides the volume overlay (after tapping it) - consumes the click + - id: vol_hide_now + then: + - script.stop: flash_volume + - lvgl.widget.hide: vol_dim + - lvgl.widget.hide: vol_arc + - lvgl.widget.hide: vol_label + - lambda: 'id(g_vol_shown) = false;' + # Assistant overlay (dim + frame + text) - always on top + - id: va_show + then: + - lvgl.widget.show: va_dim + - lvgl.widget.show: va_frame + - lvgl.widget.show: va_text + - id: va_hide + then: + - lvgl.widget.hide: va_dim + - lvgl.widget.hide: va_frame + - lvgl.widget.hide: va_text + # Draws the menu/timer/alarm overlay per g_mode (hidden in normal mode and during VA) + - id: ui_render + then: + # === A: animated transitions: BASE(0) / page_mode(menu,timer,alarm,sub) / page_settings(5) / page_controls(10) === + # g_cur_page: 0=base 1=page_mode 2=page_settings 3=page_controls. Animation only when the page changes. + - if: + condition: + lambda: 'int w=(id(g_mode)==0||id(g_mode)==33)?0:(id(g_mode)==5)?2:(id(g_mode)==10)?3:1; return w != id(g_cur_page);' + then: + - if: # -> BASE (also the watchface picker, mode 33): return per g_base; direction per g_exit_anim + condition: { lambda: 'return id(g_mode) == 0 || id(g_mode) == 33;' } + then: + - lambda: |- + // g_exit_anim 0=right 1=down 2=up -> g_nav_anim 1=right 3=bottom 2=top + id(g_nav_anim) = (id(g_exit_anim)==1) ? 3 : (id(g_exit_anim)==2) ? 2 : 1; + id(g_exit_anim) = 0; + - script.execute: show_base + - if: # -> SETTINGS (list): from base "from top" (swipe ↓), from a sub-screen "back" (to the right) + condition: { lambda: 'return id(g_mode) == 5;' } + then: + - if: + condition: { lambda: 'return id(g_cur_page) == 0;' } + then: [lvgl.page.show: { id: page_settings, animation: move_bottom, time: 250ms }] + else: [lvgl.page.show: { id: page_settings, animation: move_right, time: 250ms }] + - if: # -> CONTROLS (tiles, swipe ↑ from base = "from bottom") + condition: { lambda: 'return id(g_mode) == 10;' } + then: [lvgl.page.show: { id: page_controls, animation: move_top, time: 250ms }] + - if: # -> MODE: from settings "deeper" (to the left), menu from base "from bottom", rest fade + condition: { lambda: 'return id(g_mode) != 0 && id(g_mode) != 5 && id(g_mode) != 10 && id(g_mode) != 33;' } + then: + - if: + condition: { lambda: 'return id(g_cur_page) == 2;' } + then: [lvgl.page.show: { id: page_mode, animation: move_left, time: 250ms }] + else: + - if: + condition: { lambda: 'return id(g_mode) == 1;' } + then: [lvgl.page.show: { id: page_mode, animation: move_top, time: 250ms }] + else: [lvgl.page.show: { id: page_mode, animation: fade_in, time: 250ms }] + - lambda: 'id(g_cur_page) = (id(g_mode)==0||id(g_mode)==33)?0:(id(g_mode)==5)?2:(id(g_mode)==10)?3:1;' + - if: + condition: + lambda: 'return id(g_mode) == 0 || id(g_mode) == 33 || id(g_va_active);' + then: + - lvgl.widget.hide: ui_dim + - lvgl.widget.hide: ui_head_bg + - lvgl.widget.hide: ui_head + - lvgl.widget.hide: ui_big + - lvgl.widget.hide: ui_sub + - lvgl.widget.hide: ui_bar + - lvgl.widget.hide: disp_roller + - lvgl.widget.hide: disp_lbl + - lvgl.widget.hide: disp_switch + - lvgl.widget.hide: asys_roller + - lvgl.widget.hide: sys_roller + - lvgl.widget.hide: home_roller + - lvgl.widget.hide: wid_roller + - lvgl.widget.hide: wopt_roller + else: + - lvgl.widget.show: ui_dim + - lvgl.widget.show: ui_head_bg + - lvgl.widget.show: ui_head + - if: # subtext ONLY on Info (IP/version) - the rest without instructions + condition: { lambda: 'return id(g_mode) == 7 || id(g_mode) == 23 || id(g_mode) == 31 || id(g_mode) == 9 || id(g_mode) == 22;' } + then: [lvgl.widget.show: ui_sub] + else: [lvgl.widget.hide: ui_sub] + - if: # settings-list roller (5) - lives on page_settings (always visible there); here only the selection + condition: { lambda: 'return id(g_mode) == 5;' } + then: + - lvgl.roller.update: + id: settings_roller + animated: true + selected_index: !lambda 'return id(g_set_idx);' + - if: # bar only for brightness (6) + condition: { lambda: 'return id(g_mode) == 6;' } + then: + - lvgl.widget.show: ui_bar + - lvgl.bar.update: + id: ui_bar + value: !lambda 'return id(g_bright);' + else: + - lvgl.widget.hide: ui_bar + - if: # Display submenu (11): roller + (% for Brightness / switch for toggles) + condition: { lambda: 'return id(g_mode) == 11;' } + then: + - lvgl.widget.show: disp_roller + - lvgl.roller.update: + id: disp_roller + animated: true + selected_index: !lambda 'return id(g_disp_idx);' + - if: # Brightness (0) -> show %, hide switch + condition: { lambda: 'return id(g_disp_idx) == 0;' } + then: + - lvgl.widget.show: disp_lbl + - lvgl.widget.hide: disp_switch + - lvgl.label.update: + id: disp_lbl + text: !lambda 'char b[10]; snprintf(b, sizeof(b), "%d%%", id(g_bright)); return std::string(b);' + else: + - lvgl.widget.hide: disp_lbl + - if: + condition: { lambda: 'return id(g_disp_idx) == 1;' } + then: + - lvgl.widget.show: disp_switch + - lvgl.widget.update: { id: disp_switch, state: { checked: !lambda 'return id(g_night);' } } + else: + - lvgl.widget.hide: disp_switch + else: + - lvgl.widget.hide: disp_roller + - lvgl.widget.hide: disp_lbl + - lvgl.widget.hide: disp_switch + - if: # Home (12): home-screen widget toggles (weather / climate) + condition: { lambda: 'return id(g_mode) == 12;' } + then: + - lvgl.widget.show: home_roller + - lvgl.roller.update: { id: home_roller, animated: true, selected_index: !lambda 'return id(g_sub_idx);' } + - if: # items 0/1 are toggles; item 2 (Watchface) is a tap-to-cycle, no switch + condition: { lambda: 'return id(g_sub_idx) <= 1;' } + then: + - lvgl.widget.show: disp_switch + - lvgl.widget.update: { id: disp_switch, state: { checked: !lambda 'return id(g_sub_idx) == 0 ? id(g_show_weather) : id(g_show_climate);' } } + else: [ lvgl.widget.hide: disp_switch ] + else: [ lvgl.widget.hide: home_roller ] + - if: # Widgets (17): list of per-screen groups (Demo / Games / Weather) + condition: { lambda: 'return id(g_mode) == 17;' } + then: + - lvgl.widget.show: wid_roller + - lvgl.roller.update: { id: wid_roller, animated: true, selected_index: !lambda 'return id(g_wgrp_idx);' } + else: [ lvgl.widget.hide: wid_roller ] + - if: # Widget options (30): generic per-screen options for the selected group + condition: { lambda: 'return id(g_mode) == 30;' } + then: + - lvgl.widget.show: wopt_roller + - lvgl.roller.update: { id: wopt_roller, animated: true, selected_index: !lambda 'return id(g_wopt_idx);' } + - if: + condition: { lambda: 'return id(g_wcur_n) > 0 && id(g_wopt_kind)[id(g_wcur_opts)[id(g_wopt_idx)]] == 0;' } + then: + - lvgl.widget.show: disp_switch + - lvgl.widget.update: { id: disp_switch, state: { checked: !lambda 'int o = id(g_wcur_opts)[id(g_wopt_idx)]; return id(g_wopt_ptr)[o] && *id(g_wopt_ptr)[o];' } } + else: [ lvgl.widget.hide: disp_switch ] + else: [ lvgl.widget.hide: wopt_roller ] + - if: # Assistant (13): both items = toggles (0=wake word, 1=beep) + condition: { lambda: 'return id(g_mode) == 13;' } + then: + - lvgl.widget.show: asys_roller + - lvgl.roller.update: { id: asys_roller, animated: true, selected_index: !lambda 'return id(g_sub_idx);' } + - lvgl.widget.show: disp_switch + - lvgl.widget.update: { id: disp_switch, state: { checked: !lambda 'return id(g_sub_idx) == 0 ? id(g_ww_on) : id(g_sub_idx) == 1 ? id(g_beep_ww) : id(g_hold_talk);' } } + else: [ lvgl.widget.hide: asys_roller ] + - if: + condition: { lambda: 'return id(g_mode) == 15;' } + then: [ lvgl.widget.show: sys_roller, lvgl.roller.update: { id: sys_roller, animated: true, selected_index: !lambda 'return id(g_sub_idx);' } ] + else: [ lvgl.widget.hide: sys_roller ] + - if: # we hide ui_big in the list (5), info (7), wake word (8), Display (11) + condition: { lambda: 'return id(g_mode) == 5 || id(g_mode) == 7 || id(g_mode) == 9 || id(g_mode) == 11 || id(g_mode) == 12 || id(g_mode) == 13 || id(g_mode) == 15 || id(g_mode) == 17 || id(g_mode) == 22 || id(g_mode) == 30 || id(g_mode) == 31;' } + then: [lvgl.widget.hide: ui_big] + else: [lvgl.widget.show: ui_big] + - lvgl.label.update: + id: ui_big + text: !lambda |- + char b[16]; + int m = id(g_mode); + if (m == 1) return std::string("Timer"); + if (m == 2) { snprintf(b, sizeof(b), "%d min", id(g_set_min)); return std::string(b); } + if (m == 6) { snprintf(b, sizeof(b), "%d%%", id(g_bright)); return std::string(b); } + if (m == 9) return std::string("Restart?"); + if (m == 23) { if (id(g_batt) < 0) return std::string("--"); char b[8]; snprintf(b, sizeof(b), "%d%%", id(g_batt)); return std::string(b); } + if (m == 24) { const char* L[5]={"Never","15s","30s","1min","5min"}; return std::string(L[id(g_blank_idx) % 5]); } + if (m == 25) { char b[8]; snprintf(b, sizeof(b), "%02d:00", id(g_night_from)); return std::string(b); } + if (m == 26) { char b[8]; snprintf(b, sizeof(b), "%02d:00", id(g_night_to)); return std::string(b); } + int s = id(g_timer_secs); if (s < 0) s = 0; + snprintf(b, sizeof(b), "%d:%02d", s / 60, s % 60); + return std::string(b); + - lvgl.label.update: + id: ui_sub + text: !lambda |- + if (id(g_mode) == 23) { char b[24]; snprintf(b, sizeof(b), "%.2f V", id(batt_volt).state); return std::string(b); } + if (id(g_mode) == 31) { std::string l = (id(g_wcur_n) > 0) ? id(g_wopt_label)[id(g_wcur_opts)[id(g_wopt_idx)]] : std::string(""); return l + "?\ntap = yes"; } + if (id(g_mode) == 9) return std::string("Restart?\ntap = yes"); + if (id(g_mode) == 22) return std::string("Factory reset?\ntap = yes"); + if (id(g_mode) != 7) return std::string(""); + std::string ip = id(wifi_ip).state.empty() ? std::string("-") : id(wifi_ip).state; + return std::string("IP ") + ip + "\nESPHome " ESPHOME_VERSION; + - lvgl.label.update: + id: ui_head + text: !lambda |- + int m = id(g_mode); + if (m == 1) return std::string("MENU"); + if (m == 2 || m == 3) return std::string("TIMER"); + if (m == 4) return std::string("ALARM"); + if (m == 5) return std::string("SETTINGS"); + if (m == 6) return std::string("BRIGHTNESS"); + if (m == 7) return std::string("INFO"); + if (m == 9) return std::string("RESTART"); + if (m == 11) return std::string("DISPLAY"); + if (m == 12) return std::string("HOME SCREEN"); + if (m == 13) return std::string("VOICE ASSISTANT"); + if (m == 15) return std::string("SYSTEM"); + if (m == 17) return std::string("WIDGETS"); + if (m == 30) return id(g_wcur_name); + if (m == 31) return std::string("CONFIRM"); + if (m == 23) return std::string("BATTERY"); + if (m == 24) return std::string("SCREEN OFF"); + if (m == 25) return std::string("NIGHT FROM"); + if (m == 26) return std::string("NIGHT TO"); + if (m == 22) return std::string("FACTORY"); + return std::string(""); + - if: + condition: { lambda: 'return id(g_mode) >= 5;' } + then: [lvgl.widget.update: { id: ui_head_bg, bg_color: 0xF5B83D }] + - if: + condition: { lambda: 'return id(g_mode) == 4;' } + then: [lvgl.widget.update: { id: ui_head_bg, bg_color: 0xFF5555 }] + - if: + condition: { lambda: 'return id(g_mode) >= 1 && id(g_mode) <= 3;' } + then: [lvgl.widget.update: { id: ui_head_bg, bg_color: 0x42F4DC }] + # Alarm screen (mode 4): show the dedicated bell layout, hide the generic overlay + - if: + condition: { lambda: 'return id(g_mode) == 4;' } + then: + - lvgl.widget.show: alarm_scrim + - lvgl.widget.show: alarm_bell + - lvgl.widget.show: alarm_title + - lvgl.widget.show: alarm_sub + - lvgl.widget.hide: ui_dim + - lvgl.widget.hide: ui_head_bg + - lvgl.widget.hide: ui_head + - lvgl.widget.hide: ui_big + - lvgl.widget.hide: ui_sub + else: + - lvgl.widget.hide: alarm_scrim + - lvgl.widget.hide: alarm_bell + - lvgl.widget.hide: alarm_title + - lvgl.widget.hide: alarm_sub + - id: timer_start_local + then: + - lambda: |- + id(g_timer_secs) = id(g_set_min) * 60; + id(g_timer_local) = true; + id(g_mode) = 3; + - script.execute: ui_render + - id: timer_cancel + then: + - lambda: 'id(g_mode) = 0; id(g_timer_local) = false; id(g_timer_secs) = 0;' + - script.execute: ui_render + - id: alarm_start + then: + - lambda: 'id(g_mode) = 4; id(g_alarm_cnt) = 0; id(g_timer_active) = false; id(g_timer_paused) = false; id(g_timer_secs) = 0;' + - script.execute: ui_render + - script.execute: timer_render + - media_player.speaker.play_on_device_media_file: + id: media_player_speaker + media_file: alarm_sound + announcement: true + - id: alarm_stop + then: + - lambda: 'id(g_mode) = 0; id(g_timer_active) = false; id(g_timer_paused) = false; id(g_timer_secs) = 0;' + - script.execute: ui_render + - script.execute: timer_render + # --- Timer as a SCREEN (page_timer): render + button controls --- + - id: timer_render + then: + - lvgl.label.update: + id: timer_big + text: !lambda |- + int s = id(g_timer_active) ? id(g_timer_secs) : id(g_set_min) * 60; + if (s < 0) s = 0; + char b[12]; snprintf(b, sizeof(b), "%d:%02d", s / 60, s % 60); + return std::string(b); + - lvgl.arc.update: + id: timer_arc + value: !lambda |- + if (!id(g_timer_active)) return 100.0f; + int tot = id(g_timer_total); if (tot <= 0) return 0.0f; + float f = 100.0f * id(g_timer_secs) / tot; + if (f < 0) f = 0; if (f > 100) f = 100; + return f; + - if: + condition: { lambda: 'return id(g_timer_active);' } + then: + - lvgl.widget.hide: timer_play + - lvgl.widget.show: timer_pause + - lvgl.widget.show: timer_stop + - lvgl.widget.show: timer_dot + - lvgl.label.update: + id: timer_pause_ic + text: !lambda 'return id(g_timer_paused) ? std::string("") : std::string("");' + else: + - lvgl.widget.show: timer_play + - lvgl.widget.hide: timer_pause + - lvgl.widget.hide: timer_stop + - lvgl.widget.hide: timer_dot + - if: # ride the head-dot on the leading edge of the remaining arc + condition: { lambda: 'return id(g_timer_active);' } + then: + - lambda: |- + int tot = id(g_timer_total); float v = (tot > 0) ? 100.0f * id(g_timer_secs) / tot : 0.0f; + if (v < 0) v = 0; if (v > 100) v = 100; + float a = (271.0f + v / 100.0f * 358.0f) * 0.01745329f; // arc head angle -> radians + int x = 180 + (int) lroundf(143.0f * cosf(a)) - 9; + int y = 180 + (int) lroundf(143.0f * sinf(a)) - 9; + lv_obj_set_pos(id(timer_dot), x, y); + # status caption + arc/time colour by remaining: SET grey, then green > amber > red + - lvgl.label.update: + id: timer_cap + text: !lambda |- + if (!id(g_timer_active)) return std::string("SET"); + return id(g_timer_paused) ? std::string("PAUSED") : std::string("RUNNING"); + - if: + condition: { lambda: 'return !id(g_timer_active);' } + then: + - lvgl.arc.update: { id: timer_arc, indicator: { arc_color: 0x2FB85C } } + - lvgl.label.update: { id: timer_cap, text_color: 0x6F9A92 } + - lvgl.label.update: { id: timer_big, text_color: 0xFFFFFF } + else: + - if: + condition: { lambda: 'int t=id(g_timer_total); return t>0 && (long)id(g_timer_secs)*100 > (long)t*50;' } + then: + - lvgl.arc.update: { id: timer_arc, indicator: { arc_color: 0x2FB85C } } + - lvgl.label.update: { id: timer_cap, text_color: 0x2FB85C } + - lvgl.label.update: { id: timer_big, text_color: 0xFFFFFF } + else: + - if: + condition: { lambda: 'int t=id(g_timer_total); return t>0 && (long)id(g_timer_secs)*100 > (long)t*20;' } + then: + - lvgl.arc.update: { id: timer_arc, indicator: { arc_color: 0xF5B83D } } + - lvgl.label.update: { id: timer_cap, text_color: 0xF5B83D } + - lvgl.label.update: { id: timer_big, text_color: 0xFFFFFF } + else: + - lvgl.arc.update: { id: timer_arc, indicator: { arc_color: 0xFF3B30 } } + - lvgl.label.update: { id: timer_cap, text_color: 0xFF6B60 } + - lvgl.label.update: { id: timer_big, text_color: 0xFF6B60 } + - id: timer_start + then: + - lambda: |- + id(g_timer_secs) = id(g_set_min) * 60; + id(g_timer_total) = id(g_timer_secs); + id(g_timer_active) = true; + id(g_timer_paused) = false; + id(g_timer_local) = true; // timer from the button -> the interval counts down and fires the alarm + - script.execute: timer_render + - id: timer_toggle + then: + - lambda: 'id(g_timer_paused) = !id(g_timer_paused);' + - script.execute: timer_render + - id: timer_halt + then: + - lambda: 'id(g_timer_active) = false; id(g_timer_paused) = false; id(g_timer_secs) = 0;' + - script.execute: timer_render + # Shows/hides home-screen elements per Display settings + - id: disp_apply # gate the Classic face's weather/climate widgets per the Display settings + then: + - if: + condition: { lambda: 'return id(g_show_weather);' } + then: [ lvgl.widget.show: lbl_wx_ic, lvgl.widget.show: lbl_wx_val ] + else: [ lvgl.widget.hide: lbl_wx_ic, lvgl.widget.hide: lbl_wx_val ] + - if: + condition: { lambda: 'return id(g_show_climate);' } + then: [ lvgl.widget.show: lbl_ct_ic, lvgl.widget.show: lbl_ct_val, lvgl.widget.show: lbl_ch_ic, lvgl.widget.show: lbl_ch_val ] + else: [ lvgl.widget.hide: lbl_ct_ic, lvgl.widget.hide: lbl_ct_val, lvgl.widget.hide: lbl_ch_ic, lvgl.widget.hide: lbl_ch_val ] + # Night mode: ON -> dim screen to 15%, OFF -> restore brightness from settings + - id: night_apply + then: + - light.turn_on: + id: backlight_light + brightness: !lambda 'return id(g_bright) / 100.0f;' +# Volume (0..1) from the entity -> vol_arc value + % label +sensor: + - platform: homeassistant + id: vol_sensor + entity_id: ${player_entity} + attribute: volume_level + on_value: + - lambda: 'id(g_vol) = x;' + - lvgl.arc.update: + id: vol_arc + value: !lambda 'return x * 100.0;' + - lvgl.label.update: + id: vol_label + text: !lambda |- + char buf[8]; snprintf(buf, sizeof(buf), "%d%%", (int) roundf(x * 100.0f)); return std::string(buf); + # --- Home screen: weather (outdoor temp) + room temperature/humidity --- + - platform: homeassistant + id: wx_temp + entity_id: ${weather_entity} + attribute: temperature + on_value: + - lvgl.label.update: + id: lbl_wx_val + text: !lambda 'char b[10]; snprintf(b, sizeof(b), "%d°", (int) roundf(x)); return std::string(b);' + - platform: homeassistant + id: room_temp + entity_id: ${room_temp_entity} + on_value: + - lvgl.label.update: + id: lbl_ct_val + text: !lambda 'char b[12]; snprintf(b, sizeof(b), "%.1f°", x); return std::string(b);' + - platform: homeassistant + id: room_hum + entity_id: ${room_hum_entity} + on_value: + - lvgl.label.update: + id: lbl_ch_val + text: !lambda 'char b[8]; snprintf(b, sizeof(b), "%d%%", (int) roundf(x)); return std::string(b);' + # Battery: ADC on GPIO6 (BAT_ADC, ~10k/10k divider -> x2). PIN/RATIO to verify live. + - platform: adc + pin: GPIO1 + id: batt_volt + name: "Battery voltage" + attenuation: 12db + update_interval: 1s + samples: 64 # hardware oversampling: 64 readings/measurement averaged + # -> cuts ripple from the ETA6003 converter (no capacitor on BAT_ADC) + filters: + - multiply: 2.0 + # Reject values physically impossible for Li-ion (that's switching ripple, not voltage): + - lambda: 'return (x >= 3.20f && x <= 4.30f) ? x : NAN;' + # On the cleaned signal: moving average, output every ~10 s + - sliding_window_moving_average: + window_size: 20 + send_every: 10 + send_first_at: 5 + on_value: + - lambda: |- + // x = VCC_OUT (median, after ×2) = system rail ≈ battery voltage UNDER load. + float v = x; + // Voltage→% curve CALIBRATED on a real discharge (under load): + // start ~4.10 (≈90%), nearly linear drop, brownout ~3.20 V. Range 3.20-4.15. + static const float vt[] = {3.20f,3.25f,3.39f,3.57f,3.74f,3.84f,4.10f}; + static const float st[] = {0.f, 4.f, 20.f, 40.f, 60.f, 80.f, 100.f}; + const int n = 7; + float pct; + if (v <= vt[0]) pct = 0.f; + else if (v >= vt[n-1]) pct = 100.f; + else { pct = 0.f; for (int i = 0; i < n-1; i++) { if (v >= vt[i] && v < vt[i+1]) { pct = st[i] + (v - vt[i]) / (vt[i+1] - vt[i]) * (st[i+1] - st[i]); break; } } } + int p = (int) roundf(pct); + if (p < 0) p = 0; if (p > 100) p = 100; + id(g_batt) = p; + // --- Charging detection (no STAT pin on GPIO -> voltage heuristic) --- + // The puck constantly draws current (backlight+ESP+audio); only USB keeps the voltage high. + float prev = id(g_v_med); + id(g_v_med) = v; + bool rising = (prev > 0.1f) && (v - prev > 0.010f); // voltage rising = charging + bool high = (v >= 4.13f); // on battery under load max ~4.10 -> only USB holds ≥4.13 + bool low = (v < 4.05f); // clearly on battery + if (high || rising) id(g_charging) = true; + else if (low) id(g_charging) = false; + // 4.05-4.13 zone without a rise: keep the previous state (hysteresis, no flicker) + - script.execute: batt_render + +# Push-to-talk: screen TAP (handled in touchscreen: on_release). Swipe = next/prev. +# Eventually the wake word (micro_wake_word) will be better. +binary_sensor: + # ── KNOB: this "encoder" is NOT classic quadrature. Confirmed by logs: + # direction is encoded by WHICH pin pulses (not A/B phase): + # • LEFT → GPIO7 pulses → quieter + # • RIGHT → GPIO8 pulses → louder + # Each detent = one clean LOW pulse (sequence 0101...), zero bounce. + # That's why rotary_encoder (expects interleaved A/B) counted zero. + # So we read two independent pulses: inverted+pullup → detent = "on_press". + - platform: gpio + name: "Knob left" + id: knob_left + pin: + number: GPIO7 + inverted: true + mode: + input: true + pullup: true + on_press: + - if: + condition: { lambda: 'return id(g_disp_off);' } # asleep -> just wake, don't act (same as touch) + then: + - light.turn_on: { id: backlight_light, brightness: !lambda 'return id(g_bright) / 100.0f;' } + - lambda: 'id(g_disp_off) = false; id(g_idle_ticks) = 0;' + else: + - if: + condition: + lambda: 'return id(g_mode) == 0 && (id(g_knob_capture) || (id(g_base) == 2 && !id(g_timer_active)));' + then: + - if: + condition: { lambda: 'return id(g_knob_capture);' } + then: [ lambda: 'id(g_knob_delta)--;' ] + else: + - lambda: 'id(g_set_min)--; if (id(g_set_min) < 1) id(g_set_min) = 1;' + - script.execute: timer_render + else: + - if: + condition: + lambda: 'return id(g_mode) == 5 || id(g_mode) == 11 || (id(g_mode) >= 12 && id(g_mode) <= 17) || id(g_mode) == 30;' + then: + - lambda: |- + if (id(g_mode) == 11) { if (id(g_disp_idx) > 0) id(g_disp_idx)--; } + else if (id(g_mode) == 5) { if (id(g_set_idx) > 0) id(g_set_idx)--; } + else if (id(g_mode) == 17) { if (id(g_wgrp_idx) > 0) id(g_wgrp_idx)--; } + else if (id(g_mode) == 30) { if (id(g_wopt_idx) > 0) id(g_wopt_idx)--; } + else { if (id(g_sub_idx) > 0) id(g_sub_idx)--; } + - script.execute: ui_render + else: + - if: + condition: + lambda: 'return id(g_mode) == 6;' + then: + - lambda: 'id(g_bright) -= 10; if (id(g_bright) < 10) id(g_bright) = 10;' + - light.turn_on: + id: backlight_light + brightness: !lambda 'return id(g_bright) / 100.0f;' + - script.execute: ui_render + else: + - if: + condition: { lambda: 'return id(g_mode) == 24 || id(g_mode) == 25 || id(g_mode) == 26;' } + then: + - lambda: |- + if (id(g_mode) == 24) { if (id(g_blank_idx) > 0) id(g_blank_idx)--; } + else if (id(g_mode) == 25) { id(g_night_from)--; if (id(g_night_from) < 0) id(g_night_from) = 23; } + else { id(g_night_to)--; if (id(g_night_to) < 0) id(g_night_to) = 23; } + - script.execute: ui_render + else: + - if: # watchface picker: previous present face + condition: { lambda: 'return id(g_mode) == 33;' } + then: + - lambda: 'int n=id(g_wf_pick); for(int k=0;k<8;k++){ n=(n+7)%8; if(id(g_wf_present)[n]) break; } id(g_wf_pick)=n; id(g_watchface)=n;' + - script.execute: face_apply + - script.execute: facepick_render + else: + - media_player.volume_down: + id: media_player_speaker + - script.execute: flash_volume + - platform: gpio + name: "Knob right" + id: knob_right + pin: + number: GPIO8 + inverted: true + mode: + input: true + pullup: true + on_press: + - if: + condition: { lambda: 'return id(g_disp_off);' } # asleep -> just wake, don't act (same as touch) + then: + - light.turn_on: { id: backlight_light, brightness: !lambda 'return id(g_bright) / 100.0f;' } + - lambda: 'id(g_disp_off) = false; id(g_idle_ticks) = 0;' + else: + - if: + condition: + lambda: 'return id(g_mode) == 0 && (id(g_knob_capture) || (id(g_base) == 2 && !id(g_timer_active)));' + then: + - if: + condition: { lambda: 'return id(g_knob_capture);' } + then: [ lambda: 'id(g_knob_delta)++;' ] + else: + - lambda: 'id(g_set_min)++; if (id(g_set_min) > 180) id(g_set_min) = 180;' + - script.execute: timer_render + else: + - if: + condition: + lambda: 'return id(g_mode) == 5 || id(g_mode) == 11 || (id(g_mode) >= 12 && id(g_mode) <= 17) || id(g_mode) == 30;' + then: + - lambda: |- + if (id(g_mode) == 11) { if (id(g_disp_idx) < 4) id(g_disp_idx)++; } + else if (id(g_mode) == 5) { if (id(g_set_idx) < id(g_set_n) - 1) id(g_set_idx)++; } + else if (id(g_mode) == 17) { if (id(g_wgrp_idx) < id(g_wgrp_n) - 1) id(g_wgrp_idx)++; } + else if (id(g_mode) == 30) { if (id(g_wopt_idx) < id(g_wcur_n) - 1) id(g_wopt_idx)++; } + else { int mx = (id(g_mode) == 15) ? 3 : (id(g_mode) == 13) ? 2 : (id(g_mode) == 12) ? 2 : 0; if (id(g_sub_idx) < mx) id(g_sub_idx)++; } + - script.execute: ui_render + else: + - if: + condition: + lambda: 'return id(g_mode) == 6;' + then: + - lambda: 'id(g_bright) += 10; if (id(g_bright) > 100) id(g_bright) = 100;' + - light.turn_on: + id: backlight_light + brightness: !lambda 'return id(g_bright) / 100.0f;' + - script.execute: ui_render + else: + - if: + condition: { lambda: 'return id(g_mode) == 24 || id(g_mode) == 25 || id(g_mode) == 26;' } + then: + - lambda: |- + if (id(g_mode) == 24) { if (id(g_blank_idx) < 4) id(g_blank_idx)++; } + else if (id(g_mode) == 25) { id(g_night_from)++; if (id(g_night_from) > 23) id(g_night_from) = 0; } + else { id(g_night_to)++; if (id(g_night_to) > 23) id(g_night_to) = 0; } + - script.execute: ui_render + else: + - if: # watchface picker: next present face + condition: { lambda: 'return id(g_mode) == 33;' } + then: + - lambda: 'int n=id(g_wf_pick); for(int k=0;k<8;k++){ n=(n+1)%8; if(id(g_wf_present)[n]) break; } id(g_wf_pick)=n; id(g_watchface)=n;' + - script.execute: face_apply + - script.execute: facepick_render + else: + - media_player.volume_up: + id: media_player_speaker + - script.execute: flash_volume + +############################################################################### +# DISPLAY (QSPI ST77916) + BACKLIGHT + TOUCH (CST816) +# Pins: SCK=11 CS=12 D0=13 D1=14 D2=15 D3=16 RST=17 BLK=21 +# TOUCH SDA=9 SCL=10 INT=7 RST=8 +############################################################################### +spi: + id: display_qspi + type: quad + clk_pin: 13 + data_pins: [15, 16, 17, 18] # TFT_SDA0..3 + +i2c: + sda: 11 # TOUCH_PIN_NUM_I2C_SDA + scl: 12 # TOUCH_PIN_NUM_I2C_SCL + scan: true + id: touchscreen_bus + +output: + - platform: ledc + pin: GPIO47 # your backlight GPIO + id: backlight_output + +light: + - platform: monochromatic + output: backlight_output + name: "Backlight" + id: backlight_light + restore_mode: ALWAYS_ON + +button: + - platform: restart + id: btn_restart + name: "Restart" + +touchscreen: + platform: cst816 + id: my_touchscreen + interrupt_pin: GPIO9 # TOUCH_PIN_NUM_INT + reset_pin: GPIO10 # TOUCH_PIN_NUM_RST + display: main_display + on_touch: + - lambda: |- + id(g_x0) = (int) touch.x; + id(g_y0) = (int) touch.y; + id(g_t0) = millis(); + id(g_dx) = 0; + id(g_dy) = 0; + id(g_idle_ticks) = 0; + - if: + condition: { lambda: 'return id(g_disp_off);' } + then: + - light.turn_on: { id: backlight_light, brightness: !lambda 'return id(g_bright) / 100.0f;' } + - lambda: 'id(g_disp_off) = false; id(g_wake_consume) = true;' + on_update: + - lambda: |- + if (!touches.empty()) { + id(g_dx) = (int) touches[0].x - id(g_x0); + id(g_dy) = (int) touches[0].y - id(g_y0); + } + on_release: + # Gesture classification + mode snapshot (routing by g_mode0, so one gesture doesn't cascade) + - lambda: |- + if (id(g_boot)) { id(g_gesture) = -1; id(g_mode0) = 0; return; } // ignore input during the boot splash + int dx = id(g_dx), dy = id(g_dy); + uint32_t held = millis() - id(g_t0); + int g; + if (abs(dx) > 50 && abs(dx) > abs(dy)) g = (dx < 0) ? 2 : 3; // 2=left 3=right + else if (abs(dy) > 50 && abs(dy) > abs(dx)) g = (dy < 0) ? 4 : 5; // 4=up 5=down + else if (abs(dx) < 16 && abs(dy) < 16) g = (held >= 500) ? 1 : 0; // 1=hold 0=tap + else g = -1; + if (id(g_wake_consume)) { g = -1; id(g_wake_consume) = false; } + id(g_idle_ticks) = 0; + id(g_gesture) = g; + id(g_mode0) = id(g_mode); + # === Volume overlay on top: ANY gesture (tap/swipe/hold) hides it and is CONSUMED === + - if: + condition: + lambda: 'return id(g_vol_shown) && id(g_gesture) >= 0;' + then: + - script.execute: vol_hide_now + - lambda: 'id(g_gesture) = -1;' # neutralize the gesture -> no mode will handle it + # === NORMAL MODE === + - if: + condition: + lambda: 'return id(g_mode0) == 0;' + then: + - if: # tap -> bump a generic signal so the active screen can react (e.g. demo) + condition: { lambda: 'return id(g_gesture) == 0;' } + then: [ lambda: 'id(g_tap_seq)++;' ] + - if: # swipe up -> controls (light tiles) + condition: + lambda: 'return id(g_gesture) == 4;' + then: + - lambda: 'id(g_mode) = 10;' + - script.execute: ui_render + - if: # swipe down -> settings (list) + condition: + lambda: 'return id(g_gesture) == 5;' + then: + - lambda: 'id(g_mode) = 5; id(g_set_idx) = 0;' + - script.execute: ui_render + # Horizontal carousel: swipe steps through g_order (built from screen_order at boot), + # wrapping around. Each screen shows itself via show_base / its nav handler. + - if: # swipe ← -> next + condition: { lambda: 'return id(g_gesture) == 2;' } + then: + - lambda: |- + int i = 0; for (; i < id(g_order_n); i++) if (id(g_order)[i] == id(g_base)) break; + if (i >= id(g_order_n)) i = 0; + id(g_base) = id(g_order)[(i + 1) % id(g_order_n)]; + id(g_nav_anim) = 0; + id(g_knob_capture) = false; id(g_knob_delta) = 0; + - script.execute: show_base + - if: # swipe → -> previous + condition: { lambda: 'return id(g_gesture) == 3;' } + then: + - lambda: |- + int i = 0; for (; i < id(g_order_n); i++) if (id(g_order)[i] == id(g_base)) break; + if (i >= id(g_order_n)) i = 0; + id(g_base) = id(g_order)[(i - 1 + id(g_order_n)) % id(g_order_n)]; + id(g_nav_anim) = 1; + id(g_knob_capture) = false; id(g_knob_delta) = 0; + - script.execute: show_base + # game: START/Play buttons on the game screen handle start/restart (not a global tap) + # prev/play/next are handled by the buttons on the player screen (transport) + - if: # hold -> start the assistant (works on every screen; toggle in Settings > Assistant) + condition: + lambda: 'return id(g_gesture) == 1 && id(g_hold_talk);' + then: + - if: + condition: voice_assistant.is_running + then: [voice_assistant.stop: {}] + else: [voice_assistant.start: {}] + # === MENU === + - if: + condition: + lambda: 'return id(g_mode0) == 1;' + then: + - if: # tap -> open timer (the only item) + condition: + lambda: 'return id(g_gesture) == 0;' + then: + - lambda: 'id(g_mode) = 2; id(g_set_min) = 5;' + - script.execute: ui_render + - if: # hold / swipe down / swipe right -> close menu (back) + condition: + lambda: 'return id(g_gesture) == 1 || id(g_gesture) == 5 || id(g_gesture) == 3;' + then: + - lambda: 'id(g_mode) = 0;' + - script.execute: ui_render + # === TIMER_SET === + - if: + condition: + lambda: 'return id(g_mode0) == 2;' + then: + - if: # tap -> start + condition: + lambda: 'return id(g_gesture) == 0;' + then: + - script.execute: timer_start_local + - if: # hold / swipe right -> cancel (back) + condition: + lambda: 'return id(g_gesture) == 1 || id(g_gesture) == 3;' + then: + - script.execute: timer_cancel + # === TIMER_RUN === + - if: + condition: + lambda: 'return id(g_mode0) == 3;' + then: + - if: + condition: + lambda: 'return id(g_gesture) == 0 || id(g_gesture) == 1 || id(g_gesture) == 3;' + then: + - script.execute: timer_cancel + # === ALARM === + - if: + condition: + lambda: 'return id(g_mode0) == 4;' + then: + - if: + condition: + lambda: 'return id(g_gesture) == 0 || id(g_gesture) == 1 || id(g_gesture) == 3;' + then: + - script.execute: alarm_stop + # === CONTROLS (tiles) === tap=tile buttons; swipe ↓/→/hold = back + - if: + condition: + lambda: 'return id(g_mode0) == 10;' + then: + - if: # swipe ↓ -> close with a "downward slide" + condition: { lambda: 'return id(g_gesture) == 5;' } + then: + - lambda: 'id(g_exit_anim) = 1; id(g_mode) = 0;' + - script.execute: ui_render + - if: # back (right / hold) -> close "to the right" + condition: { lambda: 'return id(g_gesture) == 1 || id(g_gesture) == 3;' } + then: + - lambda: 'id(g_exit_anim) = 0; id(g_mode) = 0;' + - script.execute: ui_render + # === SETTINGS (list) === knob=scroll; tap=enter; right/hold=back + - if: + condition: + lambda: 'return id(g_mode0) == 5;' + then: + - if: # tap -> enter the selected item + condition: + lambda: 'return id(g_gesture) == 0;' + then: + - lambda: 'id(g_mode) = id(g_set_items)[id(g_set_idx)]; id(g_disp_idx) = 0; id(g_sub_idx) = 0; id(g_wgrp_idx) = 0;' + - script.execute: ui_render + - if: # back: swipe right / hold -> "to the right" + condition: + lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' + then: + - lambda: 'id(g_exit_anim) = 0; id(g_mode) = 0;' + - script.execute: ui_render + - if: # swipe ↑ -> close "upward" (reverse of settings entering from the bottom) + condition: + lambda: 'return id(g_gesture) == 4;' + then: + - lambda: 'id(g_exit_anim) = 2; id(g_mode) = 0;' + - script.execute: ui_render + # === DISPLAY (11) === knob=scroll; tap=action; right/hold=back to settings + - if: + condition: + lambda: 'return id(g_mode0) == 11;' + then: + - if: # tap -> action per the selected item + condition: { lambda: 'return id(g_gesture) == 0;' } + then: + - if: { condition: { lambda: 'return id(g_disp_idx) == 0;' }, then: [ lambda: 'id(g_mode) = 6;', script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_disp_idx) == 1;' }, then: [ lambda: 'id(g_night) = !id(g_night);', script.execute: night_apply, script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_disp_idx) == 2;' }, then: [ lambda: 'id(g_mode) = 25;', script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_disp_idx) == 3;' }, then: [ lambda: 'id(g_mode) = 26;', script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_disp_idx) == 4;' }, then: [ lambda: 'id(g_mode) = 24;', script.execute: ui_render ] } + - if: # back -> settings list + condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' } + then: + - lambda: 'id(g_mode) = 5;' + - script.execute: ui_render + # === HOME (12) === tap: 0=toggle weather widget, 1=toggle climate widget, 2=cycle watchface; right/hold=back + - if: + condition: { lambda: 'return id(g_mode0) == 12;' } + then: + - if: { condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 0;' }, then: [ lambda: 'id(g_show_weather) = !id(g_show_weather);', script.execute: disp_apply, script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 1;' }, then: [ lambda: 'id(g_show_climate) = !id(g_show_climate);', script.execute: disp_apply, script.execute: ui_render ] } + - if: # enter the live watchface picker (mode 33) on the home page + condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 2;' } + then: + - lambda: 'id(g_wf_prev) = id(g_watchface); id(g_wf_pick) = id(g_watchface); id(g_mode) = 33;' + - script.execute: ui_render + - script.execute: face_apply + - script.execute: facepick_render + - if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 5;', script.execute: ui_render ] } + # === WATCHFACE PICKER (33) === knob switches faces live; tap = keep, swipe-right/hold = cancel (revert) + - if: + condition: { lambda: 'return id(g_mode0) == 33;' } + then: + - if: # tap -> keep the previewed face (already applied + persisted), drop to home + condition: { lambda: 'return id(g_gesture) == 0;' } + then: + - lvgl.widget.hide: pick_bar + - script.execute: home_roller_build + - lambda: 'id(g_mode) = 0; id(g_exit_anim) = 0;' + - script.execute: ui_render + - if: # swipe-right / hold -> cancel: restore the previous face + condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' } + then: + - lvgl.widget.hide: pick_bar + - lambda: 'id(g_watchface) = id(g_wf_prev);' + - script.execute: face_apply + - script.execute: home_roller_build + - lambda: 'id(g_mode) = 0; id(g_exit_anim) = 0;' + - script.execute: ui_render + # === WIDGETS (17) === list of per-screen groups; tap opens one; right/hold = back to settings + - if: + condition: { lambda: 'return id(g_mode0) == 17;' } + then: + - if: + condition: { lambda: 'return id(g_gesture) == 0 && id(g_wgrp_n) > 0;' } + then: + - lambda: |- + int g = id(g_wgrp_idx); + id(g_wcur_n) = 0; + for (int o = 0; o < id(g_wopt_n); o++) if (id(g_wopt_group)[o] == g) id(g_wcur_opts)[id(g_wcur_n)++] = o; + std::string s; for (int k = 0; k < id(g_wcur_n); k++) { if (k) s += "\n"; s += id(g_wopt_label)[id(g_wcur_opts)[k]]; } + lv_roller_set_options(id(wopt_roller)->obj, s.c_str(), LV_ROLLER_MODE_NORMAL); + id(g_wcur_name) = ""; { std::string gl = id(g_wgrp_labels); int idx = 0; size_t pp = 0; while (true) { size_t nl = gl.find('\n', pp); std::string tok = (nl == std::string::npos) ? gl.substr(pp) : gl.substr(pp, nl - pp); if (idx == g) { id(g_wcur_name) = tok; break; } if (nl == std::string::npos) break; pp = nl + 1; idx++; } } + id(g_wopt_idx) = 0; id(g_mode) = 30; + - script.execute: ui_render + - if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 5;', script.execute: ui_render ] } + # === WIDGET OPTIONS (30) === tap: toggle / confirm action; right/hold = back to Widgets + - if: + condition: { lambda: 'return id(g_mode0) == 30;' } + then: + - if: + condition: { lambda: 'return id(g_gesture) == 0 && id(g_wcur_n) > 0;' } + then: + - if: + condition: { lambda: 'return id(g_wopt_kind)[id(g_wcur_opts)[id(g_wopt_idx)]] == 0;' } + then: [ lambda: 'int o = id(g_wcur_opts)[id(g_wopt_idx)]; if (id(g_wopt_ptr)[o]) *id(g_wopt_ptr)[o] = !*id(g_wopt_ptr)[o];', script.execute: ui_render ] + else: [ lambda: 'id(g_mode) = 31;', script.execute: ui_render ] + - if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 17;', script.execute: ui_render ] } + # === CONFIRM (31) === generic action confirm; tap = YES (fire), right/hold = NO + - if: + condition: { lambda: 'return id(g_mode0) == 31;' } + then: + - if: { condition: { lambda: 'return id(g_gesture) == 0 && id(g_wcur_n) > 0;' }, then: [ lambda: 'int o = id(g_wcur_opts)[id(g_wopt_idx)]; if (id(g_wopt_ptr)[o]) *id(g_wopt_ptr)[o] = true; id(g_mode) = 30;', script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 30;', script.execute: ui_render ] } + # === ASSISTANT (13) === tap: 0=toggle wake word, 1=toggle beep; right/hold=back + - if: + condition: { lambda: 'return id(g_mode0) == 13;' } + then: + - if: + condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 0;' } + then: + - lambda: 'id(g_ww_on) = !id(g_ww_on);' + - if: + condition: { lambda: 'return id(g_ww_on);' } + then: [micro_wake_word.start: {}] + else: [micro_wake_word.stop: {}] + - script.execute: ui_render + - if: { condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 1;' }, then: [ lambda: 'id(g_beep_ww) = !id(g_beep_ww);', script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_gesture) == 0 && id(g_sub_idx) == 2;' }, then: [ lambda: 'id(g_hold_talk) = !id(g_hold_talk);', script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 5;', script.execute: ui_render ] } + # === SYSTEM (15) === + - if: + condition: { lambda: 'return id(g_mode0) == 15;' } + then: + - if: + condition: { lambda: 'return id(g_gesture) == 0;' } + then: + - if: { condition: { lambda: 'return id(g_sub_idx) == 0;' }, then: [ lambda: 'id(g_mode) = 7;', script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_sub_idx) == 1;' }, then: [ lambda: 'id(g_mode) = 23;', script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_sub_idx) == 2;' }, then: [ lambda: 'id(g_mode) = 9;', script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_sub_idx) == 3;' }, then: [ lambda: 'id(g_mode) = 22;', script.execute: ui_render ] } + - if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 5;', script.execute: ui_render ] } + # === RESTART confirm (9) === tap = YES (restart); right/hold = NO + - if: + condition: + lambda: 'return id(g_mode0) == 9;' + then: + - if: + condition: { lambda: 'return id(g_gesture) == 0;' } + then: + - button.press: btn_restart + - if: + condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' } + then: + - lambda: 'id(g_mode) = 5;' + - script.execute: ui_render + # === BRIGHTNESS (6) === any gesture = return to Display (we enter from there) + - if: + condition: + lambda: 'return id(g_mode0) == 6;' + then: + - if: + condition: { lambda: 'return id(g_gesture) >= 0;' } + then: + - lambda: 'id(g_mode) = 11;' + - script.execute: ui_render + # === SCREEN OFF (24) === any gesture = return to Display + - if: + condition: { lambda: 'return id(g_mode0) == 24;' } + then: + - if: + condition: { lambda: 'return id(g_gesture) >= 0;' } + then: [ lambda: 'id(g_mode) = 11;', script.execute: ui_render ] + # === NIGHT FROM/TO (25/26) === any gesture = return to Display + - if: + condition: { lambda: 'return id(g_mode0) == 25 || id(g_mode0) == 26;' } + then: + - if: { condition: { lambda: 'return id(g_gesture) >= 0;' }, then: [ lambda: 'id(g_mode) = 11;', script.execute: ui_render ] } + # === BATTERY (23) === any gesture = return to System + - if: + condition: { lambda: 'return id(g_mode0) == 23;' } + then: + - if: { condition: { lambda: 'return id(g_gesture) >= 0;' }, then: [ lambda: 'id(g_mode) = 15;', script.execute: ui_render ] } + # === FACTORY confirm (22) === tap=YES (reset+restart); right/hold=NO + - if: + condition: { lambda: 'return id(g_mode0) == 22;' } + then: + - if: + condition: { lambda: 'return id(g_gesture) == 0;' } + then: + - lambda: |- + id(g_bright)=100; id(g_night)=false; id(g_show_weather)=false; id(g_show_climate)=false; id(g_watchface)=0; id(g_ww_on)=true; id(g_set_min)=60; id(g_beep_ww)=true; id(g_hold_talk)=true; id(g_night_from)=22; id(g_night_to)=7; id(g_blank_idx)=0; id(g_factory_reset)=true; + - button.press: btn_restart + - if: { condition: { lambda: 'return id(g_gesture) == 3 || id(g_gesture) == 1;' }, then: [ lambda: 'id(g_mode) = 15;', script.execute: ui_render ] } + # === INFO (7) === any gesture = return to the settings list + - if: + condition: + lambda: 'return id(g_mode0) == 7;' + then: + - if: + condition: { lambda: 'return id(g_gesture) >= 0;' } + then: + - lambda: 'id(g_mode) = 5;' + - script.execute: ui_render + +display: + - platform: mipi_spi + id: main_display + model: JC3636W518V2 + data_rate: 80MHz + color_order: rgb + rotation: 0 + cs_pin: 14 + reset_pin: 21 + invert_colors: true + auto_clear_enabled: false + update_interval: never + dimensions: + height: 360 + width: 360 + +############################################################################### +# FONTS (Latin glyphs incl. diacritics) + PLAYER DATA + ALBUM ART +############################################################################### +font: + - file: "gfonts://Roboto" + id: font_med + size: 22 + glyphsets: [GF_Latin_Core] + ignore_missing_glyphs: true # GF_Latin_Core lists combining accents Roboto's static face lacks - harmless, silence the warning + - file: "gfonts://Roboto" + id: font_artist + size: 16 + glyphsets: [GF_Latin_Core] + ignore_missing_glyphs: true + - file: "gfonts://Roboto" + id: font_sel + size: 28 + glyphsets: [GF_Latin_Core] + ignore_missing_glyphs: true + # Big bell for the alarm screen (single MDI glyph) + - file: "https://github.com/Templarian/MaterialDesign-Webfont/raw/v7.4.47/fonts/materialdesignicons-webfont.ttf" + id: font_bell + size: 72 + bpp: 4 + glyphs: [ "\U000F009A" ] + # Material Design icons (for the control tiles) - ESPHome downloads the TTF at compile time + - file: "https://github.com/Templarian/MaterialDesign-Webfont/raw/v7.4.47/fonts/materialdesignicons-webfont.ttf" + id: icons34 + size: 34 + bpp: 4 + glyphs: + # smart-home icon set (reference these by name in tileN_icon) + - "\U000f0335" + - "\U000f06b5" + - "\U000f07d6" + - "\U000f0425" + - "\U000f06a5" + - "\U000f0521" + - "\U000f0210" + - "\U000f001b" + - "\U000f0438" + - "\U000f050f" + - "\U000f033e" + - "\U000f033f" + - "\U000f081a" + - "\U000f00ac" + - "\U000f0502" + - "\U000f04c3" + - "\U000f009a" + - "\U000f05a9" + - "\U000f02dc" + - "\U000f0241" + - "\U000f0d44" + - "\U000f0e66" + - "\U000f06ba" + - "\U000f048b" + - "\U000f068a" + - "󰖐" + - "󰖑" + - "󰖓" + - "󰖔" + - "󰖕" + - "󰖖" + - "󰖗" + - "󰖘" + - "󰖙" + - "󰖝" + + - file: "https://github.com/Templarian/MaterialDesign-Webfont/raw/v7.4.47/fonts/materialdesignicons-webfont.ttf" + id: icons18 + size: 20 + bpp: 4 + glyphs: + - "󰋑" + - "󰔏" + - "󰖎" + - "\U000f09de" + - "\U000f09df" + +text_sensor: + - platform: wifi_info # IP for the Info screen (settings) + ip_address: + id: wifi_ip + # Control-tile states -> tile colour (on = accent, off = dark) + - platform: homeassistant + id: st_tile1 + entity_id: ${tile1_entity} + on_value: + - if: + condition: { lambda: 'return x == "on";' } + then: + - lvgl.widget.update: { id: tile1, bg_color: 0xF5B83D, border_color: 0xFFD27A, shadow_opa: 70% } + - lvgl.label.update: { id: tile1_ic, text_color: 0x231803 } + - lvgl.label.update: { id: tile1_lbl, text_color: 0x231803 } + else: + - lvgl.widget.update: { id: tile1, bg_color: 0x161A22, border_color: 0x2A303C, shadow_opa: 0% } + - lvgl.label.update: { id: tile1_ic, text_color: 0x707A8A } + - lvgl.label.update: { id: tile1_lbl, text_color: 0x9AA3B0 } + - platform: homeassistant + id: st_tile2 + entity_id: ${tile2_entity} + on_value: + - if: + condition: { lambda: 'return x == "on";' } + then: + - lvgl.widget.update: { id: tile2, bg_color: 0xF5B83D, border_color: 0xFFD27A, shadow_opa: 70% } + - lvgl.label.update: { id: tile2_ic, text_color: 0x231803 } + - lvgl.label.update: { id: tile2_lbl, text_color: 0x231803 } + else: + - lvgl.widget.update: { id: tile2, bg_color: 0x161A22, border_color: 0x2A303C, shadow_opa: 0% } + - lvgl.label.update: { id: tile2_ic, text_color: 0x707A8A } + - lvgl.label.update: { id: tile2_lbl, text_color: 0x9AA3B0 } + - platform: homeassistant + id: st_tile3 + entity_id: ${tile3_entity} + on_value: + - if: + condition: { lambda: 'return x == "on";' } + then: + - lvgl.widget.update: { id: tile3, bg_color: 0xF5B83D, border_color: 0xFFD27A, shadow_opa: 70% } + - lvgl.label.update: { id: tile3_ic, text_color: 0x231803 } + - lvgl.label.update: { id: tile3_lbl, text_color: 0x231803 } + else: + - lvgl.widget.update: { id: tile3, bg_color: 0x161A22, border_color: 0x2A303C, shadow_opa: 0% } + - lvgl.label.update: { id: tile3_ic, text_color: 0x707A8A } + - lvgl.label.update: { id: tile3_lbl, text_color: 0x9AA3B0 } + # Weather: state (rainy/sunny/...) -> MDI icon on the home screen + - platform: homeassistant + id: wx_state + entity_id: ${weather_entity} + on_value: + # Map the HA condition -> MDI glyph + a friendly UPPERCASE label, stash both in globals + # (lbl_wx_ic reads the glyph; the Neon watchface reuses glyph + condition text). + - lambda: |- + std::string s = x, g, c; + if (s == "sunny") { g = "󰖙"; c = "SUNNY"; } + else if (s == "clear-night") { g = "󰖔"; c = "CLEAR"; } + else if (s == "partlycloudy") { g = "󰖕"; c = "PARTLY CLOUDY"; } + else if (s == "fog") { g = "󰖑"; c = "FOG"; } + else if (s == "windy" || s == "windy-variant") { g = "󰖝"; c = "WINDY"; } + else if (s == "lightning" || s == "lightning-rainy") { g = "󰖓"; c = "STORM"; } + else if (s == "pouring") { g = "󰖖"; c = "POURING"; } + else if (s == "rainy") { g = "󰖗"; c = "RAINY"; } + else if (s == "snowy" || s == "snowy-rainy" || s == "hail") { g = "󰖘"; c = "SNOW"; } + else { g = "󰖐"; c = "CLOUDY"; } + id(g_nf_glyph) = g; id(g_nf_cond) = c; + - lvgl.label.update: { id: lbl_wx_ic, text: !lambda 'return id(g_nf_glyph);' } +############################################################################### +# LVGL - home page (clock + VA status) and PLAYER page (album art) +############################################################################### +interval: + # Alarm screen pulse: flash the bell + scrim while the alarm is ringing (mode 4) + - interval: 400ms + then: + - if: + condition: { lambda: 'return id(g_mode) == 4;' } + then: + - lambda: 'id(g_alarm_pulse) = !id(g_alarm_pulse);' + - if: + condition: { lambda: 'return id(g_alarm_pulse);' } + then: + - lvgl.widget.update: { id: alarm_scrim, bg_color: 0x520F0F } + - lvgl.label.update: { id: alarm_bell, text_color: 0xFFFFFF } + else: + - lvgl.widget.update: { id: alarm_scrim, bg_color: 0x2E0808 } + - lvgl.label.update: { id: alarm_bell, text_color: 0xFF7A6A } + # Boot splash: type out "HELLO!" then cross-fade to the clock (~5 s), once at startup. + - interval: 150ms + then: + - if: + condition: { lambda: 'return id(g_boot);' } + then: + - lambda: 'id(g_boot_t)++;' + - lvgl.label.update: { id: lbl_boot, text: !lambda 'std::string w = "HELLO!"; int n = id(g_boot_t); if (n > (int) w.size()) n = w.size(); return w.substr(0, n);' } + - if: + condition: { lambda: 'return id(g_boot_t) >= 33;' } # 33 * 150ms ~= 5 s + then: + - lambda: 'id(g_boot) = false;' + - safe_mode.mark_successful: # commit this OTA image now so a restart won't roll back to the old firmware + - script.execute: face_apply # reveal the active home watchface (Classic page_home, or a face's own page) + - interval: 2s + then: + - lambda: |- + id(g_idle_ticks)++; + bool off = false; + if (id(g_night) && id(ha_time).now().is_valid()) { + int h = id(ha_time).now().hour, f = id(g_night_from), t = id(g_night_to); + bool inwin = (f < t) ? (h >= f && h < t) : (h >= f || h < t); + if (inwin && id(g_idle_ticks) * 2 >= 10) off = true; + } + int secs[5] = {0, 15, 30, 60, 300}; + int bs = secs[id(g_blank_idx) % 5]; + if (bs > 0 && id(g_idle_ticks) * 2 >= bs) off = true; + if (id(g_va_active) || id(g_mode) == 3 || id(g_mode) == 4) off = false; + id(g_want_off) = off; + - if: + condition: { lambda: 'return id(g_want_off) && !id(g_disp_off);' } + then: + - light.turn_off: backlight_light + - lambda: 'id(g_disp_off) = true;' + - if: + condition: { lambda: 'return !id(g_want_off) && id(g_disp_off);' } + then: + - light.turn_on: { id: backlight_light, brightness: !lambda 'return id(g_bright) / 100.0f;' } + - lambda: 'id(g_disp_off) = false;' + - interval: 1s + then: + # TZ self-heal: some ESPHome builds reset the device timezone to UTC at runtime (e.g. on a + # menu restart or when a log/API client without a TZ reconnects), so the clock jumps to UTC. + # g_tz_posix was resolved at boot (from the `timezone` substitution, or the time component); + # re-assert it every tick so a stray UTC reset can't stick. (No-op once it is correct.) + - lambda: 'if (!id(g_tz_posix).empty()) { setenv("TZ", id(g_tz_posix).c_str(), 1); tzset(); }' + # Battery: while charging - "filling" animation; when full (≥99%) - static full icon. + - lambda: 'if (id(g_charging)) id(g_chg_anim) = (id(g_chg_anim) + 1) % 5;' + - script.execute: batt_render + # Clock/date: redraw only when the minute actually changes (not every second) + - lambda: |- + id(g_home_upd_min) = false; + auto t = id(ha_time).now(); + if (t.is_valid()) { int mn = t.hour * 60 + t.minute; if (mn != id(g_home_last_min)) { id(g_home_last_min) = mn; id(g_home_upd_min) = true; } } + - if: + condition: { lambda: 'return id(g_home_upd_min);' } + then: + - lvgl.label.update: + id: lbl_clock + text: !lambda 'return id(ha_time).now().strftime("%H:%M");' + - lvgl.label.update: + id: lbl_date + text: !lambda |- + auto t = id(ha_time).now(); + static const char* d[8] = {"","Su","Mo","Tu","We","Th","Fr","Sa"}; + std::string day = (t.day_of_week >= 1 && t.day_of_week <= 7) ? std::string(d[t.day_of_week]) : std::string(""); + return t.strftime("%d.%m") + " " + day; + # Screen-timer countdown (g_timer_active). From the button: local. From VA: HA calls finished, locally smooth. + - if: + condition: + lambda: 'return id(g_timer_active) && !id(g_timer_paused);' + then: + - lambda: 'id(g_timer_secs)--;' + - script.execute: timer_render + - if: # button timer reached zero -> alarm (with VA the alarm fires on on_timer_finished) + condition: { lambda: 'return id(g_timer_local) && id(g_timer_secs) <= 0;' } + then: + - script.execute: alarm_start + # Timer badge on the home screen (red) - time left (rounded up to minutes) + - if: + condition: { lambda: 'return id(g_timer_active);' } + then: + - lvgl.widget.show: lbl_timer + - lvgl.label.update: + id: lbl_timer + text: !lambda |- + int s = id(g_timer_secs); if (s < 0) s = 0; + int m = (s + 59) / 60; + char b[24]; snprintf(b, sizeof(b), " %d min", m); + return std::string(b); + else: + - lvgl.widget.hide: lbl_timer + - if: # refresh the alarm overlay every second + condition: + lambda: 'return id(g_mode) == 4;' + then: + - script.execute: ui_render + - if: # alarm: repeat the sound every ~2 s + condition: + lambda: 'return id(g_mode) == 4;' + then: + - lambda: 'id(g_alarm_cnt)++;' + - if: + condition: + lambda: 'return id(g_alarm_cnt) % 2 == 0;' + then: + - media_player.speaker.play_on_device_media_file: + id: media_player_speaker + media_file: alarm_sound + announcement: true + +lvgl: + # A <25% buffer lands in FAST internal RAM (instead of slow PSRAM at the default 100%) + buffer_size: 24% + displays: + - main_display + touchscreens: + - my_touchscreen + # Always-on-top layer: assistant overlay (dim + frame + text) + top_layer: + widgets: + # Watchface picker overlay (shown only in pick mode 33) - last so it draws on top. + # A bottom sheet: rounded top, square bottom pushed BELOW the screen edge (y:24 puts the + # bottom + its corner radius off-display), so only the straight sides reach the edge. + - obj: + id: pick_bar + align: BOTTOM_MID + y: 24 + width: 210 + height: 96 + radius: 24 + bg_color: 0x0A0E16 + bg_opa: 82% + border_width: 1 + border_color: 0xF5B83D + border_opa: 35% + pad_all: 0 + clickable: false + scrollable: false + hidden: true + widgets: + - label: # left chevron (turn the knob) + id: pick_chevl + align: TOP_LEFT + x: 16 + y: 16 + text: "<" + text_font: font_sel + text_color: 0x55606E + - label: # right chevron + id: pick_chevr + align: TOP_RIGHT + x: -16 + y: 16 + text: ">" + text_font: font_sel + text_color: 0x55606E + - label: # face name (set by facepick_render) + id: pick_name + align: TOP_MID + y: 12 + text: "" + text_font: font_sel + text_color: 0xFFFFFF + - label: # subtle hint + id: pick_help + align: TOP_MID + y: 44 + text: "Tap to keep" + text_font: font_artist + text_color: 0x6A7280 + - obj: + id: va_dim + x: 0 + y: 0 + width: 360 + height: 360 + radius: 0 + border_width: 0 + bg_color: 0x000000 + bg_opa: 100% + hidden: true + - arc: + id: va_frame + align: CENTER + width: 320 + height: 320 + arc_width: 2 + min_value: 0 + max_value: 100 + value: 0 + start_angle: 0 + end_angle: 360 + adjustable: false + arc_color: 0x0E5E54 + indicator: + arc_opa: 0% + hidden: true + - label: + id: va_text + align: CENTER + text: "" + text_font: font_med + text_color: 0x5FF0D9 + hidden: true + # Volume overlay - on top_layer, so it shows on EVERY screen while turning + - obj: + id: vol_dim + x: 0 + y: 0 + width: 360 + height: 360 + radius: 0 + border_width: 0 + bg_color: 0x000000 + bg_opa: 55% + hidden: true + - arc: + id: vol_arc + align: CENTER + width: 350 + height: 350 + arc_width: 8 + min_value: 0 + max_value: 100 + value: 50 + start_angle: 135 + end_angle: 45 + adjustable: false + hidden: true + arc_color: 0x0C4A44 + indicator: + arc_color: 0x42F4DC + arc_width: 8 + - label: + id: vol_label + align: TOP_MID + y: 40 + hidden: true + text: "50%" + text_color: 0x5FF0D9 + text_font: montserrat_20 + pages: + # --- Home page: clock + Assistant status --- + # --- Boot splash: typewriter "HELLO!" then fade to the clock (driven by the 150ms interval) --- + - id: page_boot + bg_color: 0x000000 + widgets: + # Rotating arc around the greeting - the "spinning" boot animation. + - spinner: + id: boot_spin + align: CENTER + width: 300 + height: 300 + spin_time: 1500ms + arc_length: 70deg + arc_width: 6 + arc_color: 0x16302B # faint static track + indicator: + arc_color: 0x5FF0D9 # bright sweeping arc (brand cyan) + arc_width: 6 + - label: { id: lbl_boot, align: CENTER, y: -10, text: "", text_font: montserrat_48, text_color: 0xFFFFFF } + - label: { id: lbl_boot_sub, align: CENTER, y: 40, text: "${friendly_name}", text_font: font_med, text_color: 0x5FF0D9 } + - id: page_home + bg_color: 0x000000 + widgets: + - obj: # faint outer ring for depth (matches the other screens) + id: home_ring_out + align: CENTER + width: 340 + height: 340 + radius: 170 + bg_opa: TRANSP + border_width: 2 + border_color: 0x1A1F2A + border_opa: 70% + clickable: false + scrollable: false + - label: + id: lbl_clock + align: CENTER + y: -22 + text: "--:--" + text_color: 0xFFFFFF + text_font: montserrat_48 + - label: + id: lbl_date + align: CENTER + y: 34 + text: "" + text_color: 0x6f9a92 + text_font: font_med + - label: + id: lbl_batt + align: BOTTOM_MID + y: -30 + text: "" + text_color: 0x6f9a92 + text_font: montserrat_18 + - label: # red badge: timer active - time left (TOP) + id: lbl_timer + align: TOP_MID + y: 22 + text: "" + text_font: montserrat_18 + text_color: 0xFFFFFF + bg_color: 0xFF3B30 + bg_opa: COVER + radius: 14 + pad_left: 12 + pad_right: 12 + pad_top: 5 + pad_bottom: 5 + hidden: true + - label: + id: lbl_wx_ic + align: CENTER + x: -22 + y: -92 + text: "󰖐" + text_font: icons34 + text_color: 0xBFD2DA + hidden: true + - label: + id: lbl_wx_val + align: CENTER + x: 22 + y: -92 + text: "-" + text_font: font_med + text_color: 0xE8E8EA + hidden: true + - label: + id: lbl_ct_ic + align: CENTER + x: -88 + y: 78 + text: "󰔏" + text_font: icons18 + text_color: 0xFF8A5C + hidden: true + - label: + id: lbl_ct_val + align: CENTER + x: -52 + y: 78 + text: "-" + text_font: font_med + text_color: 0xE8E8EA + hidden: true + - label: + id: lbl_ch_ic + align: CENTER + x: 24 + y: 78 + text: "󰖎" + text_font: icons18 + text_color: 0x3FA9D6 + hidden: true + - label: + id: lbl_ch_val + align: CENTER + x: 56 + y: 78 + text: "-" + text_font: font_med + text_color: 0xE8E8EA + hidden: true + - id: page_mode + bg_color: 0x000000 + widgets: + # Menu / timer / alarm overlay (controlled by ui_render) + - obj: + id: ui_dim + x: 0 + y: 0 + width: 360 + height: 360 + radius: 0 + border_width: 0 + bg_color: 0x000000 + bg_opa: 100% + hidden: true + - obj: + id: ui_head_bg + align: TOP_MID + y: 0 + width: 360 + height: 46 + radius: 0 + border_width: 0 + bg_color: 0x42F4DC + bg_opa: COVER + hidden: true + - label: + id: ui_head + align: TOP_MID + y: 15 + text: "" + text_font: font_artist + text_color: 0x000000 + hidden: true + - label: + id: ui_big + align: CENTER + y: -12 + text: "" + text_font: montserrat_48 + text_color: 0xFFFFFF + hidden: true + - label: + id: ui_sub + align: CENTER + y: 56 + width: 300 + text_align: CENTER + text: "" + text_font: font_med + text_color: 0x5FF0D9 + hidden: true + # Brightness bar - visible only in mode 6 + - bar: + id: ui_bar + align: CENTER + y: 36 + width: 180 + height: 8 + min_value: 0 + max_value: 100 + value: 100 + hidden: true + indicator: + bg_color: 0xF5B83D + # "Display" submenu (mode 11): item roller + status label of the selected one + - roller: + id: disp_roller + align: CENTER + y: -14 + width: 290 + visible_row_count: 3 + clickable: false + text_font: font_med + text_color: 0x9AA3B0 + bg_opa: TRANSP + border_width: 0 + selected: + text_color: 0x231803 + bg_color: 0xF5B83D + bg_opa: COVER + radius: 14 + options: + - "Brightness" + - "Night mode" + - "Night from" + - "Night to" + - "Screen auto-off" + selected_index: 0 + hidden: true + - label: # only for "Brightness" (shows %); toggles use disp_switch + id: disp_lbl + align: CENTER + y: 92 + text: "" + text_font: font_artist + text_color: 0x5FF0D9 + hidden: true + - switch: # visual switch state (clickable: false - tapping the screen toggles) + id: disp_switch + align: CENTER + y: 88 + clickable: false + hidden: true + - roller: + id: home_roller + align: CENTER + y: -14 + width: 290 + visible_row_count: 3 + clickable: false + text_font: font_med + text_color: 0x9AA3B0 + bg_opa: TRANSP + border_width: 0 + selected: + text_color: 0x231803 + bg_color: 0xF5B83D + bg_opa: COVER + radius: 14 + options: # 3rd line is rebuilt at runtime to "Watchface: " (home_roller_build) + - "Show weather" + - "Show climate" + - "Watchface" + selected_index: 0 + hidden: true + - roller: + id: wid_roller + align: CENTER + y: -14 + width: 290 + visible_row_count: 3 + clickable: false + text_font: font_med + text_color: 0x9AA3B0 + bg_opa: TRANSP + border_width: 0 + selected: + text_color: 0x231803 + bg_color: 0xF5B83D + bg_opa: COVER + radius: 14 + options: + - "..." + selected_index: 0 + hidden: true + - roller: + id: wopt_roller + align: CENTER + y: -14 + width: 290 + visible_row_count: 3 + clickable: false + text_font: font_med + text_color: 0x9AA3B0 + bg_opa: TRANSP + border_width: 0 + selected: + text_color: 0x231803 + bg_color: 0xF5B83D + bg_opa: COVER + radius: 14 + options: + - "..." + selected_index: 0 + hidden: true + - roller: + id: asys_roller + align: CENTER + y: -14 + width: 290 + visible_row_count: 3 + clickable: false + text_font: font_med + text_color: 0x9AA3B0 + bg_opa: TRANSP + border_width: 0 + selected: + text_color: 0x231803 + bg_color: 0xF5B83D + bg_opa: COVER + radius: 14 + options: + - "Wake word" + - "Beep wake word" + - "Hold to talk" + selected_index: 0 + hidden: true + - roller: + id: sys_roller + align: CENTER + y: -14 + width: 290 + visible_row_count: 3 + clickable: false + text_font: font_med + text_color: 0x9AA3B0 + bg_opa: TRANSP + border_width: 0 + selected: + text_color: 0x231803 + bg_color: 0xF5B83D + bg_opa: COVER + radius: 14 + options: + - "Info" + - "Battery" + - "Restart" + - "Factory reset" + selected_index: 0 + hidden: true + # ───────── Alarm screen (mode 4): red scrim + pulsing bell + TIME'S UP ───────── + - obj: + id: alarm_scrim + x: 0 + y: 0 + width: 360 + height: 360 + radius: 0 + border_width: 0 + bg_color: 0x3A0A0A + bg_opa: COVER + clickable: false + scrollable: false + hidden: true + - label: + id: alarm_bell + align: CENTER + y: -52 + text: "\U000F009A" + text_font: font_bell + text_color: 0xFFFFFF + hidden: true + - label: + id: alarm_title + align: CENTER + y: 38 + text: "TIME'S UP!" + text_font: font_sel + text_color: 0xFFFFFF + hidden: true + - label: + id: alarm_sub + align: CENTER + y: 76 + text: "Tap to stop" + text_font: font_artist + text_color: 0xF0B4B4 + hidden: true + - id: page_settings + bg_color: 0x000000 + widgets: + - obj: # header bar (amber) + id: ui_head_bg_s + align: TOP_MID + y: 0 + width: 360 + height: 46 + radius: 0 + border_width: 0 + bg_color: 0xF5B83D + bg_opa: COVER + - label: + id: ui_head_s + align: TOP_MID + y: 15 + text: "SETTINGS" + text_font: font_artist + text_color: 0x000000 + # Settings list (mode 5) - animated roller; controlled by the knob (clickable: false) + - roller: + id: settings_roller + align: CENTER + y: 8 + width: 260 + visible_row_count: 5 + clickable: false + text_font: font_med + text_color: 0x9AA3B0 + bg_opa: TRANSP + border_width: 0 + selected: + text_color: 0x231803 + bg_color: 0xF5B83D + bg_opa: COVER + radius: 14 + options: + - "Display" + - "Ring" + - "Assistant" + - "System" + selected_index: 0 + # --- TIMER page (left of the clock): ring + controls --- + - id: page_timer + bg_color: 0x000000 + widgets: + - obj: # faint outer ring for depth + id: timer_ring_out + align: CENTER + width: 332 + height: 332 + radius: 166 + bg_opa: TRANSP + border_width: 2 + border_color: 0x252B37 + border_opa: 65% + clickable: false + scrollable: false + - arc: + id: timer_arc + align: CENTER + width: 300 + height: 300 + arc_width: 14 + adjustable: false + min_value: 0 + max_value: 100 + value: 100 + start_angle: 271 + end_angle: 269 + arc_color: 0x1C2027 + indicator: + arc_color: 0x2FB85C + arc_width: 14 + - obj: # head-dot: a white bead riding the leading edge of the remaining arc (positioned in timer_render) + id: timer_dot + x: 0 + y: 0 + width: 18 + height: 18 + radius: 9 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 0 + shadow_color: 0xFFFFFF + shadow_width: 10 + shadow_opa: 55% + clickable: false + scrollable: false + hidden: true + - label: + id: timer_cap + align: CENTER + y: -72 + text: "SET" + text_font: font_artist + text_color: 0x6F9A92 + - label: + id: timer_big + align: CENTER + y: -20 + text: "60:00" + text_font: montserrat_48 + text_color: 0xFFFFFF + - button: + id: timer_play + align: CENTER + y: 54 + width: 58 + height: 58 + radius: 29 + bg_color: 0x2FB85C + bg_opa: COVER + border_width: 0 + shadow_color: 0x2FB85C + shadow_width: 18 + shadow_opa: 55% + on_click: + - script.execute: timer_start + widgets: + - label: { align: CENTER, text: "", text_font: montserrat_28, text_color: 0xFFFFFF } + - button: + id: timer_pause + align: CENTER + x: -40 + y: 54 + width: 52 + height: 52 + radius: 26 + bg_color: 0xF5B83D + bg_opa: COVER + border_width: 0 + shadow_color: 0xF5B83D + shadow_width: 14 + shadow_opa: 50% + hidden: true + on_click: + - script.execute: timer_toggle + widgets: + - label: { id: timer_pause_ic, align: CENTER, text: "", text_font: montserrat_22, text_color: 0x231803 } + - button: + id: timer_stop + align: CENTER + x: 40 + y: 54 + width: 52 + height: 52 + radius: 26 + bg_color: 0x1E232E + bg_opa: COVER + border_width: 2 + border_color: 0x2E3540 + hidden: true + on_click: + - script.execute: timer_halt + widgets: + - label: { align: CENTER, text: "", text_font: montserrat_22, text_color: 0xFF6B60 } + # --- CONTROLS page (swipe up): 4 configurable HA toggles (entity/icon/name in substitutions) --- + - id: page_controls + bg_color: 0x0A0E16 + widgets: + - button: + id: tile1 + align: CENTER + x: -61 + y: -61 + width: 110 + height: 110 + radius: 22 + bg_color: 0x161A22 + bg_opa: COVER + border_width: 2 + border_color: 0x2A303C + shadow_color: 0xF5B83D + shadow_width: 18 + shadow_spread: 1 + shadow_opa: 0% + on_click: + - homeassistant.action: { action: homeassistant.toggle, data: { entity_id: "${tile1_entity}" } } + widgets: + - label: { id: tile1_ic, align: CENTER, y: -16, text: "", text_font: icons34, text_color: 0x707A8A } + - label: { id: tile1_lbl, align: CENTER, y: 26, width: 100, long_mode: wrap, text_align: CENTER, text: "${tile1_name}", text_font: font_artist, text_color: 0x9AA3B0, text_letter_space: 2 } + - button: + id: tile2 + align: CENTER + x: 61 + y: -61 + width: 110 + height: 110 + radius: 22 + bg_color: 0x161A22 + bg_opa: COVER + border_width: 2 + border_color: 0x2A303C + shadow_color: 0xF5B83D + shadow_width: 18 + shadow_spread: 1 + shadow_opa: 0% + on_click: + - homeassistant.action: { action: homeassistant.toggle, data: { entity_id: "${tile2_entity}" } } + widgets: + - label: { id: tile2_ic, align: CENTER, y: -16, text: "", text_font: icons34, text_color: 0x707A8A } + - label: { id: tile2_lbl, align: CENTER, y: 26, width: 100, long_mode: wrap, text_align: CENTER, text: "${tile2_name}", text_font: font_artist, text_color: 0x9AA3B0, text_letter_space: 2 } + - button: + id: tile3 + align: CENTER + x: 61 + y: 61 + width: 110 + height: 110 + radius: 22 + bg_color: 0x161A22 + bg_opa: COVER + border_width: 2 + border_color: 0x2A303C + shadow_color: 0xF5B83D + shadow_width: 18 + shadow_spread: 1 + shadow_opa: 0% + on_click: + - homeassistant.action: { action: homeassistant.toggle, data: { entity_id: "${tile3_entity}" } } + widgets: + - label: { id: tile3_ic, align: CENTER, y: -16, text: "", text_font: icons34, text_color: 0x707A8A } + - label: { id: tile3_lbl, align: CENTER, y: 26, width: 100, long_mode: wrap, text_align: CENTER, text: "${tile3_name}", text_font: font_artist, text_color: 0x9AA3B0, text_letter_space: 2 } diff --git a/base/screens/README.md b/base/screens/README.md new file mode 100644 index 0000000..2af8220 --- /dev/null +++ b/base/screens/README.md @@ -0,0 +1,95 @@ +# Modular screens (beta) + +Goal: pick which carousel screens are compiled in, straight from the YAML - comment a +line in `packages:` and the screen is gone from the firmware (and the flash it used). + +This is a **beta** layout on the `beta` branch. It needs to be compiled on a device +(there is no local validation here). Until it is verified, `main` keeps the single-file +config. + +## How you choose screens + +You keep one thin file locally (`guition-va.yaml`, repo root) that pulls the core and the +screens from GitHub as a remote package. Pick screens in its `files:` list: + +```yaml +packages: + core: + url: https://github.com/MichalZaniewicz/esphome-guition-jc3636k718c-va + ref: beta + files: + - base/core.yaml # always on (clock + controls + settings) + - base/screens/timer.yaml + - base/screens/cool-cars.yaml + - base/screens/space-wars.yaml # comment out to drop Space Wars + refresh: 0s +``` + +Remove/comment a `base/screens/*.yaml` line -> that screen's page, scripts, globals and +game tick are not compiled, and its carousel slot disappears. Nothing else to edit. + +## How it stays decoupled (the contract) + +ESPHome merges package lists (globals/scripts/interval/lvgl.pages/esphome.on_boot all +concatenate), so each screen file is self-contained and the core never names a screen's +symbols. The glue is a few plain globals in the core: + +- `g_base` (int) - id of the current carousel screen. Fixed ids: 0=clock, 1=player, + 2=timer, 3=cars, 4=space (an id never changes, even if a screen is absent). +- `g_order[12]`, `g_order_n` - carousel order, built at boot. Core seeds clock+player; + each screen package appends its id in an `esphome.on_boot` step (priority sets the + position). Swipe left/right just steps through `g_order` and wraps. +- `g_nav_req` (bool) + `g_nav_anim` (int: 0=right 1=left 2=top 3=bottom) - core sets + these on a screen change; each screen package owns a small handler that, when + `g_base == `, runs its own `lvgl.page.show` with the matching animation and + clears `g_nav_req`. Core only `page.show`s its own (clock/player). +- `g_knob_capture` (bool) + `g_knob_delta` (int) - when a screen wants the knob (game + running, timer idle), it sets `g_knob_capture=true`; the core knob handler then just + accumulates +/-1 into `g_knob_delta` instead of changing volume. The screen reads and + zeroes `g_knob_delta` in its own tick. Core never names a game variable. +- Per-screen **settings registry** (`g_wgrp_*` + `g_wopt_*`) - a screen declares its own + Settings -> Widgets group and options in `on_boot`: append a group name to + `g_wgrp_labels`, then append each option (label + kind 0=toggle / 1=action + a `bool*` + to its own global) to `g_wopt_*`. The core renders them generically (modes 30 options / + 31 confirm). The option globals live in the screen package, not the core. +- `g_factory_reset` (bool, persists a reboot) - Settings -> System -> Factory reset sets + it; each game package clears its own score globals when it sees the flag in its tick. + +So a screen package contributes: its `globals`, its `script`s, its game-tick `interval`, +its `lvgl.pages` entry, an `on_boot` step to register its carousel id (and any Settings +group/options), and one nav handler. It reads/writes only the shared core globals above. + +## Status + +- [x] core refactor: data-driven carousel (`g_order`), nav via `g_nav_req`, knob via + `g_knob_capture`/`g_knob_delta`. +- [x] extract Space Wars -> `base/screens/space-wars.yaml` +- [x] extract Cool Cars -> `base/screens/cool-cars.yaml` +- [x] extract Player -> `base/screens/player.yaml` (volume + overlay stay in core) +- [x] timer carousel-screen toggle -> `base/screens/timer.yaml` (voice timers/alarm/badge stay in core) +- [x] new Weather screen -> `base/screens/weather.yaml` (radial 7-day dial, knob highlights the day; + forecast from the HA helper `base/screens/weather.ha-helper.yaml`) +- [x] new Thermostat screen -> `base/screens/thermostat.yaml` (climate.* dial; knob sets the target with a + debounced commit, tap toggles on/off, accent colour by heating/cooling/idle) +- [x] settings ownership: home-screen widget toggles moved to a "Home" submenu (core); "Display" + keeps only global brightness/night/screen-off +- [x] carousel order via the `screen_order` substitution; screens register `g_present`, + absent ones are skipped (clock kept as a safety fallback) +- [x] self-contained screen settings: each screen declares its Widgets group + options + (toggles/actions) and owns the backing globals; the core renders them generically + (`g_wgrp_*` / `g_wopt_*`, modes 30/31). Game scores + reset live in the game packages. +- [x] verified on device (compiles; screens toggle from `files:`, order from `screen_order`, + settings list adapts) +- [x] merged to `main` (stable); `beta` is the active dev branch + +## Adding a new screen (recipe) + +A screen package adds: its `globals` / `script`s / game-tick `interval` / `lvgl.pages` +entry; an `esphome.on_boot` step that sets `id(g_present)[] = true`; a 50ms nav +handler that shows its page when `g_nav_req && g_base == `. To give it a carousel +slot, add its name to the core `name -> id` map in the order builder and to `screen_order`. + +To add Settings, register a Widgets group + options in `on_boot` (see `demo.yaml` / +`weather.yaml` as templates): claim `int g = id(g_wgrp_n)++;`, append the group name to +`id(g_wgrp_labels)`, then for each option append to `g_wopt_*` (group `g`, a label, kind +0=toggle / 1=action, and `&id(your_global)`). Declare those globals in your own file. diff --git a/base/screens/cool-cars.yaml b/base/screens/cool-cars.yaml new file mode 100644 index 0000000..ad0f7c1 --- /dev/null +++ b/base/screens/cool-cars.yaml @@ -0,0 +1,565 @@ +# Cool Cars - optional carousel screen (base id 3). Lane racer. +# Remove it: drop this file from your config's package "files:" list. Nothing else to edit. +# Scores (g_top / g_best) + the "Reset scores" settings action live here - fully self-contained. + +esphome: + on_boot: + - priority: 670 + then: + - lambda: 'id(g_present)[3] = true;' # register after clock/player/timer + # Settings (Settings -> Widgets -> Cool Cars -> Reset scores) + factory-reset handling. + - priority: 650 + then: + - lambda: |- + int g = id(g_wgrp_n)++; + id(g_wgrp_labels) += (g ? "\n" : ""); id(g_wgrp_labels) += "Cool Cars"; + int o = id(g_wopt_n)++; + id(g_wopt_group)[o] = g; id(g_wopt_label)[o] = "Reset scores"; id(g_wopt_kind)[o] = 1; id(g_wopt_ptr)[o] = &id(g_cars_reset); + +globals: + # --- scores (persistent) + reset flag, owned by this package --- + - id: g_best + type: int + restore_value: yes + initial_value: '0' + - id: g_top + type: int[10] + restore_value: yes + - id: g_cars_reset # Widgets "Reset scores" sets this; the watcher below clears the scores + type: bool + restore_value: no + initial_value: 'false' + # --- "Car" game (page_game screen, g_base==3) --- + - id: g_game_state # 0=idle 1=playing 2=game over + type: int + restore_value: no + initial_value: '0' + - id: g_car_lane # car lane 0/1/2 + type: int + restore_value: no + initial_value: '1' + - id: g_score + type: int + restore_value: no + initial_value: '0' + - id: g_gspeed # obstacle speed (px/tick) + type: int + restore_value: no + initial_value: '7' + - id: g_lives # lives (3) - game v2 + type: int + restore_value: no + initial_value: '3' + - id: g_inv # invulnerability frames after a hit (anti double-hit) + type: int + restore_value: no + initial_value: '0' + - id: g_o1_type # car obstacle: 0=cop 1=muscle 2=sedan + type: int + restore_value: no + initial_value: '0' + - id: g_o1_lane + type: int + restore_value: no + initial_value: '0' + - id: g_o1_x # X position (horizontal; they move left) + type: int + restore_value: no + initial_value: '200' + - id: g_o2_type # obstacle: 0=cop 1=muscle 2=sedan 3=cone (all = crash) + type: int + restore_value: no + initial_value: '0' + - id: g_o2_lane + type: int + restore_value: no + initial_value: '2' + - id: g_o2_x + type: int + restore_value: no + initial_value: '320' + - id: g_o3_type # 3rd obstacle (more = harder) + type: int + restore_value: no + initial_value: '1' + - id: g_o3_lane + type: int + restore_value: no + initial_value: '1' + - id: g_o3_x + type: int + restore_value: no + initial_value: '460' + - id: g_cn_lane # coin + type: int + restore_value: no + initial_value: '1' + - id: g_cn_x + type: int + restore_value: no + initial_value: '440' + - id: g_cn_on + type: bool + restore_value: no + initial_value: 'true' + - id: g_dash_off # offset of the dashed lane lines (0..359) + type: int + restore_value: no + initial_value: '0' + +script: + # --- "Car" game --- + - id: car_render # car lane (Y) + score + lives (hearts) + message per state + then: + - lvgl.widget.update: { id: car, y: !lambda 'return (id(g_car_lane) - 1) * 55;' } + - lvgl.label.update: + id: lbl_game_score + text: !lambda 'char b[10]; snprintf(b, sizeof(b), "%d", id(g_score)); return std::string(b);' + - lvgl.label.update: + id: lbl_lives + text: !lambda |- + std::string h; for (int i = 0; i < id(g_lives); i++) h += "󰋑"; return h; + # Shows the correct game screen per state (0=menu 1=playing 2=game over 3=how-to 4=scores) + - id: game_screen + then: + # knob steers only during play + game over -> capture it there (block volume), free it in menu/how-to/scores + - lambda: 'id(g_knob_capture) = (id(g_game_state) == 1 || id(g_game_state) == 2);' + # 1) hide everything + - lvgl.widget.hide: road_bg + - lvgl.widget.hide: logo_bg + - lvgl.widget.hide: car + - lvgl.widget.hide: obs1 + - lvgl.widget.hide: obs2 + - lvgl.widget.hide: obs3 + - lvgl.widget.hide: coin + - lvgl.widget.hide: dl0 + - lvgl.widget.hide: dl1 + - lvgl.widget.hide: db0 + - lvgl.widget.hide: db1 + - lvgl.widget.hide: lbl_lives + - lvgl.widget.hide: lbl_game_score + - lvgl.widget.hide: hit_flash + - lvgl.widget.hide: btn_start + - lvgl.widget.hide: btn_howto + - lvgl.widget.hide: btn_scores + - lvgl.widget.hide: lbl_over + - lvgl.widget.hide: lbl_rec + - lvgl.widget.hide: btn_replay + - lvgl.widget.hide: btn_exit + - lvgl.widget.hide: lbl_howto_t + - lvgl.widget.hide: lbl_howto1 + - lvgl.widget.hide: lbl_howto2 + - lvgl.widget.hide: lbl_howto_b + - lvgl.widget.hide: img_howcoin + - lvgl.widget.hide: lbl_howto_l + - lvgl.widget.hide: lbl_howheart + - lvgl.widget.hide: badge1 + - lvgl.widget.hide: badge2 + - lvgl.widget.hide: badge3 + - lvgl.widget.hide: badge4 + - lvgl.widget.hide: lbl_scores_t + - lvgl.widget.hide: lbl_scores1 + - lvgl.widget.hide: lbl_scores2 + - lvgl.widget.hide: btn_back + # 2) show per state + - if: # MENU + condition: { lambda: 'return id(g_game_state) == 0;' } + then: + - lvgl.widget.show: logo_bg + - lvgl.widget.show: btn_start + - lvgl.widget.show: btn_howto + - lvgl.widget.show: btn_scores + - if: # GAME + condition: { lambda: 'return id(g_game_state) == 1;' } + then: + - lvgl.widget.show: road_bg + - lvgl.widget.show: car + - lvgl.widget.show: obs1 + - lvgl.widget.show: obs2 + - lvgl.widget.show: obs3 + - lvgl.widget.show: coin + - lvgl.widget.show: dl0 + - lvgl.widget.show: dl1 + - lvgl.widget.show: db0 + - lvgl.widget.show: db1 + - lvgl.widget.show: lbl_lives + - lvgl.widget.show: lbl_game_score + - if: # GAME OVER + condition: { lambda: 'return id(g_game_state) == 2;' } + then: + - lvgl.widget.show: logo_bg + - lvgl.widget.show: lbl_over + - lvgl.widget.show: lbl_rec + - lvgl.widget.show: btn_replay + - lvgl.widget.show: btn_exit + - lvgl.label.update: { id: lbl_over, text: !lambda 'char b[24]; snprintf(b,sizeof(b),"Score: %d", id(g_score)); return std::string(b);' } + - lvgl.label.update: { id: lbl_rec, text: !lambda 'char b[24]; snprintf(b,sizeof(b),"Best: %d", id(g_best)); return std::string(b);' } + - if: # HOW TO PLAY + condition: { lambda: 'return id(g_game_state) == 3;' } + then: + - lvgl.widget.show: lbl_howto_t + - lvgl.widget.show: lbl_howto1 + - lvgl.widget.show: lbl_howto2 + - lvgl.widget.show: lbl_howto_b + - lvgl.widget.show: img_howcoin + - lvgl.widget.show: lbl_howto_l + - lvgl.widget.show: lbl_howheart + - lvgl.widget.show: badge1 + - lvgl.widget.show: badge2 + - lvgl.widget.show: badge3 + - lvgl.widget.show: badge4 + - lvgl.widget.show: btn_back + - if: # SCORES (top 10) + condition: { lambda: 'return id(g_game_state) == 4;' } + then: + - lvgl.widget.show: lbl_scores_t + - lvgl.widget.show: lbl_scores1 + - lvgl.widget.show: lbl_scores2 + - lvgl.widget.show: btn_back + - lvgl.label.update: + id: lbl_scores1 + text: !lambda |- + std::string s; char b[24]; + for (int i = 0; i < 5; i++) { if(i) s += "\n"; if(id(g_top)[i]>0){ snprintf(b,sizeof(b),"%d. %d",i+1,id(g_top)[i]); } else { snprintf(b,sizeof(b),"%d. -",i+1); } s += b; } + return s; + - lvgl.label.update: + id: lbl_scores2 + text: !lambda |- + std::string s; char b[24]; + for (int i = 5; i < 10; i++) { if(i>5) s += "\n"; if(id(g_top)[i]>0){ snprintf(b,sizeof(b),"%d. %d",i+1,id(g_top)[i]); } else { snprintf(b,sizeof(b),"%d. -",i+1); } s += b; } + return s; + - id: score_render # score with "$" (refreshed on change, not every tick) + then: + - lvgl.label.update: + id: lbl_game_score + text: !lambda 'char b[10]; snprintf(b, sizeof(b), "%d", id(g_score)); return std::string(b);' + - id: obs1_spawn # obstacle: 0=cop 1=muscle 2=sedan 3=cone + then: + - lambda: |- + id(g_o1_type) = esp_random() % 4; + id(g_o1_lane) = esp_random() % 3; + id(g_o1_x) = 200 + (int)(esp_random() % 120); + - if: { condition: { lambda: 'return id(g_o1_type)==0;' }, then: [lvgl.image.update: { id: obs1, src: img_cop }] } + - if: { condition: { lambda: 'return id(g_o1_type)==1;' }, then: [lvgl.image.update: { id: obs1, src: img_mus }] } + - if: { condition: { lambda: 'return id(g_o1_type)==2;' }, then: [lvgl.image.update: { id: obs1, src: img_sed }] } + - if: { condition: { lambda: 'return id(g_o1_type)==3;' }, then: [lvgl.image.update: { id: obs1, src: img_cone }] } + - lvgl.widget.update: { id: obs1, x: !lambda 'return id(g_o1_x);', y: !lambda 'return (id(g_o1_lane)-1)*55;' } + - id: obs2_spawn + then: + - lambda: |- + id(g_o2_type) = esp_random() % 4; + id(g_o2_lane) = esp_random() % 3; + id(g_o2_x) = 260 + (int)(esp_random() % 140); + - if: { condition: { lambda: 'return id(g_o2_type)==0;' }, then: [lvgl.image.update: { id: obs2, src: img_cop }] } + - if: { condition: { lambda: 'return id(g_o2_type)==1;' }, then: [lvgl.image.update: { id: obs2, src: img_mus }] } + - if: { condition: { lambda: 'return id(g_o2_type)==2;' }, then: [lvgl.image.update: { id: obs2, src: img_sed }] } + - if: { condition: { lambda: 'return id(g_o2_type)==3;' }, then: [lvgl.image.update: { id: obs2, src: img_cone }] } + - lvgl.widget.update: { id: obs2, x: !lambda 'return id(g_o2_x);', y: !lambda 'return (id(g_o2_lane)-1)*55;' } + - id: obs3_spawn + then: + - lambda: |- + id(g_o3_type) = esp_random() % 4; + id(g_o3_lane) = esp_random() % 3; + id(g_o3_x) = 340 + (int)(esp_random() % 160); + - if: { condition: { lambda: 'return id(g_o3_type)==0;' }, then: [lvgl.image.update: { id: obs3, src: img_cop }] } + - if: { condition: { lambda: 'return id(g_o3_type)==1;' }, then: [lvgl.image.update: { id: obs3, src: img_mus }] } + - if: { condition: { lambda: 'return id(g_o3_type)==2;' }, then: [lvgl.image.update: { id: obs3, src: img_sed }] } + - if: { condition: { lambda: 'return id(g_o3_type)==3;' }, then: [lvgl.image.update: { id: obs3, src: img_cone }] } + - lvgl.widget.update: { id: obs3, x: !lambda 'return id(g_o3_x);', y: !lambda 'return (id(g_o3_lane)-1)*55;' } + - id: coin_spawn + then: + - lambda: |- + id(g_cn_lane) = esp_random() % 3; + id(g_cn_x) = 220 + (int)(esp_random() % 200); + id(g_cn_on) = true; + - lvgl.widget.show: coin + - lvgl.widget.update: { id: coin, x: !lambda 'return id(g_cn_x);', y: !lambda 'return (id(g_cn_lane)-1)*55;' } + - id: game_start + then: + - lambda: |- + id(g_game_state) = 1; id(g_score) = 0; id(g_gspeed) = 7; + id(g_car_lane) = 1; id(g_lives) = 3; id(g_inv) = 0; + id(g_knob_capture) = true; id(g_knob_delta) = 0; + - lvgl.widget.update: { id: car, x: -120 } + - script.execute: obs1_spawn + - script.execute: obs2_spawn + - script.execute: obs3_spawn + - script.execute: coin_spawn + - script.execute: score_render + - script.execute: game_screen + - script.execute: car_render + - id: lose_life # losing a life: red flash + invulnerability (blinking), 0 lives -> game over + then: + - lambda: 'id(g_lives)--; id(g_inv) = 22;' # ~1.1s invulnerability (scaled for 50ms) + - lvgl.widget.show: hit_flash + - if: + condition: { lambda: 'return id(g_lives) <= 0;' } + then: [ script.execute: game_crash ] + else: [ script.execute: car_render ] + - id: game_crash # GAME OVER (0 lives) - save score + insert into top 10 + then: + - lambda: |- + id(g_game_state) = 2; // game_screen keeps the knob captured on game over (no volume) + int s = id(g_score); + if (s > id(g_best)) id(g_best) = s; + for (int i = 0; i < 10; i++) { + if (s > id(g_top)[i]) { + for (int j = 9; j > i; j--) id(g_top)[j] = id(g_top)[j-1]; + id(g_top)[i] = s; + break; + } + } + - script.execute: game_screen + +interval: + # Reset scores when the Widgets action fired (g_cars_reset). Idempotent + cheap. + - interval: 250ms + then: + - if: + condition: { lambda: 'return id(g_cars_reset) || id(g_factory_reset);' } + then: + - lambda: 'for (int i = 0; i < 10; i++) id(g_top)[i] = 0; id(g_best) = 0; id(g_cars_reset) = false;' + # nav: show this screen when it is selected in the carousel + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_nav_req) && id(g_base)==3;' } + then: + - lambda: 'id(g_nav_req) = false; id(g_game_state) = 0;' + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_game, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_game, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_game, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_game, animation: move_bottom, time: 250ms }] } + - script.execute: game_screen + # "Car" game (driving right) - tick only on the game screen during play. + # 50ms (20fps) so motion is smooth; speeds are scaled down to keep the same px/sec pace. + - interval: 50ms + then: + - if: + condition: + lambda: 'return id(g_base) == 3 && id(g_game_state) == 1;' + then: + - if: + condition: { lambda: 'return id(g_knob_capture) && id(g_knob_delta) != 0;' } + then: + - lambda: 'id(g_car_lane) += id(g_knob_delta); if (id(g_car_lane) < 0) id(g_car_lane) = 0; if (id(g_car_lane) > 2) id(g_car_lane) = 2; id(g_knob_delta) = 0;' + - script.execute: car_render + - lambda: |- + id(g_o1_x) -= id(g_gspeed); + id(g_o2_x) -= id(g_gspeed); + id(g_o3_x) -= id(g_gspeed); + id(g_cn_x) -= id(g_gspeed); + if (id(g_inv) > 0) id(g_inv)--; + int sp = 7 + id(g_score) / 11; if (sp > 16) sp = 16; id(g_gspeed) = sp; // ramps up (scaled for 50ms) + id(g_dash_off) = (id(g_dash_off) + id(g_gspeed)) % 360; + # Recycle obstacles past the left edge (+ a point for dodging, score updated only on change) + - if: + condition: { lambda: 'return id(g_o1_x) < -200;' } + then: [ lambda: 'id(g_score)++;', script.execute: obs1_spawn, script.execute: score_render ] + - if: + condition: { lambda: 'return id(g_o2_x) < -200;' } + then: [ lambda: 'id(g_score)++;', script.execute: obs2_spawn, script.execute: score_render ] + - if: + condition: { lambda: 'return id(g_o3_x) < -200;' } + then: [ lambda: 'id(g_score)++;', script.execute: obs3_spawn, script.execute: score_render ] + - if: + condition: { lambda: 'return id(g_cn_x) < -200;' } + then: [ script.execute: coin_spawn ] + # Collisions (cars + cone = lose a life) + - if: + condition: { lambda: 'return id(g_inv) <= 0 && abs(id(g_o1_x) - (-120)) < 30 && id(g_o1_lane) == id(g_car_lane);' } + then: [ script.execute: lose_life ] + - if: + condition: { lambda: 'return id(g_inv) <= 0 && abs(id(g_o2_x) - (-120)) < 30 && id(g_o2_lane) == id(g_car_lane);' } + then: [ script.execute: lose_life ] + - if: + condition: { lambda: 'return id(g_inv) <= 0 && abs(id(g_o3_x) - (-120)) < 30 && id(g_o3_lane) == id(g_car_lane);' } + then: [ script.execute: lose_life ] + # Coin pickup + - if: + condition: { lambda: 'return id(g_cn_on) && abs(id(g_cn_x) - (-120)) < 28 && id(g_cn_lane) == id(g_car_lane);' } + then: + - lambda: 'id(g_score) += 5; id(g_cn_on) = false;' + - lvgl.widget.hide: coin + - script.execute: coin_spawn + - script.execute: score_render + # Hit effect: red flash fades after a moment, car blinks during invulnerability + - if: + condition: { lambda: 'return id(g_inv) > 0;' } + then: + - lvgl.widget.update: { id: car, hidden: !lambda 'return (id(g_inv) % 4) >= 2;' } + - lvgl.widget.update: { id: hit_flash, hidden: !lambda 'return id(g_inv) < 16;' } + # Move obstacles/coin/dashes in ONE lambda (lv_obj_align) instead of 8 widget.update + # actions - far less per-tick overhead (the pattern that keeps Snake/Knobuss smooth). + - lambda: |- + lv_obj_align(id(obs1), LV_ALIGN_CENTER, id(g_o1_x), (id(g_o1_lane) - 1) * 55); + lv_obj_align(id(obs2), LV_ALIGN_CENTER, id(g_o2_x), (id(g_o2_lane) - 1) * 55); + lv_obj_align(id(obs3), LV_ALIGN_CENTER, id(g_o3_x), (id(g_o3_lane) - 1) * 55); + lv_obj_align(id(coin), LV_ALIGN_CENTER, id(g_cn_x), (id(g_cn_lane) - 1) * 55); + int d0 = ((0 - id(g_dash_off)) % 360 + 360) % 360 - 180; + int d1 = ((180 - id(g_dash_off)) % 360 + 360) % 360 - 180; + lv_obj_align(id(dl0), LV_ALIGN_CENTER, d0, -28); + lv_obj_align(id(dl1), LV_ALIGN_CENTER, d1, -28); + lv_obj_align(id(db0), LV_ALIGN_CENTER, d0, 27); + lv_obj_align(id(db1), LV_ALIGN_CENTER, d1, 27); + +image: + - id: img_road2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/cool-cars/road2.png" + type: RGB565 + - id: img_p + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/cool-cars/pcar2.png" + type: RGB565 + transparency: alpha_channel + - id: img_cop + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/cool-cars/ocop2.png" + type: RGB565 + transparency: alpha_channel + - id: img_mus + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/cool-cars/omus2.png" + type: RGB565 + transparency: alpha_channel + - id: img_sed + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/cool-cars/osed2.png" + type: RGB565 + transparency: alpha_channel + - id: img_coin + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/cool-cars/coin.png" + type: RGB565 + transparency: alpha_channel + - id: img_cone + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/cool-cars/cone.png" + type: RGB565 + transparency: alpha_channel + - id: img_logo + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/cool-cars/logo.png" + type: RGB565 + +lvgl: + pages: + # --- "Car" GAME page (after the player): 3 lanes, car at the bottom, obstacles from the top --- + - id: page_game + bg_color: 0x0D1323 + scrollbar_mode: "off" + scrollable: false + widgets: + - image: { id: road_bg, align: CENTER, x: 0, y: 0, src: img_road2, hidden: true } # road (only in-game) + # Dashed lane lines - moving (scrolled in the interval = sense of driving) + - obj: { id: dl0, align: CENTER, x: 0, y: -28, width: 26, height: 4, radius: 0, border_width: 0, bg_color: 0xECD25A, hidden: true } + - obj: { id: dl1, align: CENTER, x: 0, y: -28, width: 26, height: 4, radius: 0, border_width: 0, bg_color: 0xECD25A, hidden: true } + - obj: { id: db0, align: CENTER, x: 0, y: 27, width: 26, height: 4, radius: 0, border_width: 0, bg_color: 0xECD25A, hidden: true } + - obj: { id: db1, align: CENTER, x: 0, y: 27, width: 26, height: 4, radius: 0, border_width: 0, bg_color: 0xECD25A, hidden: true } + - image: { id: coin, align: CENTER, x: 440, y: 0, src: img_coin, hidden: true } + - image: { id: obs1, align: CENTER, x: 200, y: -55, src: img_cop, hidden: true } + - image: { id: obs2, align: CENTER, x: 320, y: 55, src: img_mus, hidden: true } + - image: { id: obs3, align: CENTER, x: 460, y: 0, src: img_sed, hidden: true } + - image: { id: car, align: CENTER, x: -120, y: 0, src: img_p, hidden: true } + - label: { id: lbl_lives, align: CENTER, x: -70, y: -108, text: "", text_font: icons18, text_color: 0xE24B4A, hidden: true } + - label: { id: lbl_game_score, align: CENTER, x: 70, y: -108, text: "0", text_font: font_med, text_color: 0xCFEAE4, hidden: true } + - obj: { id: hit_flash, align: CENTER, x: 0, y: 0, width: 360, height: 360, radius: 0, border_width: 0, bg_color: 0xFF2A2A, bg_opa: 45%, hidden: true } + # --- Logo background (menu) --- + - image: { id: logo_bg, align: CENTER, x: 0, y: 0, src: img_logo, hidden: true } + # --- MENU (state 0): Start / How to play? / Scores (under the logo) --- + - button: + id: btn_start + align: CENTER + y: -8 + width: 150 + height: 44 + radius: 22 + bg_color: 0x2FE6CF + bg_opa: COVER + border_width: 0 + on_click: [ script.execute: game_start ] + widgets: + - label: { align: CENTER, text: "Start", text_font: font_med, text_color: 0x06201C } + - button: + id: btn_howto + align: CENTER + y: 42 + width: 150 + height: 44 + radius: 22 + bg_color: 0x2A2E55 + bg_opa: COVER + border_width: 0 + on_click: [ lambda: 'id(g_game_state) = 3;', script.execute: game_screen ] + widgets: + - label: { align: CENTER, text: "How to play?", text_font: font_med, text_color: 0xFFFFFF } + - button: + id: btn_scores + align: CENTER + y: 92 + width: 150 + height: 44 + radius: 22 + bg_color: 0x2A2E55 + bg_opa: COVER + border_width: 0 + on_click: [ lambda: 'id(g_game_state) = 4;', script.execute: game_screen ] + widgets: + - label: { align: CENTER, text: "Scores", text_font: font_med, text_color: 0xFFFFFF } + # --- GAME OVER (state 2): score + Play + Exit --- + - label: { id: lbl_over, align: CENTER, x: 0, y: -10, text_align: CENTER, text: "", text_font: font_sel, text_color: 0xFFFFFF, hidden: true } + - label: { id: lbl_rec, align: CENTER, x: 0, y: 22, text_align: CENTER, text: "", text_font: font_artist, text_color: 0x9AA6B2, hidden: true } + - button: + id: btn_replay + align: CENTER + x: -66 + y: 62 + width: 112 + height: 48 + radius: 24 + bg_color: 0x2FE6CF + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ script.execute: game_start ] + widgets: + - label: { align: CENTER, text: "Play", text_font: font_med, text_color: 0x06201C } + - button: + id: btn_exit + align: CENTER + x: 66 + y: 62 + width: 112 + height: 48 + radius: 24 + bg_color: 0x3A3A3D + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ lambda: 'id(g_game_state) = 0;', script.execute: game_screen ] + widgets: + - label: { align: CENTER, text: "Exit", text_font: font_med, text_color: 0xFFFFFF } + # --- HOW TO PLAY? (state 3) - navy background, logo accents --- + - label: { id: lbl_howto_t, align: CENTER, y: -104, text: "HOW TO PLAY?", text_font: font_sel, text_color: 0xE94FB0, hidden: true } + - obj: { id: badge1, align: CENTER, x: -90, y: -46, width: 24, height: 24, radius: 12, bg_color: 0xE94FB0, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "1", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: badge2, align: CENTER, x: -90, y: -10, width: 24, height: 24, radius: 12, bg_color: 0xE94FB0, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "2", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: badge3, align: CENTER, x: -90, y: 26, width: 24, height: 24, radius: 12, bg_color: 0xE94FB0, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "3", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: badge4, align: CENTER, x: -90, y: 62, width: 24, height: 24, radius: 12, bg_color: 0xE94FB0, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "4", text_font: font_artist, text_color: 0xFFFFFF } ] } + - label: { id: lbl_howto1, align_to: { id: badge1, align: OUT_RIGHT_MID, x: 8 }, text: "Use the knob", text_font: font_med, text_color: 0xC2CDDA, hidden: true } + - label: { id: lbl_howto2, align_to: { id: badge2, align: OUT_RIGHT_MID, x: 8 }, text: "Dodge obstacles", text_font: font_med, text_color: 0xC2CDDA, hidden: true } + - label: { id: lbl_howto_b, align_to: { id: badge3, align: OUT_RIGHT_MID, x: 8 }, text: "Collect bonuses", text_font: font_med, text_color: 0xC2CDDA, hidden: true } + - image: { id: img_howcoin, align_to: { id: lbl_howto_b, align: OUT_RIGHT_MID, x: 6 }, src: img_coin, hidden: true } + - label: { id: lbl_howto_l, align_to: { id: badge4, align: OUT_RIGHT_MID, x: 8 }, text: "You have 3 lives", text_font: font_med, text_color: 0xC2CDDA, hidden: true } + - label: { id: lbl_howheart, align_to: { id: lbl_howto_l, align: OUT_RIGHT_MID, x: 6 }, text: "󰋑󰋑󰋑", text_font: icons18, text_color: 0xE24B4A, hidden: true } + # --- SCORES (state 4): top 10 in two columns --- + - label: { id: lbl_scores_t, align: CENTER, y: -116, text: "SCORES", text_font: font_sel, text_color: 0xE94FB0, hidden: true } + - label: { id: lbl_scores1, align: CENTER, x: -52, y: 0, text: "", text_align: LEFT, text_font: font_artist, text_color: 0xC2CDDA, hidden: true } + - label: { id: lbl_scores2, align: CENTER, x: 56, y: 0, text: "", text_align: LEFT, text_font: font_artist, text_color: 0xC2CDDA, hidden: true } + # --- Back (state 3/4 -> menu) --- + - button: + id: btn_back + align: CENTER + y: 120 + width: 130 + height: 42 + radius: 21 + bg_color: 0x3A3A3D + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ lambda: 'id(g_game_state) = 0;', script.execute: game_screen ] + widgets: + - label: { align: CENTER, text: "Back", text_font: font_med, text_color: 0xFFFFFF } diff --git a/base/screens/demo.yaml b/base/screens/demo.yaml new file mode 100644 index 0000000..c9feea7 --- /dev/null +++ b/base/screens/demo.yaml @@ -0,0 +1,147 @@ +# ============================================================================ +# DEMO / EXAMPLE SCREEN (base id 6, carousel name "demo") +# ---------------------------------------------------------------------------- +# A minimal, heavily-commented screen that shows how a screen package is built: +# * it registers itself in the carousel (g_present), +# * it shows its own LVGL page, +# * it reacts to a tap (toggles the background black <-> white, text inverts), +# * it declares its OWN settings toggles (Settings -> Widgets -> Demo): +# g_demo_border -> draw a border +# g_demo_led -> drive the LED ring (green on white bg, red on black bg) +# The globals live in THIS file and are registered into the core's generic settings +# registry (g_wgrp_* / g_wopt_*) in on_boot - see how below as the template. +# * it drives the LED ring directly (a simple demo - it bypasses the ring +# reaction-priority system in the core; that is fine for an example). +# +# Everything a screen needs lives in THIS file (globals/scripts/interval/lvgl page + +# its settings registration). Remove the screen by dropping this file from the package +# "files:" list - nothing else to edit, anywhere. +# ============================================================================ + +esphome: + on_boot: + # Register this screen so the carousel knows it exists. The core builds the + # carousel order from `screen_order`, including only screens that set g_present. + - priority: 655 + then: + - lambda: 'id(g_present)[6] = true;' + # Register this screen's settings (Settings -> Widgets -> Demo) in the generic registry. + - priority: 650 + then: + - lambda: |- + int g = id(g_wgrp_n)++; + id(g_wgrp_labels) += (g ? "\n" : ""); id(g_wgrp_labels) += "Demo"; + int o; + o = id(g_wopt_n)++; id(g_wopt_group)[o] = g; id(g_wopt_label)[o] = "Border"; id(g_wopt_kind)[o] = 0; id(g_wopt_ptr)[o] = &id(g_demo_border); + o = id(g_wopt_n)++; id(g_wopt_group)[o] = g; id(g_wopt_label)[o] = "Demo LED"; id(g_wopt_kind)[o] = 0; id(g_wopt_ptr)[o] = &id(g_demo_led); + +globals: + - id: g_demo_bg # background: false = black, true = white (runtime state) + type: bool + restore_value: no + initial_value: 'false' + # Demo settings (declared here, registered above; the core menu reaches them by pointer). + - id: g_demo_border # draw a border (Settings -> Widgets -> Demo) + type: bool + restore_value: yes + initial_value: 'false' + - id: g_demo_led # drive the LED ring (Settings -> Widgets -> Demo) + type: bool + restore_value: yes + initial_value: 'false' + # Tap de-duplication: the core bumps g_tap_seq on every normal-mode tap; we + # remember the last value we handled so one tap = one toggle. + - id: g_demo_lasttap + type: int + restore_value: no + initial_value: '0' + # Tracks whether WE currently own the LED ring, so we can release it on leave. + # Persistent so that after a reboot (the ring component restores its last colour) the + # 80ms loop below sees "we owned it", and since we boot onto the home screen it turns + # the ring back off instead of leaving the demo colour stuck on. + - id: g_demo_ring_on + type: bool + restore_value: yes + initial_value: 'false' + +script: + # Redraws the screen from the three state flags (g_demo_bg / border / led). + - id: demo_render + then: + # background + text colour (text is the inverse of the background) + - if: + condition: { lambda: 'return id(g_demo_bg);' } # white background + then: + - lvgl.widget.update: { id: demo_bg, bg_color: 0xFFFFFF, border_color: 0x000000 } + - lvgl.label.update: { id: demo_lbl, text_color: 0x000000 } + else: # black background + - lvgl.widget.update: { id: demo_bg, bg_color: 0x000000, border_color: 0xFFFFFF } + - lvgl.label.update: { id: demo_lbl, text_color: 0xFFFFFF } + # border on/off (width is an int, so a lambda is fine here) + - lvgl.widget.update: { id: demo_bg, border_width: !lambda 'return id(g_demo_border) ? 6 : 0;' } + # LED ring: green when the background is white, red when it is black + - if: + condition: { lambda: 'return id(g_demo_led);' } + then: + - if: + condition: { lambda: 'return id(g_demo_bg);' } + then: [ light.turn_on: { id: ring_light, red: 0%, green: 100%, blue: 0%, effect: "None" } ] + else: [ light.turn_on: { id: ring_light, red: 100%, green: 0%, blue: 0%, effect: "None" } ] + - lambda: 'id(g_demo_ring_on) = true;' + else: + # LED toggle is off: release the ring if we were driving it + - if: + condition: { lambda: 'return id(g_demo_ring_on);' } + then: + - light.turn_off: ring_light + - lambda: 'id(g_demo_ring_on) = false;' + +interval: + # Carousel navigation: when this screen becomes selected, show its page. + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_nav_req) && id(g_base)==6;' } + then: + - lambda: 'id(g_nav_req) = false; id(g_demo_lasttap) = id(g_tap_seq);' # sync tap counter so arriving never counts as a tap + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_demo, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_demo, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_demo, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_demo, animation: move_bottom, time: 250ms }] } + - script.execute: demo_render + # React to a tap while this screen is on top; release the LED when we leave. + - interval: 80ms + then: + - if: + condition: { lambda: 'return id(g_base)==6 && id(g_tap_seq) != id(g_demo_lasttap);' } + then: + - lambda: 'id(g_demo_lasttap) = id(g_tap_seq); id(g_demo_bg) = !id(g_demo_bg);' # one tap = flip background + - script.execute: demo_render + - if: + condition: { lambda: 'return id(g_base) != 6 && id(g_demo_ring_on);' } + then: + - lambda: 'id(g_demo_ring_on) = false;' + +lvgl: + pages: + - id: page_demo + bg_color: 0x000000 + widgets: + # A full circle that fills the round screen - this is the background we recolour. + # radius 180 on a 360x360 box makes it a circle, so the optional border is a clean ring. + - obj: + id: demo_bg + align: CENTER + width: 360 + height: 360 + radius: 180 + bg_color: 0x000000 + bg_opa: COVER + border_width: 0 + border_color: 0xFFFFFF + - label: + id: demo_lbl + align: CENTER + text: "TAP ME!" + text_font: montserrat_34 + text_color: 0xFFFFFF diff --git a/base/screens/knobuss.yaml b/base/screens/knobuss.yaml new file mode 100644 index 0000000..874b04d --- /dev/null +++ b/base/screens/knobuss.yaml @@ -0,0 +1,711 @@ +# Knobuss - optional carousel screen (base id 10). A Gyruss-style tube shooter: your ship orbits the rim, you +# auto-fire inward, foes spiral out from the centre. Turn the knob to aim, blast them before they +# reach you. Remove it: drop this file from your config's package "files:" list. Nothing else to edit. +# Scores (g_gy_top / g_gy_best) + the "Reset scores" settings action live here - fully self-contained. +# +# Perf: ship + bullet pool (6) + enemy pool (10) are all positioned each tick from float arrays in +# ONE lambda (lv_obj_set_pos over a pooled lv_obj_t* array - LVGL can't index a widget by a runtime +# var), so per-tick cost is constant and the action chain stays shallow. Sprites have a fixed src +# (enemies alternate enemy/enemy2 by slot), so there are no per-tick image swaps. + +esphome: + on_boot: + - priority: 655 + then: + - lambda: 'id(g_present)[10] = true;' + - priority: 650 + then: + - lambda: |- + int g = id(g_wgrp_n)++; + id(g_wgrp_labels) += (g ? "\n" : ""); id(g_wgrp_labels) += "Knobuss"; + int o = id(g_wopt_n)++; + id(g_wopt_group)[o] = g; id(g_wopt_label)[o] = "Reset scores"; id(g_wopt_kind)[o] = 1; id(g_wopt_ptr)[o] = &id(g_gy_reset); + +globals: + - id: g_gy_best + type: int + restore_value: yes + initial_value: '0' + - id: g_gy_top + type: int[10] + restore_value: yes + - id: g_gy_reset + type: bool + restore_value: no + initial_value: 'false' + - id: g_gy_state # 0=menu 1=playing 2=game over 3=how-to 4=scores + type: int + restore_value: no + initial_value: '0' + - id: g_gy_score + type: int + restore_value: no + initial_value: '0' + - id: g_gy_lives + type: int + restore_value: no + initial_value: '3' + - id: g_gy_ship_ang # ship orbit angle (radians) + type: float + restore_value: no + initial_value: '0.0' + - id: g_gy_dead + type: bool + restore_value: no + initial_value: 'false' + - id: g_gy_fire_clk + type: int + restore_value: no + initial_value: '0' + - id: g_gy_spawn_clk + type: int + restore_value: no + initial_value: '0' + - id: g_gy_hud_dirty # score/lives changed this tick -> refresh the HUD labels + type: bool + restore_value: no + initial_value: 'false' + # bullet pool (fly inward along a fixed radial) + - id: g_gy_br # radius (px from centre) + type: float[6] + restore_value: no + - id: g_gy_ba # angle (radians) + type: float[6] + restore_value: no + - id: g_gy_bon + type: bool[6] + restore_value: no + # enemy pool (spiral outward from the centre) + - id: g_gy_er + type: float[10] + restore_value: no + - id: g_gy_ea + type: float[10] + restore_value: no + - id: g_gy_eon + type: bool[10] + restore_value: no + # starfield (depth illusion): dots rush outward from the centre, faster as they near the rim + - id: g_gy_sr # radius (px from centre) + type: float[10] + restore_value: no + - id: g_gy_sa # angle (radians) + type: float[10] + restore_value: no + # explosion pool (expanding shockwave ring of dots) - fired when a foe reaches the rim + - id: g_gy_exx # centre X (relative to screen centre, px) + type: float[2] + restore_value: no + - id: g_gy_exy # centre Y + type: float[2] + restore_value: no + - id: g_gy_exage # -1 = inactive, else explosion frame 0..7 + type: int[2] + restore_value: no + - id: g_gy_shipframe # current ship rotation frame (0..15), -1 forces a refresh + type: int + restore_value: no + initial_value: '-1' + - id: g_gy_shipdirty # ship rotation bucket changed this tick + type: bool + restore_value: no + initial_value: 'false' + - id: g_gy_exact # an explosion is active or expired this tick (refresh its sprite) + type: bool + restore_value: no + initial_value: 'false' + - id: g_gy_tick # frame counter (throttles the starfield + ship-sprite refresh) + type: int + restore_value: no + initial_value: '0' + +script: + # position every active widget from the float arrays (constant cost, one shallow lambda) + - id: gy_render + then: + - lambda: |- + const float R = 148.0f; + // starfield (background depth) - small dots rushing outward + lv_obj_t *SF[6] = { id(gy_s0), id(gy_s1), id(gy_s2), id(gy_s3), id(gy_s4), id(gy_s5) }; + for (int k = 0; k < 6; k++) { + float r = id(g_gy_sr)[k], a = id(g_gy_sa)[k]; + lv_obj_set_pos(SF[k], 180 + (int) lroundf(cosf(a) * r) - 1, 180 + (int) lroundf(sinf(a) * r) - 1); + } + lv_obj_t *BB[6] = { id(gy_b0), id(gy_b1), id(gy_b2), id(gy_b3), id(gy_b4), id(gy_b5) }; + for (int k = 0; k < 6; k++) { + if (id(g_gy_bon)[k]) { + float r = id(g_gy_br)[k], a = id(g_gy_ba)[k]; + lv_obj_set_pos(BB[k], 180 + (int) lroundf(cosf(a) * r) - 6, 180 + (int) lroundf(sinf(a) * r) - 6); + } else lv_obj_set_pos(BB[k], 400, 400); + } + lv_obj_t *EE[10] = { id(gy_e0), id(gy_e1), id(gy_e2), id(gy_e3), id(gy_e4), id(gy_e5), id(gy_e6), id(gy_e7), id(gy_e8), id(gy_e9) }; + for (int k = 0; k < 10; k++) { + if (id(g_gy_eon)[k]) { + float r = id(g_gy_er)[k], a = id(g_gy_ea)[k]; + lv_obj_set_pos(EE[k], 180 + (int) lroundf(cosf(a) * r) - 13, 180 + (int) lroundf(sinf(a) * r) - 13); + } else lv_obj_set_pos(EE[k], 400, 400); + } + float sa = id(g_gy_ship_ang); + lv_obj_set_pos(id(gy_ship), 180 + (int) lroundf(cosf(sa) * R) - 18, 180 + (int) lroundf(sinf(sa) * R) - 18); + // explosions (32px sprites) - positioned while alive, parked otherwise + lv_obj_t *XX[2] = { id(gy_x0), id(gy_x1) }; + for (int s = 0; s < 2; s++) { + if (id(g_gy_exage)[s] >= 0) lv_obj_set_pos(XX[s], 180 + (int) lroundf(id(g_gy_exx)[s]) - 16, 180 + (int) lroundf(id(g_gy_exy)[s]) - 16); + else lv_obj_set_pos(XX[s], 400, 400); + } + # set the ship sprite to its current rotation frame (called only when the bucket changes) + - id: gy_shipspr + then: + - if: { condition: { lambda: 'return id(g_gy_shipframe)==0;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship0 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==1;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship1 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==2;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship2 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==3;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship3 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==4;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship4 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==5;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship5 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==6;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship6 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==7;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship7 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==8;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship8 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==9;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship9 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==10;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship10 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==11;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship11 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==12;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship12 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==13;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship13 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==14;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship14 } ] } + - if: { condition: { lambda: 'return id(g_gy_shipframe)==15;' }, then: [ lvgl.image.update: { id: gy_ship, src: img_gy_ship15 } ] } + # advance/hide the explosion sprites by their frame (called only while a burst is live) + - id: gy_explspr + then: + - if: + condition: { lambda: 'return id(g_gy_exage)[0] < 0;' } + then: [ lvgl.widget.hide: gy_x0 ] + else: + - lvgl.widget.show: gy_x0 + - if: { condition: { lambda: 'return id(g_gy_exage)[0]==0;' }, then: [ lvgl.image.update: { id: gy_x0, src: img_gy_expl0 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[0]==1;' }, then: [ lvgl.image.update: { id: gy_x0, src: img_gy_expl1 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[0]==2;' }, then: [ lvgl.image.update: { id: gy_x0, src: img_gy_expl2 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[0]==3;' }, then: [ lvgl.image.update: { id: gy_x0, src: img_gy_expl3 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[0]==4;' }, then: [ lvgl.image.update: { id: gy_x0, src: img_gy_expl4 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[0]==5;' }, then: [ lvgl.image.update: { id: gy_x0, src: img_gy_expl5 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[0]==6;' }, then: [ lvgl.image.update: { id: gy_x0, src: img_gy_expl6 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[0]>=7;' }, then: [ lvgl.image.update: { id: gy_x0, src: img_gy_expl7 } ] } + - if: + condition: { lambda: 'return id(g_gy_exage)[1] < 0;' } + then: [ lvgl.widget.hide: gy_x1 ] + else: + - lvgl.widget.show: gy_x1 + - if: { condition: { lambda: 'return id(g_gy_exage)[1]==0;' }, then: [ lvgl.image.update: { id: gy_x1, src: img_gy_expl0 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[1]==1;' }, then: [ lvgl.image.update: { id: gy_x1, src: img_gy_expl1 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[1]==2;' }, then: [ lvgl.image.update: { id: gy_x1, src: img_gy_expl2 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[1]==3;' }, then: [ lvgl.image.update: { id: gy_x1, src: img_gy_expl3 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[1]==4;' }, then: [ lvgl.image.update: { id: gy_x1, src: img_gy_expl4 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[1]==5;' }, then: [ lvgl.image.update: { id: gy_x1, src: img_gy_expl5 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[1]==6;' }, then: [ lvgl.image.update: { id: gy_x1, src: img_gy_expl6 } ] } + - if: { condition: { lambda: 'return id(g_gy_exage)[1]>=7;' }, then: [ lvgl.image.update: { id: gy_x1, src: img_gy_expl7 } ] } + - id: gy_hud + then: + - lvgl.label.update: { id: lbl_gy_score, text: !lambda 'char b[12]; snprintf(b,sizeof(b),"%d",id(g_gy_score)); return std::string(b);' } + - lvgl.label.update: { id: lbl_gy_lives, text: !lambda 'char b[16]; snprintf(b,sizeof(b),"LIVES %d",id(g_gy_lives)<0?0:id(g_gy_lives)); return std::string(b);' } + # park every gameplay widget off-screen + - id: gy_clear + then: + - lambda: |- + lv_obj_t *BB[6] = { id(gy_b0), id(gy_b1), id(gy_b2), id(gy_b3), id(gy_b4), id(gy_b5) }; + for (int k = 0; k < 6; k++) lv_obj_set_pos(BB[k], 400, 400); + lv_obj_t *EE[10] = { id(gy_e0), id(gy_e1), id(gy_e2), id(gy_e3), id(gy_e4), id(gy_e5), id(gy_e6), id(gy_e7), id(gy_e8), id(gy_e9) }; + for (int k = 0; k < 10; k++) lv_obj_set_pos(EE[k], 400, 400); + lv_obj_t *SF[6] = { id(gy_s0), id(gy_s1), id(gy_s2), id(gy_s3), id(gy_s4), id(gy_s5) }; + for (int k = 0; k < 6; k++) lv_obj_set_pos(SF[k], 400, 400); + lv_obj_set_pos(id(gy_ship), 400, 400); + - lvgl.widget.hide: gy_x0 + - lvgl.widget.hide: gy_x1 + - id: gy_start + then: + - lambda: |- + id(g_gy_state) = 1; id(g_gy_score) = 0; id(g_gy_lives) = 3; id(g_gy_dead) = false; + id(g_gy_ship_ang) = -1.5708f; // start at the top + id(g_gy_fire_clk) = 0; id(g_gy_spawn_clk) = 0; + for (int k = 0; k < 6; k++) id(g_gy_bon)[k] = false; + for (int k = 0; k < 10; k++) id(g_gy_eon)[k] = false; + for (int k = 0; k < 6; k++) { id(g_gy_sr)[k] = 6.0f + (esp_random() % 168); id(g_gy_sa)[k] = (esp_random() % 62832) / 10000.0f; } + for (int s = 0; s < 2; s++) id(g_gy_exage)[s] = -1; + id(g_gy_shipframe) = -1; // force a ship-sprite refresh on the first tick + id(g_knob_capture) = true; id(g_knob_delta) = 0; + - script.execute: gy_screen + - script.execute: gy_hud + - id: gy_over + then: + - lambda: |- + id(g_gy_state) = 2; + int s = id(g_gy_score); + if (s > id(g_gy_best)) id(g_gy_best) = s; + for (int i = 0; i < 10; i++) { + if (s > id(g_gy_top)[i]) { + for (int j = 9; j > i; j--) id(g_gy_top)[j] = id(g_gy_top)[j-1]; + id(g_gy_top)[i] = s; + break; + } + } + - script.execute: gy_screen + # screen state + the single knob-capture authority (captured only in play + game over) + - id: gy_screen + then: + - lambda: 'id(g_knob_capture) = (id(g_gy_state) == 1 || id(g_gy_state) == 2);' + - script.execute: gy_clear + - lvgl.widget.hide: gy_logo + - lvgl.widget.hide: gy_ring + - lvgl.widget.hide: gy_ring2 + - lvgl.widget.hide: gy_ring3 + - lvgl.widget.hide: gy_core + - lvgl.widget.hide: lbl_gy_score + - lvgl.widget.hide: lbl_gy_lives + - lvgl.widget.hide: lbl_gy_title + - lvgl.widget.hide: lbl_gy_sub + - lvgl.widget.hide: btn_gy_start + - lvgl.widget.hide: btn_gy_howto + - lvgl.widget.hide: btn_gy_scores + - lvgl.widget.hide: lbl_gy_over + - lvgl.widget.hide: lbl_gy_rec + - lvgl.widget.hide: btn_gy_replay + - lvgl.widget.hide: btn_gy_exit + - lvgl.widget.hide: lbl_gy_howto_t + - lvgl.widget.hide: gy_hb1 + - lvgl.widget.hide: gy_hb2 + - lvgl.widget.hide: gy_hb3 + - lvgl.widget.hide: gy_hb4 + - lvgl.widget.hide: lbl_gy_howto1 + - lvgl.widget.hide: lbl_gy_howto2 + - lvgl.widget.hide: lbl_gy_howto3 + - lvgl.widget.hide: lbl_gy_howto4 + - lvgl.widget.hide: gy_life_icon + - lvgl.widget.hide: lbl_gy_scores_t + - lvgl.widget.hide: lbl_gy_scores1 + - lvgl.widget.hide: lbl_gy_scores2 + - lvgl.widget.hide: btn_gy_back + - if: # MENU + condition: { lambda: 'return id(g_gy_state) == 0;' } + then: + - lvgl.widget.show: gy_logo + - lvgl.widget.show: btn_gy_start + - lvgl.widget.show: btn_gy_howto + - lvgl.widget.show: btn_gy_scores + - if: # PLAY + condition: { lambda: 'return id(g_gy_state) == 1;' } + then: + - lvgl.widget.show: gy_ring + - lvgl.widget.show: gy_ring2 + - lvgl.widget.show: gy_ring3 + - lvgl.widget.show: gy_core + - lvgl.widget.show: lbl_gy_score + - lvgl.widget.show: lbl_gy_lives + - script.execute: gy_render + - if: # GAME OVER + condition: { lambda: 'return id(g_gy_state) == 2;' } + then: + - lvgl.widget.show: gy_logo + - lvgl.widget.show: lbl_gy_over + - lvgl.widget.show: lbl_gy_rec + - lvgl.widget.show: btn_gy_replay + - lvgl.widget.show: btn_gy_exit + - lvgl.label.update: { id: lbl_gy_over, text: !lambda 'char b[24]; snprintf(b,sizeof(b),"Score: %d", id(g_gy_score)); return std::string(b);' } + - lvgl.label.update: { id: lbl_gy_rec, text: !lambda 'char b[24]; snprintf(b,sizeof(b),"Best: %d", id(g_gy_best)); return std::string(b);' } + - if: # HOW TO PLAY + condition: { lambda: 'return id(g_gy_state) == 3;' } + then: + - lvgl.widget.show: lbl_gy_howto_t + - lvgl.widget.show: gy_hb1 + - lvgl.widget.show: gy_hb2 + - lvgl.widget.show: gy_hb3 + - lvgl.widget.show: gy_hb4 + - lvgl.widget.show: lbl_gy_howto1 + - lvgl.widget.show: lbl_gy_howto2 + - lvgl.widget.show: lbl_gy_howto3 + - lvgl.widget.show: lbl_gy_howto4 + - lvgl.widget.show: gy_life_icon + - lvgl.widget.show: btn_gy_back + - if: # SCORES (top 10) + condition: { lambda: 'return id(g_gy_state) == 4;' } + then: + - lvgl.widget.show: lbl_gy_scores_t + - lvgl.widget.show: lbl_gy_scores1 + - lvgl.widget.show: lbl_gy_scores2 + - lvgl.widget.show: btn_gy_back + - lvgl.label.update: + id: lbl_gy_scores1 + text: !lambda |- + std::string s; char b[24]; + for (int i = 0; i < 5; i++) { if(i) s += "\n"; if(id(g_gy_top)[i]>0){ snprintf(b,sizeof(b),"%d. %d",i+1,id(g_gy_top)[i]); } else { snprintf(b,sizeof(b),"%d. -",i+1); } s += b; } + return s; + - lvgl.label.update: + id: lbl_gy_scores2 + text: !lambda |- + std::string s; char b[24]; + for (int i = 5; i < 10; i++) { if(i>5) s += "\n"; if(id(g_gy_top)[i]>0){ snprintf(b,sizeof(b),"%d. %d",i+1,id(g_gy_top)[i]); } else { snprintf(b,sizeof(b),"%d. -",i+1); } s += b; } + return s; + +interval: + - interval: 250ms + then: + - if: + condition: { lambda: 'return id(g_gy_reset) || id(g_factory_reset);' } + then: + - lambda: 'for (int i = 0; i < 10; i++) id(g_gy_top)[i] = 0; id(g_gy_best) = 0; id(g_gy_reset) = false;' + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_nav_req) && id(g_base)==10;' } + then: + - lambda: 'id(g_nav_req) = false; id(g_gy_state) = 0; id(g_knob_delta) = 0;' + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_gyruss, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_gyruss, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_gyruss, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_gyruss, animation: move_bottom, time: 250ms }] } + - script.execute: gy_screen + # Game tick: aim (knob) + auto-fire + spiral the foes + collisions; all positioning in gy_render. + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_base) == 10 && id(g_gy_state) == 1;' } + then: + - lambda: |- + id(g_gy_hud_dirty) = false; id(g_gy_shipdirty) = false; + id(g_gy_tick)++; + int kd = id(g_knob_delta); id(g_knob_delta) = 0; + id(g_gy_ship_ang) += kd * 0.30f; // aim sensitivity: ~17 deg per knob detent + // auto-fire a bullet down the ship's radial + if (++id(g_gy_fire_clk) >= 5) { + id(g_gy_fire_clk) = 0; + for (int k = 0; k < 6; k++) if (!id(g_gy_bon)[k]) { id(g_gy_bon)[k] = true; id(g_gy_br)[k] = 138.0f; id(g_gy_ba)[k] = id(g_gy_ship_ang); break; } + } + for (int k = 0; k < 6; k++) if (id(g_gy_bon)[k]) { id(g_gy_br)[k] -= 9.0f; if (id(g_gy_br)[k] < 12.0f) id(g_gy_bon)[k] = false; } + // spawn foes from the core (faster as the score climbs) + int sw = 24 - id(g_gy_score) / 200; if (sw < 9) sw = 9; + if (++id(g_gy_spawn_clk) >= sw) { + id(g_gy_spawn_clk) = 0; + for (int k = 0; k < 10; k++) if (!id(g_gy_eon)[k]) { + float a = (esp_random() % 62832) / 10000.0f; + id(g_gy_eon)[k] = true; id(g_gy_er)[k] = 32.0f; id(g_gy_ea)[k] = a; break; // start at the core's rim (not under it) to avoid central overdraw + } + } + // age existing explosions first (8-frame sprite); refresh sprite while any is live/expiring + id(g_gy_exact) = false; + for (int s = 0; s < 2; s++) if (id(g_gy_exage)[s] >= 0) { + id(g_gy_exact) = true; + if (++id(g_gy_exage)[s] > 7) id(g_gy_exage)[s] = -1; + } + // foes spiral outward; ANY foe that reaches the rim costs a life and explodes there + float esp = 1.5f + id(g_gy_score) / 1500.0f; if (esp > 3.2f) esp = 3.2f; + for (int k = 0; k < 10; k++) if (id(g_gy_eon)[k]) { + id(g_gy_er)[k] += esp; id(g_gy_ea)[k] += 0.05f; + if (id(g_gy_er)[k] >= 146.0f) { + id(g_gy_eon)[k] = false; + id(g_gy_lives)--; id(g_gy_hud_dirty) = true; if (id(g_gy_lives) <= 0) id(g_gy_dead) = true; + for (int s = 0; s < 2; s++) if (id(g_gy_exage)[s] < 0) { id(g_gy_exx)[s] = cosf(id(g_gy_ea)[k]) * 146.0f; id(g_gy_exy)[s] = sinf(id(g_gy_ea)[k]) * 146.0f; id(g_gy_exage)[s] = 0; id(g_gy_exact) = true; break; } + } + } + // depth starfield (throttled to every 2nd tick): rush outward, respawn near the centre + if ((id(g_gy_tick) & 1) == 0) for (int k = 0; k < 6; k++) { + id(g_gy_sr)[k] += id(g_gy_sr)[k] * 0.10f + 2.0f; + if (id(g_gy_sr)[k] > 176.0f) { id(g_gy_sr)[k] = 4.0f + (esp_random() % 14); id(g_gy_sa)[k] = (esp_random() % 62832) / 10000.0f; } + } + // ship rotation frame (every 2nd tick, on the ODD ticks so it doesn't share a tick with + // the starfield move above - spreads the two throttled costs; position stays 50ms) + if ((id(g_gy_tick) & 1) == 1) { + int bk = (int) lroundf(id(g_gy_ship_ang) / 0.3926991f); bk %= 16; if (bk < 0) bk += 16; + id(g_gy_shipdirty) = (bk != id(g_gy_shipframe)); + if (id(g_gy_shipdirty)) id(g_gy_shipframe) = bk; + } + // bullet vs foe (polar proximity) + for (int b = 0; b < 6; b++) if (id(g_gy_bon)[b]) for (int e = 0; e < 10; e++) if (id(g_gy_eon)[e]) { + float dr = id(g_gy_br)[b] - id(g_gy_er)[e]; if (dr < 0) dr = -dr; + if (dr < 13.0f) { + float da = id(g_gy_ba)[b] - id(g_gy_ea)[e]; da = atan2f(sinf(da), cosf(da)); + if (fabsf(da) < 0.28f) { id(g_gy_bon)[b] = false; id(g_gy_eon)[e] = false; id(g_gy_score) += 10; id(g_gy_hud_dirty) = true; break; } + } + } + - if: { condition: { lambda: 'return id(g_gy_shipdirty);' }, then: [ script.execute: gy_shipspr ] } + - if: { condition: { lambda: 'return id(g_gy_exact);' }, then: [ script.execute: gy_explspr ] } + - script.execute: gy_render + - if: + condition: { lambda: 'return id(g_gy_dead);' } + then: + - script.execute: gy_over + else: + - if: + condition: { lambda: 'return id(g_gy_hud_dirty);' } + then: + - script.execute: gy_hud + +# Sprites are pulled from the repo /main/ raw URLs at compile time. +# Third-party art (see assets/sprites/knobuss/CREDITS.txt). +image: + - id: img_gy_ship0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship0.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship1.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship2.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship3.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship4 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship4.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship5 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship5.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship6 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship6.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship7 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship7.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship8 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship8.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship9 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship9.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship10 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship10.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship11 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship11.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship12 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship12.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship13 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship13.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship14 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship14.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_ship15 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/ship15.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_enemy0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/enemy0.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_enemy1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/enemy1.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_enemy2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/enemy2.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_enemy3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/enemy3.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_expl0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/expl0.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_expl1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/expl1.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_expl2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/expl2.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_expl3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/expl3.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_expl4 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/expl4.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_expl5 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/expl5.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_expl6 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/expl6.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_expl7 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/expl7.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_bullet + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/bullet.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_core + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/core.png" + type: RGB565 + transparency: alpha_channel + - id: img_gy_logo + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/knobuss-logo.png" + type: RGB565 + - id: img_gy_life + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/knobuss/life.png" + type: RGB565 + transparency: alpha_channel +lvgl: + pages: + - id: page_gyruss + bg_color: 0x070611 + scrollbar_mode: "off" + scrollable: false + widgets: + # Knobuss title screen (full-screen menu backdrop, behind the buttons) - hidden in play + - image: { id: gy_logo, align: CENTER, x: 0, y: 0, src: img_gy_logo, hidden: true } + # starfield (declared first = drawn behind everything) - parked until play + - obj: { id: gy_s0, x: 400, y: 400, width: 3, height: 3, radius: 999, bg_color: 0x6076A4, bg_opa: COVER, border_width: 0, pad_all: 0 } + - obj: { id: gy_s1, x: 400, y: 400, width: 2, height: 2, radius: 999, bg_color: 0x32466E, bg_opa: COVER, border_width: 0, pad_all: 0 } + - obj: { id: gy_s2, x: 400, y: 400, width: 3, height: 3, radius: 999, bg_color: 0x6076A4, bg_opa: COVER, border_width: 0, pad_all: 0 } + - obj: { id: gy_s3, x: 400, y: 400, width: 2, height: 2, radius: 999, bg_color: 0x32466E, bg_opa: COVER, border_width: 0, pad_all: 0 } + - obj: { id: gy_s4, x: 400, y: 400, width: 3, height: 3, radius: 999, bg_color: 0x6076A4, bg_opa: COVER, border_width: 0, pad_all: 0 } + - obj: { id: gy_s5, x: 400, y: 400, width: 2, height: 2, radius: 999, bg_color: 0x32466E, bg_opa: COVER, border_width: 0, pad_all: 0 } + # concentric guide rings (static) suggest a tube receding to the core - free depth cue + - obj: { id: gy_ring3, align: CENTER, x: 0, y: 0, width: 112, height: 112, radius: 56, bg_opa: TRANSP, border_width: 1, border_color: 0x1A2742, hidden: true } + - obj: { id: gy_ring2, align: CENTER, x: 0, y: 0, width: 200, height: 200, radius: 100, bg_opa: TRANSP, border_width: 1, border_color: 0x1E2C4A, hidden: true } + - obj: { id: gy_ring, align: CENTER, x: 0, y: 0, width: 300, height: 300, radius: 150, bg_opa: TRANSP, border_width: 2, border_color: 0x2A3C66, hidden: true } + - image: { id: gy_core, align: CENTER, x: 0, y: 0, src: img_gy_core, hidden: true } + # foes: 4 glowing-orb colours cycled across the 10 slots (fixed src - no runtime swap) + - image: { id: gy_e0, x: 400, y: 400, src: img_gy_enemy0 } + - image: { id: gy_e1, x: 400, y: 400, src: img_gy_enemy1 } + - image: { id: gy_e2, x: 400, y: 400, src: img_gy_enemy2 } + - image: { id: gy_e3, x: 400, y: 400, src: img_gy_enemy3 } + - image: { id: gy_e4, x: 400, y: 400, src: img_gy_enemy0 } + - image: { id: gy_e5, x: 400, y: 400, src: img_gy_enemy1 } + - image: { id: gy_e6, x: 400, y: 400, src: img_gy_enemy2 } + - image: { id: gy_e7, x: 400, y: 400, src: img_gy_enemy3 } + - image: { id: gy_e8, x: 400, y: 400, src: img_gy_enemy0 } + - image: { id: gy_e9, x: 400, y: 400, src: img_gy_enemy1 } + - image: { id: gy_b0, x: 400, y: 400, src: img_gy_bullet } + - image: { id: gy_b1, x: 400, y: 400, src: img_gy_bullet } + - image: { id: gy_b2, x: 400, y: 400, src: img_gy_bullet } + - image: { id: gy_b3, x: 400, y: 400, src: img_gy_bullet } + - image: { id: gy_b4, x: 400, y: 400, src: img_gy_bullet } + - image: { id: gy_b5, x: 400, y: 400, src: img_gy_bullet } + - image: { id: gy_ship, x: 400, y: 400, src: img_gy_ship0 } + # explosion sprites (8-frame), parked + hidden until a foe hits the rim + - image: { id: gy_x0, x: 400, y: 400, src: img_gy_expl0, hidden: true } + - image: { id: gy_x1, x: 400, y: 400, src: img_gy_expl0, hidden: true } + - label: { id: lbl_gy_score, align: CENTER, x: 0, y: -150, text: "0", text_font: font_med, text_color: 0xCFE4FF, hidden: true } + - label: { id: lbl_gy_lives, align: CENTER, x: 0, y: 150, text: "LIVES 3", text_font: font_artist, text_color: 0xFF7A6A, hidden: true } + - label: { id: lbl_gy_title, align: CENTER, x: 0, y: -86, text: "KNOBUSS", text_font: font_sel, text_color: 0x46E3D0, hidden: true } + - label: { id: lbl_gy_sub, align: CENTER, x: 0, y: -52, text: "tube shooter", text_font: font_artist, text_color: 0x6B7BA6, hidden: true } + - button: + id: btn_gy_start + align: CENTER + y: -8 + width: 150 + height: 44 + radius: 22 + bg_color: 0x39C6E6 + bg_opa: COVER + border_width: 0 + on_click: [ script.execute: gy_start ] + widgets: + - label: { align: CENTER, text: "Start", text_font: font_med, text_color: 0x06131A } + - button: + id: btn_gy_howto + align: CENTER + y: 40 + width: 150 + height: 44 + radius: 22 + bg_color: 0x1C2A44 + bg_opa: COVER + border_width: 0 + on_click: [ lambda: 'id(g_gy_state) = 3;', script.execute: gy_screen ] + widgets: + - label: { align: CENTER, text: "How to play?", text_font: font_med, text_color: 0xC4D2F0 } + - button: + id: btn_gy_scores + align: CENTER + y: 88 + width: 150 + height: 44 + radius: 22 + bg_color: 0x1C2A44 + bg_opa: COVER + border_width: 0 + on_click: [ lambda: 'id(g_gy_state) = 4;', script.execute: gy_screen ] + widgets: + - label: { align: CENTER, text: "Scores", text_font: font_med, text_color: 0xC4D2F0 } + - label: { id: lbl_gy_over, align: CENTER, x: 0, y: -10, text_align: CENTER, text: "", text_font: font_sel, text_color: 0xCFE4FF, hidden: true } + - label: { id: lbl_gy_rec, align: CENTER, x: 0, y: 22, text_align: CENTER, text: "", text_font: font_artist, text_color: 0x6B7BA6, hidden: true } + - button: + id: btn_gy_replay + align: CENTER + x: -66 + y: 62 + width: 112 + height: 48 + radius: 24 + bg_color: 0x39C6E6 + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ script.execute: gy_start ] + widgets: + - label: { align: CENTER, text: "Play", text_font: font_med, text_color: 0x06131A } + - button: + id: btn_gy_exit + align: CENTER + x: 66 + y: 62 + width: 112 + height: 48 + radius: 24 + bg_color: 0x2A2E3D + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ lambda: 'id(g_gy_state) = 0;', script.execute: gy_screen ] + widgets: + - label: { align: CENTER, text: "Exit", text_font: font_med, text_color: 0xFFFFFF } + - label: { id: lbl_gy_howto_t, align: CENTER, y: -104, text: "HOW TO PLAY?", text_font: font_sel, text_color: 0x46E3D0, hidden: true } + - obj: { id: gy_hb1, align: CENTER, x: -96, y: -52, width: 24, height: 24, radius: 12, bg_color: 0x2C6FB2, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "1", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: gy_hb2, align: CENTER, x: -96, y: -16, width: 24, height: 24, radius: 12, bg_color: 0x2C6FB2, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "2", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: gy_hb3, align: CENTER, x: -96, y: 20, width: 24, height: 24, radius: 12, bg_color: 0x2C6FB2, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "3", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: gy_hb4, align: CENTER, x: -96, y: 56, width: 24, height: 24, radius: 12, bg_color: 0x2C6FB2, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "4", text_font: font_artist, text_color: 0xFFFFFF } ] } + - label: { id: lbl_gy_howto1, align_to: { id: gy_hb1, align: OUT_RIGHT_MID, x: 8 }, text: "Turn knob to aim", text_font: font_med, text_color: 0xC4D2F0, hidden: true } + - label: { id: lbl_gy_howto2, align_to: { id: gy_hb2, align: OUT_RIGHT_MID, x: 8 }, text: "You auto-fire inward", text_font: font_med, text_color: 0xC4D2F0, hidden: true } + - label: { id: lbl_gy_howto3, align_to: { id: gy_hb3, align: OUT_RIGHT_MID, x: 8 }, text: "Blast foes before the rim", text_font: font_med, text_color: 0xC4D2F0, hidden: true } + - label: { id: lbl_gy_howto4, align_to: { id: gy_hb4, align: OUT_RIGHT_MID, x: 8 }, text: "You have three lives", text_font: font_med, text_color: 0xC4D2F0, hidden: true } + - image: { id: gy_life_icon, align_to: { id: lbl_gy_howto4, align: OUT_RIGHT_MID, x: 8 }, src: img_gy_life, hidden: true } + - label: { id: lbl_gy_scores_t, align: CENTER, y: -116, text: "SCORES", text_font: font_sel, text_color: 0x46E3D0, hidden: true } + - label: { id: lbl_gy_scores1, align: CENTER, x: -52, y: 0, text: "", text_align: LEFT, text_font: font_artist, text_color: 0xC4D2F0, hidden: true } + - label: { id: lbl_gy_scores2, align: CENTER, x: 56, y: 0, text: "", text_align: LEFT, text_font: font_artist, text_color: 0xC4D2F0, hidden: true } + - button: + id: btn_gy_back + align: CENTER + y: 120 + width: 130 + height: 42 + radius: 21 + bg_color: 0x2A2E3D + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ lambda: 'id(g_gy_state) = 0;', script.execute: gy_screen ] + widgets: + - label: { align: CENTER, text: "Back", text_font: font_med, text_color: 0xFFFFFF } diff --git a/base/screens/player.yaml b/base/screens/player.yaml new file mode 100644 index 0000000..ecccf82 --- /dev/null +++ b/base/screens/player.yaml @@ -0,0 +1,265 @@ +# Player - optional carousel screen (base id 1). Full-screen album art + transport. +# Remove it: drop this file from your config's package "files:" list. +# Volume control + the volume overlay stay in the core; this is only the now-playing SCREEN. + +esphome: + on_boot: + - priority: 690 + then: + - lambda: 'id(g_present)[1] = true;' # register after clock + +globals: + - id: g_last_pic + type: std::string + restore_value: no + - id: g_pos # position in the track (s) - progress bar + type: int + restore_value: no + initial_value: '0' + - id: g_dur # track length (s) + type: int + restore_value: no + initial_value: '0' + +# Required by online_image (fetching album art over HTTP) +http_request: + timeout: 10s + verify_ssl: false + +# Album art fetched dynamically from HA (entity_picture is a signed URL) + +online_image: + - id: album_art + url: "${ha_base_url}/" + format: JPEG + type: RGB565 + resize: 360x360 + # Album art FIX: after download you must EXPLICITLY refresh the LVGL widget (it won't redraw itself) + on_download_finished: + - lvgl.image.update: + id: ui_album + src: album_art + on_error: + - logger.log: "Failed to fetch album art" + +sensor: + - platform: homeassistant + id: media_pos + entity_id: ${player_entity} + attribute: media_position + on_value: + - lambda: 'id(g_pos) = (int) x;' + - platform: homeassistant + id: media_dur + entity_id: ${player_entity} + attribute: media_duration + on_value: + - lambda: 'id(g_dur) = (int) x;' + +text_sensor: + - platform: homeassistant + id: media_title + entity_id: ${player_entity} + attribute: media_title + on_value: + - lvgl.label.update: + id: lbl_title + text: !lambda 'return x.empty() ? std::string("-") : x;' + - platform: homeassistant + id: media_artist + entity_id: ${player_entity} + attribute: media_artist + on_value: + - lvgl.label.update: + id: lbl_artist + text: !lambda 'return x;' + - platform: homeassistant + id: media_pic + entity_id: ${player_entity} + attribute: entity_picture + on_value: + - lambda: |- + if (x.empty()) return; + std::string url = x.rfind("http", 0) == 0 ? x : std::string("${ha_base_url}") + x; + if (url == id(g_last_pic)) return; // same album art -> don't reload (less flicker) + id(g_last_pic) = url; + id(album_art)->set_url(url.c_str()); + id(album_art)->update(); + # Player state -> only "playing" shows the player; pause/stop = player disappears (clock) + - platform: homeassistant + id: media_state + entity_id: ${player_entity} + on_value: + - lvgl.label.update: # icon on the button: pause when playing, play when not + id: lbl_play + text: !lambda 'return id(media_state).state == "playing" ? std::string("") : std::string("");' + - if: + condition: + lambda: 'return !id(g_va_active) && !id(g_boot) && id(g_mode) == 0 && (id(g_base) == 0 || id(g_base) == 1);' # auto-switch clock<->player only when we're on the clock/player (and not during boot splash) + then: + # Only switch on an ACTUAL transition: home -> player when playback starts, + # player -> home when it stops. (Avoids re-navigating on every media-state event, + # incl. the state HA pushes at boot, which used to flip the home to Classic.) + - if: + condition: { lambda: 'return x == "playing" && id(g_base) == 0;' } + then: + - lvgl.page.show: { id: page_player, animation: move_left, time: 250ms } + - lambda: 'id(g_base) = 1;' + - if: + condition: { lambda: 'return x != "playing" && id(g_base) == 1;' } + then: + # back to the home base, respecting the active watchface (Classic shows + # page_home directly; other faces self-show via g_wf_req) - don't hardcode Classic + - lambda: 'id(g_base) = 0; id(g_nav_anim) = 1;' + - if: + condition: { lambda: 'return id(g_watchface) == 0;' } + then: + - lvgl.page.show: { id: page_home, animation: move_right, time: 250ms } + else: + - lambda: 'id(g_wf_req) = true;' + +interval: + # nav: show this screen when it is selected in the carousel + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_nav_req) && id(g_base)==1;' } + then: + - lambda: 'id(g_nav_req) = false;' + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_player, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_player, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_player, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_player, animation: move_bottom, time: 250ms }] } + # track progress (position / time / progress bar) + - interval: 1s + then: + # Track progress: tick the position while playing + update time and bar + - if: + condition: + lambda: 'return id(media_state).state == "playing" && id(g_dur) > 0 && id(g_pos) < id(g_dur);' + then: + - lambda: 'id(g_pos)++;' + - lvgl.label.update: + id: lbl_time + text: !lambda |- + int p = id(g_pos), d = id(g_dur); + if (d <= 0) return std::string(""); + char b[20]; snprintf(b, sizeof(b), "%d:%02d | %d:%02d", p/60, p%60, d/60, d%60); + return std::string(b); + - lvgl.bar.update: + id: prog_bar + value: !lambda 'return id(g_dur) > 0 ? (int)(1000L * id(g_pos) / id(g_dur)) : 0;' + +lvgl: + pages: + # --- Player page: full-screen album art + scrim + title/artist + volume arc --- + - id: page_player + bg_color: 0x000000 + widgets: + # Album art covering the whole round screen (360x360; the bezel hides the corners) + - image: + id: ui_album + align: CENTER + src: album_art + # Dim the whole thing under text and buttons (readability) + - obj: + align: CENTER + width: 360 + height: 360 + radius: 0 + border_width: 0 + bg_color: 0x000000 + bg_opa: 42% + # Title (scroll) + artist (clip) - in the upper part + - label: + id: lbl_title + align: CENTER + y: -72 + width: 250 + long_mode: scroll_circular + text_align: CENTER + text_font: font_med + text_color: 0xFFFFFF + text: "-" + - label: + id: lbl_artist + align: CENTER + y: -46 + width: 230 + long_mode: clip + text_align: CENTER + text_font: font_artist + text_color: 0x9FC7BE + text: "" + # Transport: prev / play-pause / next (touch buttons) + - button: + id: btn_prev + align: CENTER + x: -84 + y: 6 + width: 50 + height: 50 + radius: 25 + bg_color: 0x223230 + bg_opa: COVER + border_width: 0 + on_click: + - homeassistant.action: + action: media_player.media_previous_track + data: { entity_id: "${player_entity}" } + widgets: + - label: { align: CENTER, text: "", text_font: montserrat_22, text_color: 0xCFEAE4 } + - button: + id: btn_play + align: CENTER + x: 0 + y: 6 + width: 74 + height: 74 + radius: 37 + bg_color: 0x2FE6CF + bg_opa: COVER + border_width: 0 + on_click: + - homeassistant.action: + action: media_player.media_play_pause + data: { entity_id: "${player_entity}" } + widgets: + - label: { id: lbl_play, align: CENTER, text: "", text_font: montserrat_28, text_color: 0x06201C } + - button: + id: btn_next + align: CENTER + x: 84 + y: 6 + width: 50 + height: 50 + radius: 25 + bg_color: 0x223230 + bg_opa: COVER + border_width: 0 + on_click: + - homeassistant.action: + action: media_player.media_next_track + data: { entity_id: "${player_entity}" } + widgets: + - label: { align: CENTER, text: "", text_font: montserrat_22, text_color: 0xCFEAE4 } + # Time + progress bar + - label: + id: lbl_time + align: CENTER + y: 64 + text: "" + text_font: font_artist + text_color: 0xCFEAE4 + - bar: + id: prog_bar + align: CENTER + y: 90 + width: 210 + height: 5 + min_value: 0 + max_value: 1000 + value: 0 + border_width: 0 + indicator: + bg_color: 0x2FE6CF diff --git a/base/screens/sensors.yaml b/base/screens/sensors.yaml new file mode 100644 index 0000000..d498dea --- /dev/null +++ b/base/screens/sensors.yaml @@ -0,0 +1,244 @@ +# Sensors / glance - optional carousel screen (base id 8). +# Shows one configurable Home Assistant sensor at a time, big and readable; turn the KNOB to +# cycle between them. Pulls straight from HA (no helper). Works for 1 sensor (no cycling) up to +# 6 (knob scrolls, a "n/total" indicator appears). Any entity works - the state is shown as text +# and the unit comes from the entity's unit_of_measurement attribute. +# +# Configure slots with the glanceN_entity / glanceN_name / glanceN_icon substitutions. Leave a +# slot at its default (a non-existent entity) and it's skipped automatically. Icon names use the +# same set as the control tiles; leave empty for no icon. +# +# Remove it: drop this file from the package "files:" list. + +esphome: + on_boot: + - priority: 655 + then: + - lambda: |- + id(g_present)[8] = true; // register the sensors screen + // resolve icon names -> glyphs (same set as the control tiles); "" -> no icon + auto ic = [](const std::string &s) -> std::string { + if (s == "bulb" || s == "light") return "\U000f0335"; + if (s == "lamp") return "\U000f06b5"; + if (s == "led" || s == "strip") return "\U000f07d6"; + if (s == "power") return "\U000f0425"; + if (s == "plug") return "\U000f06a5"; + if (s == "switch") return "\U000f0521"; + if (s == "fan") return "\U000f0210"; + if (s == "ac" || s == "aircon") return "\U000f001b"; + if (s == "heater" || s == "radiator") return "\U000f0438"; + if (s == "thermometer" || s == "temp") return "\U000f050f"; + if (s == "lock") return "\U000f033e"; + if (s == "unlock") return "\U000f033f"; + if (s == "door") return "\U000f081a"; + if (s == "blinds") return "\U000f00ac"; + if (s == "tv") return "\U000f0502"; + if (s == "speaker") return "\U000f04c3"; + if (s == "bell") return "\U000f009a"; + if (s == "wifi") return "\U000f05a9"; + if (s == "home") return "\U000f02dc"; + if (s == "flash") return "\U000f0241"; + if (s == "air" || s == "pm" || s == "airquality") return "\U000f0d44"; + if (s == "plant" || s == "sprout" || s == "moisture") return "\U000f0e66"; + if (s == "plex") return "\U000f06ba"; + if (s == "server") return "\U000f048b"; + if (s == "shield" || s == "alarm" || s == "security") return "\U000f068a"; + return s; + }; + id(g_gl_glyph)[0] = ic("${glance1_icon}"); id(g_gl_name)[0] = "${glance1_name}"; + id(g_gl_glyph)[1] = ic("${glance2_icon}"); id(g_gl_name)[1] = "${glance2_name}"; + id(g_gl_glyph)[2] = ic("${glance3_icon}"); id(g_gl_name)[2] = "${glance3_name}"; + id(g_gl_glyph)[3] = ic("${glance4_icon}"); id(g_gl_name)[3] = "${glance4_name}"; + id(g_gl_glyph)[4] = ic("${glance5_icon}"); id(g_gl_name)[4] = "${glance5_name}"; + id(g_gl_glyph)[5] = ic("${glance6_icon}"); id(g_gl_name)[5] = "${glance6_name}"; + +globals: + - id: g_gl_val + type: std::string[6] + restore_value: no + - id: g_gl_unit + type: std::string[6] + restore_value: no + - id: g_gl_name + type: std::string[6] + restore_value: no + - id: g_gl_glyph + type: std::string[6] + restore_value: no + - id: g_gl_present + type: bool[6] + restore_value: no + - id: g_gl_sel + type: int + restore_value: no + initial_value: '0' + +script: + - id: gl_render + then: + - lambda: |- + int first = -1; + for (int i = 0; i < 6; i++) if (id(g_gl_present)[i]) { first = i; break; } + if (first >= 0 && !id(g_gl_present)[id(g_gl_sel)]) id(g_gl_sel) = first; + - if: + condition: { lambda: 'int c=0; for(int i=0;i<6;i++) if(id(g_gl_present)[i]) c++; return c==0;' } + then: + - lvgl.widget.show: gl_empty + - lvgl.widget.hide: gl_icon + - lvgl.widget.hide: gl_value + - lvgl.widget.hide: gl_name + - lvgl.widget.hide: gl_pos + else: + - lvgl.widget.hide: gl_empty + - lvgl.widget.show: gl_value + - lvgl.widget.show: gl_name + - lvgl.label.update: { id: gl_icon, text: !lambda 'return id(g_gl_glyph)[id(g_gl_sel)];' } + - lvgl.widget.update: { id: gl_icon, hidden: !lambda 'return id(g_gl_glyph)[id(g_gl_sel)].empty();' } + - lvgl.label.update: + id: gl_value + text: !lambda |- + std::string v = id(g_gl_val)[id(g_gl_sel)]; + if (v.empty() || v == "unknown" || v == "unavailable") return std::string("--"); + std::string u = id(g_gl_unit)[id(g_gl_sel)]; + if (!u.empty()) v += " " + u; + // replace symbols the value font lacks (micro sign, superscripts) so units don't tofu + auto rep = [&](const std::string &a, const std::string &b) { + size_t p; while ((p = v.find(a)) != std::string::npos) v.replace(p, a.size(), b); + }; + rep("\xC2\xB5", "u"); // µ micro sign (U+00B5) -> u + rep("\xCE\xBC", "u"); // μ Greek mu (U+03BC, what HA often uses) -> u + rep("\xC2\xB2", "2"); // ² -> 2 + rep("\xC2\xB3", "3"); // ³ -> 3 + rep("\xC2\xB9", "1"); // ¹ -> 1 + return v; + - lvgl.label.update: { id: gl_name, text: !lambda 'return id(g_gl_name)[id(g_gl_sel)];' } + - lvgl.widget.update: { id: gl_pos, hidden: !lambda 'int c=0; for(int i=0;i<6;i++) if(id(g_gl_present)[i]) c++; return c<=1;' } + - lvgl.label.update: + id: gl_pos + text: !lambda |- + std::string d; // slider dots: active = bigger (circle-medium), rest = circle-small + for (int i=0;i<6;i++) { + if (!id(g_gl_present)[i]) continue; + d += (i == id(g_gl_sel)) ? "\U000f09de" : "\U000f09df"; + } + return d; + # per-sensor accent (by slot): tints the icon + dots so each glance has its identity + - if: { condition: { lambda: 'return id(g_gl_sel)==0;' }, then: [ lvgl.label.update: { id: gl_icon, text_color: 0x35C759 }, lvgl.label.update: { id: gl_pos, text_color: 0x35C759 } ] } + - if: { condition: { lambda: 'return id(g_gl_sel)==1;' }, then: [ lvgl.label.update: { id: gl_icon, text_color: 0x3FA9D6 }, lvgl.label.update: { id: gl_pos, text_color: 0x3FA9D6 } ] } + - if: { condition: { lambda: 'return id(g_gl_sel)==2;' }, then: [ lvgl.label.update: { id: gl_icon, text_color: 0xF5B83D }, lvgl.label.update: { id: gl_pos, text_color: 0xF5B83D } ] } + - if: { condition: { lambda: 'return id(g_gl_sel)==3;' }, then: [ lvgl.label.update: { id: gl_icon, text_color: 0xA66BFF }, lvgl.label.update: { id: gl_pos, text_color: 0xA66BFF } ] } + - if: { condition: { lambda: 'return id(g_gl_sel)==4;' }, then: [ lvgl.label.update: { id: gl_icon, text_color: 0xFF6B60 }, lvgl.label.update: { id: gl_pos, text_color: 0xFF6B60 } ] } + - if: { condition: { lambda: 'return id(g_gl_sel)==5;' }, then: [ lvgl.label.update: { id: gl_icon, text_color: 0x2FE6CF }, lvgl.label.update: { id: gl_pos, text_color: 0x2FE6CF } ] } + +text_sensor: + - platform: homeassistant + id: gl1_v + entity_id: ${glance1_entity} + on_value: [ lambda: 'id(g_gl_val)[0]=x; id(g_gl_present)[0]=true;', script.execute: gl_render ] + - platform: homeassistant + id: gl1_u + entity_id: ${glance1_entity} + attribute: unit_of_measurement + on_value: [ lambda: 'id(g_gl_unit)[0]=x;', script.execute: gl_render ] + - platform: homeassistant + id: gl2_v + entity_id: ${glance2_entity} + on_value: [ lambda: 'id(g_gl_val)[1]=x; id(g_gl_present)[1]=true;', script.execute: gl_render ] + - platform: homeassistant + id: gl2_u + entity_id: ${glance2_entity} + attribute: unit_of_measurement + on_value: [ lambda: 'id(g_gl_unit)[1]=x;', script.execute: gl_render ] + - platform: homeassistant + id: gl3_v + entity_id: ${glance3_entity} + on_value: [ lambda: 'id(g_gl_val)[2]=x; id(g_gl_present)[2]=true;', script.execute: gl_render ] + - platform: homeassistant + id: gl3_u + entity_id: ${glance3_entity} + attribute: unit_of_measurement + on_value: [ lambda: 'id(g_gl_unit)[2]=x;', script.execute: gl_render ] + - platform: homeassistant + id: gl4_v + entity_id: ${glance4_entity} + on_value: [ lambda: 'id(g_gl_val)[3]=x; id(g_gl_present)[3]=true;', script.execute: gl_render ] + - platform: homeassistant + id: gl4_u + entity_id: ${glance4_entity} + attribute: unit_of_measurement + on_value: [ lambda: 'id(g_gl_unit)[3]=x;', script.execute: gl_render ] + - platform: homeassistant + id: gl5_v + entity_id: ${glance5_entity} + on_value: [ lambda: 'id(g_gl_val)[4]=x; id(g_gl_present)[4]=true;', script.execute: gl_render ] + - platform: homeassistant + id: gl5_u + entity_id: ${glance5_entity} + attribute: unit_of_measurement + on_value: [ lambda: 'id(g_gl_unit)[4]=x;', script.execute: gl_render ] + - platform: homeassistant + id: gl6_v + entity_id: ${glance6_entity} + on_value: [ lambda: 'id(g_gl_val)[5]=x; id(g_gl_present)[5]=true;', script.execute: gl_render ] + - platform: homeassistant + id: gl6_u + entity_id: ${glance6_entity} + attribute: unit_of_measurement + on_value: [ lambda: 'id(g_gl_unit)[5]=x;', script.execute: gl_render ] + +interval: + # Carousel nav: when selected, show the page and take the knob (only if >1 sensor). + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_nav_req) && id(g_base)==8;' } + then: + - lambda: 'id(g_nav_req) = false;' + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_sensors, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_sensors, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_sensors, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_sensors, animation: move_bottom, time: 250ms }] } + - script.execute: gl_render + # Knob cycles between present sensors (capture only when there's more than one). + - interval: 120ms + then: + - if: + condition: { lambda: 'return id(g_base)==8;' } + then: + - lambda: |- + int c=0; for (int i=0;i<6;i++) if (id(g_gl_present)[i]) c++; + id(g_knob_capture) = (c > 1); + - if: + condition: { lambda: 'return id(g_knob_delta) != 0;' } + then: + - lambda: |- + int d = id(g_knob_delta); id(g_knob_delta) = 0; + int cnt=0; for (int i=0;i<6;i++) if (id(g_gl_present)[i]) cnt++; + if (cnt > 0) { + int step = (d > 0) ? 1 : -1; int n = (d > 0) ? d : -d; + for (int k=0;k id(g_sn_best)) id(g_sn_best) = s; + for (int i = 0; i < 10; i++) { + if (s > id(g_sn_top)[i]) { + for (int j = 9; j > i; j--) id(g_sn_top)[j] = id(g_sn_top)[j-1]; + id(g_sn_top)[i] = s; + break; + } + } + - script.execute: sn_screen + # screen state + the single knob-capture authority (captured only in play + game over) + - id: sn_screen + then: + - lambda: 'id(g_knob_capture) = (id(g_sn_state) == 1 || id(g_sn_state) == 2);' + - lvgl.widget.hide: sn_ring + - script.execute: sn_clear + - lvgl.widget.hide: lbl_sn_score + - lvgl.widget.hide: sn_logo + - lvgl.widget.hide: btn_sn_start + - lvgl.widget.hide: btn_sn_howto + - lvgl.widget.hide: btn_sn_scores + - lvgl.widget.hide: lbl_sn_over + - lvgl.widget.hide: lbl_sn_rec + - lvgl.widget.hide: btn_sn_replay + - lvgl.widget.hide: btn_sn_exit + - lvgl.widget.hide: lbl_sn_howto_t + - lvgl.widget.hide: sn_b1 + - lvgl.widget.hide: sn_b2 + - lvgl.widget.hide: sn_b3 + - lvgl.widget.hide: sn_b4 + - lvgl.widget.hide: lbl_sn_howto1 + - lvgl.widget.hide: lbl_sn_howto2 + - lvgl.widget.hide: lbl_sn_howto3 + - lvgl.widget.hide: lbl_sn_howto4 + - lvgl.widget.hide: lbl_sn_scores_t + - lvgl.widget.hide: lbl_sn_scores1 + - lvgl.widget.hide: lbl_sn_scores2 + - lvgl.widget.hide: btn_sn_back + - if: # MENU + condition: { lambda: 'return id(g_sn_state) == 0;' } + then: + - lvgl.widget.show: sn_logo + - lvgl.widget.show: btn_sn_start + - lvgl.widget.show: btn_sn_howto + - lvgl.widget.show: btn_sn_scores + - if: # PLAY + condition: { lambda: 'return id(g_sn_state) == 1;' } + then: + - lvgl.widget.show: sn_ring + - lvgl.widget.show: lbl_sn_score + - script.execute: sn_drawfull + - if: # GAME OVER + condition: { lambda: 'return id(g_sn_state) == 2;' } + then: + - lvgl.widget.show: sn_logo + - lvgl.widget.show: lbl_sn_over + - lvgl.widget.show: lbl_sn_rec + - lvgl.widget.show: btn_sn_replay + - lvgl.widget.show: btn_sn_exit + - lvgl.label.update: { id: lbl_sn_over, text: !lambda 'char b[24]; snprintf(b,sizeof(b),"Score: %d", id(g_sn_score)); return std::string(b);' } + - lvgl.label.update: { id: lbl_sn_rec, text: !lambda 'char b[24]; snprintf(b,sizeof(b),"Best: %d", id(g_sn_best)); return std::string(b);' } + - if: # HOW TO PLAY + condition: { lambda: 'return id(g_sn_state) == 3;' } + then: + - lvgl.widget.show: lbl_sn_howto_t + - lvgl.widget.show: sn_b1 + - lvgl.widget.show: sn_b2 + - lvgl.widget.show: sn_b3 + - lvgl.widget.show: sn_b4 + - lvgl.widget.show: lbl_sn_howto1 + - lvgl.widget.show: lbl_sn_howto2 + - lvgl.widget.show: lbl_sn_howto3 + - lvgl.widget.show: lbl_sn_howto4 + - lvgl.widget.show: btn_sn_back + - if: # SCORES (top 10) + condition: { lambda: 'return id(g_sn_state) == 4;' } + then: + - lvgl.widget.show: lbl_sn_scores_t + - lvgl.widget.show: lbl_sn_scores1 + - lvgl.widget.show: lbl_sn_scores2 + - lvgl.widget.show: btn_sn_back + - lvgl.label.update: + id: lbl_sn_scores1 + text: !lambda |- + std::string s; char b[24]; + for (int i = 0; i < 5; i++) { if(i) s += "\n"; if(id(g_sn_top)[i]>0){ snprintf(b,sizeof(b),"%d. %d",i+1,id(g_sn_top)[i]); } else { snprintf(b,sizeof(b),"%d. -",i+1); } s += b; } + return s; + - lvgl.label.update: + id: lbl_sn_scores2 + text: !lambda |- + std::string s; char b[24]; + for (int i = 5; i < 10; i++) { if(i>5) s += "\n"; if(id(g_sn_top)[i]>0){ snprintf(b,sizeof(b),"%d. %d",i+1,id(g_sn_top)[i]); } else { snprintf(b,sizeof(b),"%d. -",i+1); } s += b; } + return s; + +interval: + - interval: 250ms + then: + - if: + condition: { lambda: 'return id(g_sn_reset) || id(g_factory_reset);' } + then: + - lambda: 'for (int i = 0; i < 10; i++) id(g_sn_top)[i] = 0; id(g_sn_best) = 0; id(g_sn_reset) = false;' + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_nav_req) && id(g_base)==9;' } + then: + - lambda: 'id(g_nav_req) = false; id(g_sn_state) = 0; id(g_knob_delta) = 0;' + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_snake, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_snake, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_snake, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_snake, animation: move_bottom, time: 250ms }] } + - script.execute: sn_screen + # Game tick: steer + glide head; lay/drop one trail dot when the head advanced SEG_DIST. + # All body/head positioning is done IN this lambda (lv_obj_set_pos) to keep the action chain shallow. + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_base) == 9 && id(g_sn_state) == 1;' } + then: + - lambda: |- + id(g_sn_add) = -1; id(g_sn_del) = -1; id(g_sn_newfruit) = -1; id(g_sn_newskull) = -1; + int kd = id(g_knob_delta); id(g_knob_delta) = 0; + id(g_sn_ang) += kd * 0.26f; + float spd = 3.2f + id(g_sn_score) / 400.0f; if (spd > 5.5f) spd = 5.5f; + float hx = id(g_sn_hx) + cosf(id(g_sn_ang)) * spd; + float hy = id(g_sn_hy) + sinf(id(g_sn_ang)) * spd; + if (hx*hx + hy*hy > 168.0f * 168.0f) { id(g_sn_dead) = true; } // hit the ring + else { + id(g_sn_hx) = hx; id(g_sn_hy) = hy; + int len = id(g_sn_len), head = id(g_sn_head); + for (int i = 6; i < len; i++) { + int sl = (head - i + 400) % 100; + float dx = hx - id(g_sn_tx)[sl], dy = hy - id(g_sn_ty)[sl]; + if (dx*dx + dy*dy < 8.0f * 8.0f) { id(g_sn_dead) = true; break; } + } + for (int s = 0; s < 2; s++) if (id(g_sn_skon)[s]) { + float dx = hx - id(g_sn_skx)[s], dy = hy - id(g_sn_sky)[s]; + if (dx*dx + dy*dy < 13.0f * 13.0f) { id(g_sn_dead) = true; } + } + for (int s = 0; s < 3 && !id(g_sn_dead); s++) if (id(g_sn_fon)[s]) { + float dx = hx - id(g_sn_fx)[s], dy = hy - id(g_sn_fy)[s]; + if (dx*dx + dy*dy < 12.0f * 12.0f) { id(g_sn_fon)[s] = false; id(g_sn_score) += 10; id(g_sn_grow) += 3; } + } + if (!id(g_sn_dead)) { + float dx = hx - id(g_sn_tx)[head], dy = hy - id(g_sn_ty)[head]; + if (dx*dx + dy*dy >= 8.0f * 8.0f) { + int nh = (head + 1) % 100; + id(g_sn_tx)[nh] = hx; id(g_sn_ty)[nh] = hy; + id(g_sn_head) = nh; id(g_sn_add) = nh; + if (id(g_sn_grow) > 0 && id(g_sn_len) < 99) { id(g_sn_grow)--; id(g_sn_len)++; id(g_sn_del) = -1; } // cap at POOL-1: the ring needs one free slot or add/drop collide + else { if (id(g_sn_grow) > 0) id(g_sn_grow)--; id(g_sn_del) = (head - id(g_sn_len) + 1 + 400) % 100; } + } + } + } + if (!id(g_sn_dead)) { + int fc = 0; for (int s = 0; s < 3; s++) if (id(g_sn_fon)[s]) fc++; + id(g_sn_fclk)++; + int wait = (fc <= 1) ? 45 : 90; + if (fc < 3 && id(g_sn_fclk) >= wait) { + id(g_sn_fclk) = 0; + int sl = -1; for (int s = 0; s < 3; s++) if (!id(g_sn_fon)[s]) { sl = s; break; } + if (sl >= 0) { + float a = (esp_random() % 62832) / 10000.0f; float rad = 25.0f + (esp_random() % 140); + id(g_sn_fx)[sl] = cosf(a)*rad; id(g_sn_fy)[sl] = sinf(a)*rad; + id(g_sn_fi)[sl] = esp_random() % 12; id(g_sn_fon)[sl] = true; id(g_sn_newfruit) = sl; + } + } + int sc = 0; for (int s = 0; s < 2; s++) if (id(g_sn_skon)[s]) sc++; + id(g_sn_skclk)++; + if (sc < 2 && id(g_sn_skclk) >= 200) { + id(g_sn_skclk) = 0; + int sl = -1; for (int s = 0; s < 2; s++) if (!id(g_sn_skon)[s]) { sl = s; break; } + if (sl >= 0) { + float a = (esp_random() % 62832) / 10000.0f; float rad = 30.0f + (esp_random() % 130); + id(g_sn_skx)[sl] = cosf(a)*rad; id(g_sn_sky)[sl] = sinf(a)*rad; + id(g_sn_skon)[sl] = true; id(g_sn_skage)[sl] = 0; id(g_sn_newskull) = sl; + } + } + for (int s = 0; s < 2; s++) if (id(g_sn_skon)[s]) { id(g_sn_skage)[s]++; if (id(g_sn_skage)[s] >= 220) id(g_sn_skon)[s] = false; } + // render (constant cost): head every tick + the one added/dropped dot + lv_obj_set_pos(id(sn_head), 180 + (int) lroundf(id(g_sn_hx)) - 4, 180 + (int) lroundf(id(g_sn_hy)) - 4); + if (id(g_sn_add) >= 0 || id(g_sn_del) >= 0) { + lv_obj_t *SS[100] = { id(sn_seg0), id(sn_seg1), id(sn_seg2), id(sn_seg3), id(sn_seg4), id(sn_seg5), id(sn_seg6), id(sn_seg7), id(sn_seg8), id(sn_seg9), id(sn_seg10), id(sn_seg11), id(sn_seg12), id(sn_seg13), id(sn_seg14), id(sn_seg15), id(sn_seg16), id(sn_seg17), id(sn_seg18), id(sn_seg19), id(sn_seg20), id(sn_seg21), id(sn_seg22), id(sn_seg23), id(sn_seg24), id(sn_seg25), id(sn_seg26), id(sn_seg27), id(sn_seg28), id(sn_seg29), id(sn_seg30), id(sn_seg31), id(sn_seg32), id(sn_seg33), id(sn_seg34), id(sn_seg35), id(sn_seg36), id(sn_seg37), id(sn_seg38), id(sn_seg39), id(sn_seg40), id(sn_seg41), id(sn_seg42), id(sn_seg43), id(sn_seg44), id(sn_seg45), id(sn_seg46), id(sn_seg47), id(sn_seg48), id(sn_seg49), id(sn_seg50), id(sn_seg51), id(sn_seg52), id(sn_seg53), id(sn_seg54), id(sn_seg55), id(sn_seg56), id(sn_seg57), id(sn_seg58), id(sn_seg59), id(sn_seg60), id(sn_seg61), id(sn_seg62), id(sn_seg63), id(sn_seg64), id(sn_seg65), id(sn_seg66), id(sn_seg67), id(sn_seg68), id(sn_seg69), id(sn_seg70), id(sn_seg71), id(sn_seg72), id(sn_seg73), id(sn_seg74), id(sn_seg75), id(sn_seg76), id(sn_seg77), id(sn_seg78), id(sn_seg79), id(sn_seg80), id(sn_seg81), id(sn_seg82), id(sn_seg83), id(sn_seg84), id(sn_seg85), id(sn_seg86), id(sn_seg87), id(sn_seg88), id(sn_seg89), id(sn_seg90), id(sn_seg91), id(sn_seg92), id(sn_seg93), id(sn_seg94), id(sn_seg95), id(sn_seg96), id(sn_seg97), id(sn_seg98), id(sn_seg99) }; + if (id(g_sn_add) >= 0) lv_obj_set_pos(SS[id(g_sn_add)], 180 + (int) lroundf(id(g_sn_tx)[id(g_sn_add)]) - 4, 180 + (int) lroundf(id(g_sn_ty)[id(g_sn_add)]) - 4); + if (id(g_sn_del) >= 0) lv_obj_set_pos(SS[id(g_sn_del)], 400, 400); + } + } + - if: + condition: { lambda: 'return id(g_sn_dead);' } + then: + - script.execute: sn_over + else: + - if: + condition: { lambda: 'return id(g_sn_newfruit) >= 0;' } + then: + - if: { condition: { lambda: 'return id(g_sn_newfruit) == 0;' }, then: [ script.execute: sn_fspr0 ] } + - if: { condition: { lambda: 'return id(g_sn_newfruit) == 1;' }, then: [ script.execute: sn_fspr1 ] } + - if: { condition: { lambda: 'return id(g_sn_newfruit) == 2;' }, then: [ script.execute: sn_fspr2 ] } + - if: + condition: { lambda: 'return id(g_sn_newskull) >= 0;' } + then: + - if: { condition: { lambda: 'return id(g_sn_newskull) == 0;' }, then: [ lvgl.widget.update: { id: sn_skull0, hidden: false, x: !lambda 'return (int) lroundf(id(g_sn_skx)[0]);', y: !lambda 'return (int) lroundf(id(g_sn_sky)[0]);' } ] } + - if: { condition: { lambda: 'return id(g_sn_newskull) == 1;' }, then: [ lvgl.widget.update: { id: sn_skull1, hidden: false, x: !lambda 'return (int) lroundf(id(g_sn_skx)[1]);', y: !lambda 'return (int) lroundf(id(g_sn_sky)[1]);' } ] } + - script.execute: sn_items + - script.execute: sn_hud + +image: + - id: img_sn_logo + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/snake-logo.png" + type: RGB565 + - id: img_sn_fruit0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit0.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_fruit1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit1.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_fruit2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit2.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_fruit3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit3.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_fruit4 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit4.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_fruit5 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit5.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_fruit6 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit6.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_fruit7 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit7.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_fruit8 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit8.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_fruit9 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit9.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_fruit10 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit10.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_fruit11 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/fruit11.png" + type: RGB565 + transparency: alpha_channel + - id: img_sn_skull + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/skull.png" + type: RGB565 + transparency: alpha_channel + +lvgl: + pages: + - id: page_snake + bg_color: 0x0D1E15 + scrollbar_mode: "off" + scrollable: false + widgets: + - obj: { id: sn_ring, align: CENTER, x: 0, y: 0, width: 344, height: 344, radius: 172, bg_opa: TRANSP, border_width: 4, border_color: 0x3A8C4E, hidden: true } + - image: { id: sn_fruit0, align: CENTER, x: 0, y: 0, src: img_sn_fruit0, hidden: true } + - image: { id: sn_fruit1, align: CENTER, x: 0, y: 0, src: img_sn_fruit0, hidden: true } + - image: { id: sn_fruit2, align: CENTER, x: 0, y: 0, src: img_sn_fruit0, hidden: true } + - image: { id: sn_skull0, align: CENTER, x: 0, y: 0, src: img_sn_skull, hidden: true } + - image: { id: sn_skull1, align: CENTER, x: 0, y: 0, src: img_sn_skull, hidden: true } + - obj: { id: sn_seg0, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg1, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg2, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg3, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg4, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg5, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg6, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg7, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg8, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg9, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg10, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg11, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg12, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg13, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg14, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg15, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg16, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg17, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg18, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg19, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg20, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg21, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg22, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg23, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg24, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg25, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg26, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg27, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg28, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg29, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg30, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg31, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg32, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg33, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg34, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg35, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg36, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg37, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg38, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg39, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg40, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg41, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg42, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg43, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg44, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg45, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg46, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg47, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg48, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg49, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg50, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg51, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg52, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg53, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg54, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg55, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg56, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg57, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg58, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg59, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg60, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg61, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg62, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg63, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg64, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg65, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg66, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg67, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg68, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg69, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg70, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg71, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg72, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg73, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg74, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg75, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg76, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg77, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg78, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg79, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg80, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg81, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg82, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg83, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg84, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg85, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg86, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg87, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg88, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg89, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg90, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg91, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg92, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg93, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg94, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg95, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg96, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg97, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg98, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_seg99, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0x52A049, bg_opa: COVER } + - obj: { id: sn_head, x: 400, y: 400, width: 9, height: 9, radius: 4, border_width: 0, bg_color: 0xB8E86A, bg_opa: COVER } + - label: { id: lbl_sn_score, align: CENTER, x: 0, y: -150, text: "0", text_font: font_med, text_color: 0xCFEAE4, hidden: true } + - image: { id: sn_logo, align: CENTER, x: 0, y: 0, src: img_sn_logo, hidden: true } + - button: + id: btn_sn_start + align: CENTER + y: 4 + width: 150 + height: 44 + radius: 22 + bg_color: 0xA6D85F + bg_opa: COVER + border_width: 0 + on_click: [ script.execute: sn_start ] + widgets: + - label: { align: CENTER, text: "Start", text_font: font_med, text_color: 0x12241A } + - button: + id: btn_sn_howto + align: CENTER + y: 54 + width: 150 + height: 44 + radius: 22 + bg_color: 0x244A30 + bg_opa: COVER + border_width: 0 + on_click: [ lambda: 'id(g_sn_state) = 3;', script.execute: sn_screen ] + widgets: + - label: { align: CENTER, text: "How to play?", text_font: font_med, text_color: 0xCFE6C4 } + - button: + id: btn_sn_scores + align: CENTER + y: 104 + width: 150 + height: 44 + radius: 22 + bg_color: 0x244A30 + bg_opa: COVER + border_width: 0 + on_click: [ lambda: 'id(g_sn_state) = 4;', script.execute: sn_screen ] + widgets: + - label: { align: CENTER, text: "Scores", text_font: font_med, text_color: 0xCFE6C4 } + - label: { id: lbl_sn_over, align: CENTER, x: 0, y: -10, text_align: CENTER, text: "", text_font: font_sel, text_color: 0x12212D, hidden: true } + - label: { id: lbl_sn_rec, align: CENTER, x: 0, y: 22, text_align: CENTER, text: "", text_font: font_artist, text_color: 0x12212D, hidden: true } + - button: + id: btn_sn_replay + align: CENTER + x: -66 + y: 62 + width: 112 + height: 48 + radius: 24 + bg_color: 0xA6D85F + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ script.execute: sn_start ] + widgets: + - label: { align: CENTER, text: "Play", text_font: font_med, text_color: 0x12241A } + - button: + id: btn_sn_exit + align: CENTER + x: 66 + y: 62 + width: 112 + height: 48 + radius: 24 + bg_color: 0x3A3A3D + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ lambda: 'id(g_sn_state) = 0;', script.execute: sn_screen ] + widgets: + - label: { align: CENTER, text: "Exit", text_font: font_med, text_color: 0xFFFFFF } + - label: { id: lbl_sn_howto_t, align: CENTER, y: -104, text: "HOW TO PLAY?", text_font: font_sel, text_color: 0xA6D85F, hidden: true } + - obj: { id: sn_b1, align: CENTER, x: -96, y: -52, width: 24, height: 24, radius: 12, bg_color: 0x6FB23F, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "1", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: sn_b2, align: CENTER, x: -96, y: -16, width: 24, height: 24, radius: 12, bg_color: 0x6FB23F, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "2", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: sn_b3, align: CENTER, x: -96, y: 20, width: 24, height: 24, radius: 12, bg_color: 0x6FB23F, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "3", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: sn_b4, align: CENTER, x: -96, y: 56, width: 24, height: 24, radius: 12, bg_color: 0xE05A4A, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "!", text_font: font_artist, text_color: 0xFFFFFF } ] } + - label: { id: lbl_sn_howto1, align_to: { id: sn_b1, align: OUT_RIGHT_MID, x: 8 }, text: "Turn knob to steer", text_font: font_med, text_color: 0xC6DCC0, hidden: true } + - label: { id: lbl_sn_howto2, align_to: { id: sn_b2, align: OUT_RIGHT_MID, x: 8 }, text: "Eat fruit to grow", text_font: font_med, text_color: 0xC6DCC0, hidden: true } + - label: { id: lbl_sn_howto3, align_to: { id: sn_b3, align: OUT_RIGHT_MID, x: 8 }, text: "Avoid the ring & tail", text_font: font_med, text_color: 0xC6DCC0, hidden: true } + - label: { id: lbl_sn_howto4, align_to: { id: sn_b4, align: OUT_RIGHT_MID, x: 8 }, text: "Skulls kill - avoid!", text_font: font_med, text_color: 0xC6DCC0, hidden: true } + - label: { id: lbl_sn_scores_t, align: CENTER, y: -116, text: "SCORES", text_font: font_sel, text_color: 0xA6D85F, hidden: true } + - label: { id: lbl_sn_scores1, align: CENTER, x: -52, y: 0, text: "", text_align: LEFT, text_font: font_artist, text_color: 0xC6DCC0, hidden: true } + - label: { id: lbl_sn_scores2, align: CENTER, x: 56, y: 0, text: "", text_align: LEFT, text_font: font_artist, text_color: 0xC6DCC0, hidden: true } + - button: + id: btn_sn_back + align: CENTER + y: 120 + width: 130 + height: 42 + radius: 21 + bg_color: 0x3A3A3D + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ lambda: 'id(g_sn_state) = 0;', script.execute: sn_screen ] + widgets: + - label: { align: CENTER, text: "Back", text_font: font_med, text_color: 0xFFFFFF } diff --git a/base/screens/space-wars.yaml b/base/screens/space-wars.yaml new file mode 100644 index 0000000..f42ac17 --- /dev/null +++ b/base/screens/space-wars.yaml @@ -0,0 +1,498 @@ +# Space Wars - optional carousel screen (base id 4). Vertical shooter. +# Remove it: drop this file from your config's package "files:" list. Nothing else to edit. +# Scores (g_sw_top / g_sw_best) + the "Reset scores" settings action live here - fully self-contained. + +esphome: + on_boot: + - priority: 660 + then: + - lambda: 'id(g_present)[4] = true;' # register after clock/player/timer/cars + # Settings (Settings -> Widgets -> Space Wars -> Reset scores) + factory-reset handling. + - priority: 650 + then: + - lambda: |- + int g = id(g_wgrp_n)++; + id(g_wgrp_labels) += (g ? "\n" : ""); id(g_wgrp_labels) += "Space Wars"; + int o = id(g_wopt_n)++; + id(g_wopt_group)[o] = g; id(g_wopt_label)[o] = "Reset scores"; id(g_wopt_kind)[o] = 1; id(g_wopt_ptr)[o] = &id(g_sw_reset); + +globals: + # --- scores (persistent) + reset flag, owned by this package --- + - id: g_sw_best + type: int + restore_value: yes + initial_value: '0' + - id: g_sw_top + type: int[10] + restore_value: yes + - id: g_sw_reset # Widgets "Reset scores" sets this; the watcher below clears the scores + type: bool + restore_value: no + initial_value: 'false' + # --- Game 2 "Space Wars" (page_game2, g_base==4) --- + - id: g_sw_state # 0=menu 1=playing 2=game over 3=how-to 4=scores + type: int + restore_value: no + initial_value: '0' + - id: g_sw_px # player ship X + type: int + restore_value: no + initial_value: '0' + - id: g_sw_lives + type: int + restore_value: no + initial_value: '3' + - id: g_sw_inv + type: int + restore_value: no + initial_value: '0' + - id: g_sw_score + type: int + restore_value: no + initial_value: '0' + - id: g_fire # auto-fire cadence counter + type: int + restore_value: no + initial_value: '0' + - id: g_tcount # time counter (points for time survived) + type: int + restore_value: no + initial_value: '0' + - id: g_sw_crash + type: bool + restore_value: no + initial_value: 'false' + - id: g_b_x # player bullets (2): X + type: int[2] + restore_value: no + - id: g_b_y + type: int[2] + restore_value: no + - id: g_b_on + type: bool[2] + restore_value: no + - id: g_en_x # enemies (3) + type: int[3] + restore_value: no + - id: g_en_y + type: int[3] + restore_value: no + - id: g_en_on + type: bool[3] + restore_value: no + - id: g_eb_x # enemy bullets (2) + type: int[2] + restore_value: no + - id: g_eb_y + type: int[2] + restore_value: no + - id: g_eb_on + type: bool[2] + restore_value: no + - id: g_exp_f # explosion: countdown frame counter (0=inactive), ticks in the 70ms loop + type: int + restore_value: no + initial_value: '0' + - id: g_exp_x # explosion: position (from the destroyed enemy) + type: int + restore_value: no + initial_value: '0' + - id: g_exp_y + type: int + restore_value: no + initial_value: '0' + - id: g_exp_new # flag: start the animation this tick + type: bool + restore_value: no + initial_value: 'false' + - id: g_exp_hide # flag: hide the explosion this tick + type: bool + restore_value: no + initial_value: 'false' + +script: + # ===== SPACE WARS (game 2) ===== + - id: sw_render # ship X + score + lives + then: + - lvgl.widget.update: { id: sw_ship, x: !lambda 'return id(g_sw_px);' } + - lvgl.label.update: { id: lbl_sw_score, text: !lambda 'char b[12]; snprintf(b,sizeof(b),"%d",id(g_sw_score)); return std::string(b);' } + - lvgl.label.update: + id: lbl_sw_lives + text: !lambda |- + std::string h; for (int i=0;iid(g_sw_best)) id(g_sw_best)=s; + for (int i=0;i<10;i++){ if(s>id(g_sw_top)[i]){ for(int j=9;j>i;j--) id(g_sw_top)[j]=id(g_sw_top)[j-1]; id(g_sw_top)[i]=s; break; } } + // reset game state so the end of the current tick restores nothing (red flash / enemies) + id(g_sw_inv)=0; + for(int i=0;i<3;i++) id(g_en_on)[i]=false; + for(int i=0;i<2;i++){ id(g_b_on)[i]=false; id(g_eb_on)[i]=false; } + id(g_exp_f)=0; id(g_exp_new)=false; id(g_exp_hide)=false; + - script.execute: sw_screen + - id: sw_screen # 0=menu 1=playing 2=game over 3=how-to 4=scores + then: + # knob steers only during play + game over -> capture it there (block volume), free it in menu/how-to/scores + - lambda: 'id(g_knob_capture) = (id(g_sw_state) == 1 || id(g_sw_state) == 2);' + - lvgl.widget.hide: sw_bg + - lvgl.widget.hide: sw_logo + - lvgl.widget.hide: sw_ship + - lvgl.widget.hide: sw_exp + - lvgl.widget.hide: b0 + - lvgl.widget.hide: b1 + - lvgl.widget.hide: eb0 + - lvgl.widget.hide: eb1 + - lvgl.widget.hide: en0 + - lvgl.widget.hide: en1 + - lvgl.widget.hide: en2 + - lvgl.widget.hide: lbl_sw_lives + - lvgl.widget.hide: lbl_sw_score + - lvgl.widget.hide: sw_flash + - lvgl.widget.hide: btn_sw_start + - lvgl.widget.hide: btn_sw_howto + - lvgl.widget.hide: btn_sw_scores + - lvgl.widget.hide: lbl_sw_over + - lvgl.widget.hide: lbl_sw_rec + - lvgl.widget.hide: btn_sw_replay + - lvgl.widget.hide: btn_sw_exit + - lvgl.widget.hide: lbl_sw_ht_t + - lvgl.widget.hide: badge_h1 + - lvgl.widget.hide: badge_h2 + - lvgl.widget.hide: badge_h3 + - lvgl.widget.hide: badge_h4 + - lvgl.widget.hide: lbl_sw_ht1 + - lvgl.widget.hide: lbl_sw_ht2 + - lvgl.widget.hide: lbl_sw_ht3 + - lvgl.widget.hide: lbl_sw_ht4 + - lvgl.widget.hide: lbl_sw_hheart + - lvgl.widget.hide: lbl_sw_st + - lvgl.widget.hide: lbl_sw_s1 + - lvgl.widget.hide: lbl_sw_s2 + - lvgl.widget.hide: btn_sw_back + - if: # MENU + condition: { lambda: 'return id(g_sw_state)==0;' } + then: [ lvgl.widget.show: sw_logo, lvgl.widget.show: btn_sw_start, lvgl.widget.show: btn_sw_howto, lvgl.widget.show: btn_sw_scores ] + - if: # GAME + condition: { lambda: 'return id(g_sw_state)==1;' } + then: + - lvgl.widget.show: sw_bg + - lvgl.widget.show: sw_ship + - lvgl.widget.show: en0 + - lvgl.widget.show: en1 + - lvgl.widget.show: en2 + - lvgl.widget.show: lbl_sw_lives + - lvgl.widget.show: lbl_sw_score + - if: # GAME OVER + condition: { lambda: 'return id(g_sw_state)==2;' } + then: + - lvgl.widget.show: sw_logo + - lvgl.widget.show: lbl_sw_over + - lvgl.widget.show: lbl_sw_rec + - lvgl.widget.show: btn_sw_replay + - lvgl.widget.show: btn_sw_exit + - lvgl.label.update: { id: lbl_sw_over, text: !lambda 'char b[24]; snprintf(b,sizeof(b),"Score: %d", id(g_sw_score)); return std::string(b);' } + - lvgl.label.update: { id: lbl_sw_rec, text: !lambda 'char b[24]; snprintf(b,sizeof(b),"Best: %d", id(g_sw_best)); return std::string(b);' } + - if: # HOW TO PLAY + condition: { lambda: 'return id(g_sw_state)==3;' } + then: + - lvgl.widget.show: sw_bg + - lvgl.widget.show: lbl_sw_ht_t + - lvgl.widget.show: badge_h1 + - lvgl.widget.show: badge_h2 + - lvgl.widget.show: badge_h3 + - lvgl.widget.show: badge_h4 + - lvgl.widget.show: lbl_sw_ht1 + - lvgl.widget.show: lbl_sw_ht2 + - lvgl.widget.show: lbl_sw_ht3 + - lvgl.widget.show: lbl_sw_ht4 + - lvgl.widget.show: lbl_sw_hheart + - lvgl.widget.show: btn_sw_back + - if: # SCORES + condition: { lambda: 'return id(g_sw_state)==4;' } + then: + - lvgl.widget.show: sw_bg + - lvgl.widget.show: lbl_sw_st + - lvgl.widget.show: lbl_sw_s1 + - lvgl.widget.show: lbl_sw_s2 + - lvgl.widget.show: btn_sw_back + - lvgl.label.update: { id: lbl_sw_s1, text: !lambda 'std::string s; char b[24]; for(int i=0;i<5;i++){ if(i) s+="\n"; if(id(g_sw_top)[i]>0){ snprintf(b,sizeof(b),"%d. %d",i+1,id(g_sw_top)[i]); } else { snprintf(b,sizeof(b),"%d. -",i+1); } s+=b; } return s;' } + - lvgl.label.update: { id: lbl_sw_s2, text: !lambda 'std::string s; char b[24]; for(int i=5;i<10;i++){ if(i>5) s+="\n"; if(id(g_sw_top)[i]>0){ snprintf(b,sizeof(b),"%d. %d",i+1,id(g_sw_top)[i]); } else { snprintf(b,sizeof(b),"%d. -",i+1); } s+=b; } return s;' } + +interval: + # Reset scores when the Widgets action fired (g_sw_reset). Idempotent + cheap. + - interval: 250ms + then: + - if: + condition: { lambda: 'return id(g_sw_reset) || id(g_factory_reset);' } + then: + - lambda: 'for (int i = 0; i < 10; i++) id(g_sw_top)[i] = 0; id(g_sw_best) = 0; id(g_sw_reset) = false;' + # nav: show this screen when it is selected in the carousel + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_nav_req) && id(g_base)==4;' } + then: + - lambda: 'id(g_nav_req) = false; id(g_sw_state) = 0;' + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_game2, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_game2, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_game2, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_game2, animation: move_bottom, time: 250ms }] } + - script.execute: sw_screen + # Space Wars - tick only on the Space screen during play + - interval: 70ms + then: + - if: + condition: { lambda: 'return id(g_base) == 4 && id(g_sw_state) == 1;' } + then: + - lambda: |- + id(g_sw_px) += id(g_knob_delta) * 18; if (id(g_sw_px) < -150) id(g_sw_px) = -150; if (id(g_sw_px) > 150) id(g_sw_px) = 150; id(g_knob_delta) = 0; + int px = id(g_sw_px); + if (id(g_sw_inv) > 0) id(g_sw_inv)--; + if (id(g_exp_f) > 0) { id(g_exp_f)--; if (id(g_exp_f)==0) id(g_exp_hide)=true; } + id(g_tcount)++; if (id(g_tcount) % 10 == 0) id(g_sw_score)++; + // auto-fire every 5 ticks into a free slot + id(g_fire)++; + if (id(g_fire) >= 5) { id(g_fire)=0; for(int i=0;i<2;i++){ if(!id(g_b_on)[i]){ id(g_b_on)[i]=true; id(g_b_x)[i]=px; id(g_b_y)[i]=100; break; } } } + // player bullets go up + for (int i=0;i<2;i++){ if(id(g_b_on)[i]){ id(g_b_y)[i]-=24; if(id(g_b_y)[i]<-185) id(g_b_on)[i]=false; } } + // enemies go down; destroyed/escaped -> respawn at the top + for (int e=0;e<3;e++){ + if(id(g_en_on)[e]){ id(g_en_y)[e]+=6; if(id(g_en_y)[e]>188){ id(g_en_y)[e]=-160; id(g_en_x)[e]=(int)(esp_random()%280)-140; } } + else { id(g_en_on)[e]=true; id(g_en_y)[e]=-160-(int)(esp_random()%140); id(g_en_x)[e]=(int)(esp_random()%280)-140; } + } + // enemies shoot + if ((int)(esp_random()%100) < 9) { int k=(int)(esp_random()%3); if(id(g_en_on)[k]) for(int i=0;i<2;i++){ if(!id(g_eb_on)[i]){ id(g_eb_on)[i]=true; id(g_eb_x)[i]=id(g_en_x)[k]; id(g_eb_y)[i]=id(g_en_y)[k]+26; break; } } } + // enemy bullets go down + for (int i=0;i<2;i++){ if(id(g_eb_on)[i]){ id(g_eb_y)[i]+=13; if(id(g_eb_y)[i]>188) id(g_eb_on)[i]=false; } } + // player bullet x enemy + for (int b=0;b<2;b++){ if(id(g_b_on)[b]) for(int e=0;e<3;e++){ if(id(g_en_on)[e] && abs(id(g_b_x)[b]-id(g_en_x)[e])<22 && abs(id(g_b_y)[b]-id(g_en_y)[e])<26){ id(g_en_on)[e]=false; id(g_b_on)[b]=false; id(g_sw_score)+=10; id(g_exp_x)=id(g_en_x)[e]; id(g_exp_y)=id(g_en_y)[e]; id(g_exp_f)=6; id(g_exp_new)=true; break; } } } + // enemy / enemy bullet x player + id(g_sw_crash)=false; + if (id(g_sw_inv)<=0){ + for(int e=0;e<3;e++){ if(id(g_en_on)[e] && abs(id(g_en_x)[e]-px)<26 && abs(id(g_en_y)[e]-130)<32){ id(g_en_on)[e]=false; id(g_sw_crash)=true; break; } } + for(int i=0;i<2;i++){ if(id(g_eb_on)[i] && abs(id(g_eb_x)[i]-px)<18 && abs(id(g_eb_y)[i]-130)<26){ id(g_eb_on)[i]=false; id(g_sw_crash)=true; break; } } + } + - lvgl.widget.update: { id: sw_ship, x: !lambda 'return id(g_sw_px);' } + - if: { condition: { lambda: 'return id(g_sw_crash);' }, then: [ script.execute: sw_lose ] } + # explosion after destroying an enemy: set position, show, run the animation once + - if: + condition: { lambda: 'return id(g_exp_new);' } + then: + - lvgl.widget.update: { id: sw_exp, x: !lambda 'return id(g_exp_x);', y: !lambda 'return id(g_exp_y);', hidden: false } + - lvgl.animimg.start: sw_exp + - lambda: 'id(g_exp_new)=false;' + - if: + condition: { lambda: 'return id(g_exp_hide);' } + then: + - lvgl.animimg.stop: sw_exp + - lvgl.widget.hide: sw_exp + - lambda: 'id(g_exp_hide)=false;' + # positions + visibility + - lvgl.widget.update: { id: b0, x: !lambda 'return id(g_b_x)[0];', y: !lambda 'return id(g_b_y)[0];', hidden: !lambda 'return !id(g_b_on)[0];' } + - lvgl.widget.update: { id: b1, x: !lambda 'return id(g_b_x)[1];', y: !lambda 'return id(g_b_y)[1];', hidden: !lambda 'return !id(g_b_on)[1];' } + - lvgl.widget.update: { id: eb0, x: !lambda 'return id(g_eb_x)[0];', y: !lambda 'return id(g_eb_y)[0];', hidden: !lambda 'return !id(g_eb_on)[0];' } + - lvgl.widget.update: { id: eb1, x: !lambda 'return id(g_eb_x)[1];', y: !lambda 'return id(g_eb_y)[1];', hidden: !lambda 'return !id(g_eb_on)[1];' } + - lvgl.widget.update: { id: en0, x: !lambda 'return id(g_en_x)[0];', y: !lambda 'return id(g_en_y)[0];', hidden: !lambda 'return !id(g_en_on)[0];' } + - lvgl.widget.update: { id: en1, x: !lambda 'return id(g_en_x)[1];', y: !lambda 'return id(g_en_y)[1];', hidden: !lambda 'return !id(g_en_on)[1];' } + - lvgl.widget.update: { id: en2, x: !lambda 'return id(g_en_x)[2];', y: !lambda 'return id(g_en_y)[2];', hidden: !lambda 'return !id(g_en_on)[2];' } + - lvgl.label.update: { id: lbl_sw_score, text: !lambda 'char b[12]; snprintf(b,sizeof(b),"%d",id(g_sw_score)); return std::string(b);' } + - if: + condition: { lambda: 'return id(g_sw_inv) > 0;' } + then: + - lvgl.widget.update: { id: sw_ship, hidden: !lambda 'return (id(g_sw_inv) % 4) >= 2;' } + - lvgl.widget.update: { id: sw_flash, hidden: !lambda 'return id(g_sw_inv) < 12;' } + +image: + # Space Wars + - id: img_spacebg + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/space-wars/spacebg.png" + type: RGB565 + - id: img_logo2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/space-wars/logo2.png" + type: RGB565 + - id: img_sp + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/space-wars/player.png" + type: RGB565 + transparency: alpha_channel + - id: img_se1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/space-wars/enemy1.png" + type: RGB565 + transparency: alpha_channel + - id: img_se2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/space-wars/enemy2.png" + type: RGB565 + transparency: alpha_channel + - id: img_exp0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/space-wars/exp0.png" + type: RGB565 + transparency: alpha_channel + - id: img_exp1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/space-wars/exp1.png" + type: RGB565 + transparency: alpha_channel + - id: img_exp2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/space-wars/exp2.png" + type: RGB565 + transparency: alpha_channel + - id: img_exp3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/space-wars/exp3.png" + type: RGB565 + transparency: alpha_channel + - id: img_exp4 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/space-wars/exp4.png" + type: RGB565 + transparency: alpha_channel + - id: img_exp5 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/space-wars/exp5.png" + type: RGB565 + transparency: alpha_channel + +lvgl: + pages: + # --- SPACE WARS page (game 2): vertical shooter --- + - id: page_game2 + bg_color: 0x140A20 + scrollbar_mode: "off" + scrollable: false + widgets: + - image: { id: sw_bg, align: CENTER, x: 0, y: 0, src: img_spacebg, hidden: true } + - image: { id: sw_logo, align: CENTER, x: 0, y: 0, src: img_logo2, hidden: true } + - obj: { id: b0, align: CENTER, x: 0, y: 0, width: 4, height: 14, radius: 2, border_width: 0, bg_color: 0xFFE45A, hidden: true } + - obj: { id: b1, align: CENTER, x: 0, y: 0, width: 4, height: 14, radius: 2, border_width: 0, bg_color: 0xFFE45A, hidden: true } + - obj: { id: eb0, align: CENTER, x: 0, y: 0, width: 5, height: 14, radius: 2, border_width: 0, bg_color: 0xFF5050, hidden: true } + - obj: { id: eb1, align: CENTER, x: 0, y: 0, width: 5, height: 14, radius: 2, border_width: 0, bg_color: 0xFF5050, hidden: true } + - image: { id: en0, align: CENTER, x: -60, y: -150, src: img_se1, hidden: true } + - image: { id: en1, align: CENTER, x: 60, y: -150, src: img_se1, hidden: true } + - image: { id: en2, align: CENTER, x: 0, y: -150, src: img_se2, hidden: true } + - image: { id: sw_ship, align: CENTER, x: 0, y: 130, src: img_sp, hidden: true } + - animimg: + id: sw_exp + align: CENTER + x: 0 + y: 0 + src: [img_exp0, img_exp1, img_exp2, img_exp3, img_exp4, img_exp5] + duration: 420ms + repeat_count: 1 + auto_start: false + hidden: true + - label: { id: lbl_sw_lives, align: CENTER, x: -54, y: -132, text: "", text_font: icons18, text_color: 0xE24B4A, hidden: true } + - label: { id: lbl_sw_score, align: CENTER, x: 54, y: -132, text: "0", text_font: font_med, text_color: 0xCFEAE4, hidden: true } + - obj: { id: sw_flash, align: CENTER, x: 0, y: 0, width: 360, height: 360, radius: 0, border_width: 0, bg_color: 0xFF2A2A, bg_opa: 45%, hidden: true } + # MENU + - button: + id: btn_sw_start + align: CENTER + y: 4 + width: 150 + height: 44 + radius: 22 + bg_color: 0xFF7A4D + bg_opa: COVER + border_width: 0 + on_click: [ script.execute: sw_start ] + widgets: [ label: { align: CENTER, text: "Start", text_font: font_med, text_color: 0x2A0E06 } ] + - button: + id: btn_sw_howto + align: CENTER + y: 54 + width: 150 + height: 44 + radius: 22 + bg_color: 0x3A2150 + bg_opa: COVER + border_width: 0 + on_click: [ lambda: 'id(g_sw_state)=3;', script.execute: sw_screen ] + widgets: [ label: { align: CENTER, text: "How to play?", text_font: font_med, text_color: 0xFFFFFF } ] + - button: + id: btn_sw_scores + align: CENTER + y: 104 + width: 150 + height: 44 + radius: 22 + bg_color: 0x3A2150 + bg_opa: COVER + border_width: 0 + on_click: [ lambda: 'id(g_sw_state)=4;', script.execute: sw_screen ] + widgets: [ label: { align: CENTER, text: "Scores", text_font: font_med, text_color: 0xFFFFFF } ] + # GAME OVER + - label: { id: lbl_sw_over, align: CENTER, x: 0, y: -10, text_align: CENTER, text: "", text_font: font_sel, text_color: 0xFFFFFF, hidden: true } + - label: { id: lbl_sw_rec, align: CENTER, x: 0, y: 22, text_align: CENTER, text: "", text_font: font_artist, text_color: 0x9AA6B2, hidden: true } + - button: + id: btn_sw_replay + align: CENTER + x: -66 + y: 62 + width: 112 + height: 48 + radius: 24 + bg_color: 0xFF7A4D + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ script.execute: sw_start ] + widgets: [ label: { align: CENTER, text: "Play", text_font: font_med, text_color: 0x2A0E06 } ] + - button: + id: btn_sw_exit + align: CENTER + x: 66 + y: 62 + width: 112 + height: 48 + radius: 24 + bg_color: 0x3A3A3D + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ lambda: 'id(g_sw_state)=0;', script.execute: sw_screen ] + widgets: [ label: { align: CENTER, text: "Exit", text_font: font_med, text_color: 0xFFFFFF } ] + # HOW TO PLAY + - label: { id: lbl_sw_ht_t, align: CENTER, y: -104, text: "HOW TO PLAY?", text_font: font_sel, text_color: 0xFF7A4D, hidden: true } + - obj: { id: badge_h1, align: CENTER, x: -90, y: -46, width: 24, height: 24, radius: 12, bg_color: 0xFF7A4D, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "1", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: badge_h2, align: CENTER, x: -90, y: -10, width: 24, height: 24, radius: 12, bg_color: 0xFF7A4D, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "2", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: badge_h3, align: CENTER, x: -90, y: 26, width: 24, height: 24, radius: 12, bg_color: 0xFF7A4D, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "3", text_font: font_artist, text_color: 0xFFFFFF } ] } + - obj: { id: badge_h4, align: CENTER, x: -90, y: 62, width: 24, height: 24, radius: 12, bg_color: 0xFF7A4D, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: { align: CENTER, text: "4", text_font: font_artist, text_color: 0xFFFFFF } ] } + - label: { id: lbl_sw_ht1, align_to: { id: badge_h1, align: OUT_RIGHT_MID, x: 8 }, text: "Use the knob", text_font: font_med, text_color: 0xC2CDDA, hidden: true } + - label: { id: lbl_sw_ht2, align_to: { id: badge_h2, align: OUT_RIGHT_MID, x: 8 }, text: "Dodge bullets", text_font: font_med, text_color: 0xC2CDDA, hidden: true } + - label: { id: lbl_sw_ht3, align_to: { id: badge_h3, align: OUT_RIGHT_MID, x: 8 }, text: "Destroy enemies", text_font: font_med, text_color: 0xC2CDDA, hidden: true } + - label: { id: lbl_sw_ht4, align_to: { id: badge_h4, align: OUT_RIGHT_MID, x: 8 }, text: "You have 3 lives", text_font: font_med, text_color: 0xC2CDDA, hidden: true } + - label: { id: lbl_sw_hheart, align_to: { id: lbl_sw_ht4, align: OUT_RIGHT_MID, x: 6 }, text: "󰋑󰋑󰋑", text_font: icons18, text_color: 0xE24B4A, hidden: true } + # SCORES + - label: { id: lbl_sw_st, align: CENTER, y: -116, text: "SCORES", text_font: font_sel, text_color: 0xFF7A4D, hidden: true } + - label: { id: lbl_sw_s1, align: CENTER, x: -50, y: -6, text: "", text_align: LEFT, text_font: font_artist, text_color: 0xC2CDDA, hidden: true } + - label: { id: lbl_sw_s2, align: CENTER, x: 50, y: -6, text: "", text_align: LEFT, text_font: font_artist, text_color: 0xC2CDDA, hidden: true } + - button: + id: btn_sw_back + align: CENTER + y: 120 + width: 130 + height: 42 + radius: 21 + bg_color: 0x3A3A3D + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ lambda: 'id(g_sw_state)=0;', script.execute: sw_screen ] + widgets: [ label: { align: CENTER, text: "Back", text_font: font_med, text_color: 0xFFFFFF } ] diff --git a/base/screens/thermostat.yaml b/base/screens/thermostat.yaml new file mode 100644 index 0000000..c6e6c69 --- /dev/null +++ b/base/screens/thermostat.yaml @@ -0,0 +1,298 @@ +# Thermostat - optional carousel screen (base id 7). +# A round dial for a Home Assistant climate.* entity (${climate_entity}): +# * big centre = the target temperature; turn the KNOB to change it (by the entity's +# target_temp_step). The new value is sent to HA ~0.6 s after you stop turning. +# * "now ..." = the current room temperature; "RH ..%" = humidity (if the entity has it). +# * top label + the target colour show the action: heating (orange) / cooling (blue) / +# idle (teal) / off (grey). Bottom chip = the HVAC mode. +# * TAP toggles the climate on/off. +# +# Set ${climate_entity} in your config. Remove this screen by dropping the file from the +# package "files:" list. + +esphome: + on_boot: + - priority: 655 + then: + - lambda: 'id(g_present)[7] = true;' # register the thermostat screen + +globals: + - id: g_th_cur # current temperature (-127 = no reading yet) + type: float + restore_value: no + initial_value: '-127.0' + - id: g_th_target # target temperature (synced from HA; edited locally by the knob) + type: float + restore_value: no + initial_value: '20.0' + - id: g_th_action # hvac_action: heating / cooling / idle / off (may be empty) + type: std::string + restore_value: no + - id: g_th_mode # hvac mode (the entity state): off / heat / cool / auto / heat_cool + type: std::string + restore_value: no + - id: g_th_hum # current humidity % (-1 = none) + type: int + restore_value: no + initial_value: '-1' + - id: g_th_min + type: float + restore_value: no + initial_value: '7.0' + - id: g_th_max + type: float + restore_value: no + initial_value: '35.0' + - id: g_th_step + type: float + restore_value: no + initial_value: '0.5' + - id: g_th_pending # true = local edit not yet sent; blocks HA from overwriting the target + type: bool + restore_value: no + initial_value: 'false' + - id: g_th_lasttap # last g_tap_seq we handled (one tap = one on/off toggle) + type: int + restore_value: no + initial_value: '0' + +script: + # Sends the edited target to HA, but only ~0.6 s after the last knob turn (mode: restart). + - id: th_commit + mode: restart + then: + - delay: 600ms + - homeassistant.action: + action: climate.set_temperature + data: + entity_id: ${climate_entity} + temperature: !lambda 'char b[8]; snprintf(b, sizeof(b), "%.1f", id(g_th_target)); return std::string(b);' + - lambda: 'id(g_th_pending) = false;' + # Redraws the dial from the state globals. + - id: th_render + then: + - lvgl.label.update: + id: th_target + text: !lambda 'char b[12]; snprintf(b, sizeof(b), "%.1f°", id(g_th_target)); return std::string(b);' + - lvgl.label.update: + id: th_cur + text: !lambda |- + if (id(g_th_cur) < -100) return std::string("now --"); + char b[16]; snprintf(b, sizeof(b), "now %.1f°", id(g_th_cur)); return std::string(b); + - lvgl.widget.update: { id: th_hum, hidden: !lambda 'return id(g_th_hum) < 0;' } + - lvgl.label.update: + id: th_hum + text: !lambda |- + if (id(g_th_hum) < 0) return std::string(""); + char b[12]; snprintf(b, sizeof(b), "RH %d%%", id(g_th_hum)); return std::string(b); + - lvgl.label.update: + id: th_mode + text: !lambda |- + std::string m = id(g_th_mode); + if (m == "heat_cool") return std::string("Auto"); + if (m.empty()) return std::string("-"); + if (m[0] >= 'a' && m[0] <= 'z') m[0] -= 32; return m; + - lvgl.arc.update: + id: th_arc + value: !lambda |- + float r = id(g_th_max) - id(g_th_min); + if (r <= 0) return 0.0f; + float p = 100.0f * (id(g_th_target) - id(g_th_min)) / r; + if (p < 0) p = 0; if (p > 100) p = 100; + return p; + - lvgl.label.update: + id: th_action + text: !lambda |- + std::string m = id(g_th_mode), a = id(g_th_action); + if (m == "off" || m.empty()) return std::string("OFF"); + if (a.find("heat") != std::string::npos || (a.empty() && m == "heat")) return std::string("HEATING"); + if (a.find("cool") != std::string::npos || (a.empty() && m == "cool")) return std::string("COOLING"); + if (a == "idle") return std::string("IDLE"); + if (!a.empty()) { if (a[0] >= 'a' && a[0] <= 'z') a[0] -= 32; return a; } + if (m[0] >= 'a' && m[0] <= 'z') m[0] -= 32; return m; + # accent colour for the action label + target by heat/cool/off/idle + - if: + condition: + lambda: 'return id(g_th_mode) != "off" && !id(g_th_mode).empty() && (id(g_th_action).find("heat") != std::string::npos || (id(g_th_action).empty() && id(g_th_mode) == "heat"));' + then: + - lvgl.label.update: { id: th_action, text_color: 0xFF6B30 } + - lvgl.label.update: { id: th_target, text_color: 0xFF6B30 } + - lvgl.arc.update: { id: th_arc, indicator: { arc_color: 0xFF6B30 } } + else: + - if: + condition: + lambda: 'return id(g_th_mode) != "off" && !id(g_th_mode).empty() && (id(g_th_action).find("cool") != std::string::npos || (id(g_th_action).empty() && id(g_th_mode) == "cool"));' + then: + - lvgl.label.update: { id: th_action, text_color: 0x3FA9D6 } + - lvgl.label.update: { id: th_target, text_color: 0x3FA9D6 } + - lvgl.arc.update: { id: th_arc, indicator: { arc_color: 0x3FA9D6 } } + else: + - if: + condition: { lambda: 'return id(g_th_mode) == "off" || id(g_th_mode).empty();' } + then: + - lvgl.label.update: { id: th_action, text_color: 0x6F9A92 } + - lvgl.label.update: { id: th_target, text_color: 0x6F9A92 } + - lvgl.arc.update: { id: th_arc, indicator: { arc_color: 0x44506A } } + else: + - lvgl.label.update: { id: th_action, text_color: 0x9FC7BE } + - lvgl.label.update: { id: th_target, text_color: 0xFFFFFF } + - lvgl.arc.update: { id: th_arc, indicator: { arc_color: 0x2FE6CF } } + - lambda: |- # position the target head-dot on the gauge + float r = id(g_th_max) - id(g_th_min); + float p = (r > 0) ? 100.0f * (id(g_th_target) - id(g_th_min)) / r : 0.0f; + if (p < 0) p = 0; if (p > 100) p = 100; + float a = (135.0f + p / 100.0f * 270.0f) * 0.01745329f; + int x = 180 + (int) lroundf(144.0f * cosf(a)) - 9; + int y = 180 + (int) lroundf(144.0f * sinf(a)) - 9; + lv_obj_set_pos(id(th_dot), x, y); + +sensor: + - platform: homeassistant + id: th_cur_s + entity_id: ${climate_entity} + attribute: current_temperature + on_value: + - lambda: 'id(g_th_cur) = x;' + - script.execute: th_render + - platform: homeassistant + id: th_tgt_s + entity_id: ${climate_entity} + attribute: temperature + on_value: + - lambda: 'if (!id(g_th_pending)) id(g_th_target) = x;' # don't clobber a value being edited + - script.execute: th_render + - platform: homeassistant + id: th_hum_s + entity_id: ${climate_entity} + attribute: current_humidity + on_value: + - lambda: 'id(g_th_hum) = (int) roundf(x);' + - script.execute: th_render + - platform: homeassistant + id: th_min_s + entity_id: ${climate_entity} + attribute: min_temp + on_value: [ lambda: 'id(g_th_min) = x;' ] + - platform: homeassistant + id: th_max_s + entity_id: ${climate_entity} + attribute: max_temp + on_value: [ lambda: 'id(g_th_max) = x;' ] + - platform: homeassistant + id: th_step_s + entity_id: ${climate_entity} + attribute: target_temp_step + on_value: [ lambda: 'if (x > 0) id(g_th_step) = x;' ] + +text_sensor: + - platform: homeassistant + id: th_mode_s + entity_id: ${climate_entity} # the entity state IS the hvac mode + on_value: + - lambda: 'id(g_th_mode) = x;' + - script.execute: th_render + - platform: homeassistant + id: th_action_s + entity_id: ${climate_entity} + attribute: hvac_action + on_value: + - lambda: 'id(g_th_action) = x;' + - script.execute: th_render + +interval: + # Carousel nav: when selected, show the page and take the knob. + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_nav_req) && id(g_base)==7;' } + then: + - lambda: 'id(g_nav_req) = false; id(g_knob_capture) = true; id(g_th_lasttap) = id(g_tap_seq);' + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_thermostat, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_thermostat, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_thermostat, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_thermostat, animation: move_bottom, time: 250ms }] } + - script.execute: th_render + # Knob (adjust target, debounced commit) + tap (on/off) while this screen is on top. + - interval: 120ms + then: + - if: + condition: { lambda: 'return id(g_base)==7 && id(g_knob_delta) != 0;' } + then: + - lambda: |- + id(g_th_target) += id(g_knob_delta) * id(g_th_step); + id(g_knob_delta) = 0; + if (id(g_th_target) < id(g_th_min)) id(g_th_target) = id(g_th_min); + if (id(g_th_target) > id(g_th_max)) id(g_th_target) = id(g_th_max); + id(g_th_pending) = true; + - script.execute: th_commit + - script.execute: th_render + - if: + condition: { lambda: 'return id(g_base)==7 && id(g_tap_seq) != id(g_th_lasttap);' } + then: + - lambda: 'id(g_th_lasttap) = id(g_tap_seq);' + - if: + condition: { lambda: 'return id(g_th_mode) == "off";' } + then: + - homeassistant.action: + action: climate.turn_on + data: + entity_id: ${climate_entity} + else: + - homeassistant.action: + action: climate.turn_off + data: + entity_id: ${climate_entity} + +lvgl: + pages: + - id: page_thermostat + bg_color: 0x0B1020 + widgets: + - obj: # faint outer ring for depth + id: th_ring_out + align: CENTER + width: 332 + height: 332 + radius: 166 + bg_opa: TRANSP + border_width: 2 + border_color: 0x1B2740 + border_opa: 70% + clickable: false + scrollable: false + - arc: + id: th_arc + align: CENTER + width: 300 + height: 300 + arc_width: 12 + adjustable: false + min_value: 0 + max_value: 100 + value: 50 + start_angle: 135 + end_angle: 45 + arc_color: 0x21304A + indicator: + arc_color: 0x2FE6CF + arc_width: 12 + - obj: # head-dot: white bead marking the target on the gauge (positioned in th_render) + id: th_dot + x: 0 + y: 0 + width: 18 + height: 18 + radius: 9 + bg_color: 0xFFFFFF + bg_opa: COVER + border_width: 0 + shadow_color: 0xFFFFFF + shadow_width: 10 + shadow_opa: 55% + clickable: false + scrollable: false + - label: { id: th_action, align: CENTER, y: -86, text: "", text_font: font_artist, text_color: 0x9FC7BE } + - label: { id: th_target, align: CENTER, y: -14, text: "--", text_font: montserrat_48, text_color: 0xFFFFFF } + - label: { id: th_cur, align: CENTER, y: 30, text: "now --", text_font: font_med, text_color: 0x9FC7BE } + - label: { id: th_hum, align: CENTER, y: 58, text: "", text_font: font_artist, text_color: 0x3FA9D6, hidden: true } + - label: { id: th_mode, align: CENTER, y: 92, text: "", text_font: font_artist, text_color: 0xCFEAE4 } diff --git a/base/screens/timer.yaml b/base/screens/timer.yaml new file mode 100644 index 0000000..f10c97d --- /dev/null +++ b/base/screens/timer.yaml @@ -0,0 +1,17 @@ +# Timer carousel-screen toggle. +# +# Unlike the other screens, the timer cannot be a full standalone package: its logic is +# wired into core components (the voice_assistant on_timer_* triggers, the shared alarm +# overlay, timer_render, the home-screen badge, the knob). So the timer FEATURE always +# lives in base/core.yaml. This tiny package only adds the timer SCREEN to the swipe +# carousel (base id 2). +# +# Remove this file from your config's package "files:" list to hide the timer screen from +# the carousel. Voice timers ("set a 5 minute timer"), the alarm and the home-screen +# countdown badge keep working - you just can't swipe to the on-device timer screen. + +esphome: + on_boot: + - priority: 680 + then: + - lambda: 'id(g_present)[2] = true;' # add the timer screen to the carousel diff --git a/base/screens/weather.ha-helper.yaml b/base/screens/weather.ha-helper.yaml new file mode 100644 index 0000000..7893c42 --- /dev/null +++ b/base/screens/weather.ha-helper.yaml @@ -0,0 +1,37 @@ +# Home Assistant helper for the device's Weather screen (base/screens/weather.yaml). +# +# HA no longer exposes a weather entity's multi-day forecast as an attribute, so this +# template calls weather.get_forecasts and publishes a single compact sensor the device +# reads. State format: "condition,high,low,DayName|..." (one segment per day, up to 7). +# +# 1. Change weather.forecast_dom below to your weather entity (3 places). +# 2. Put this in HA configuration.yaml (or a packages/ file), then restart HA / reload +# template entities. It creates sensor.va_weekly_forecast. +# 3. On the device, ${forecast_entity} defaults to sensor.va_weekly_forecast - override +# the substitution if you name it differently. + +template: + - trigger: + - platform: homeassistant + event: start + - platform: time_pattern + minutes: "/30" + action: + - service: weather.get_forecasts + target: + entity_id: weather.forecast_dom + data: + type: daily + response_variable: fc + sensor: + - name: "VA weekly forecast" + unique_id: va_weekly_forecast + state: > + {% set ns = namespace(parts=[]) %} + {% for d in fc['weather.forecast_dom'].forecast[:7] %} + {% set hi = (d.temperature | round(0)) if d.temperature is not none else 0 %} + {% set lo = (d.templow | round(0)) if (d.templow is defined and d.templow is not none) else hi %} + {% set nm = as_timestamp(d.datetime) | timestamp_custom('%a', true) %} + {% set ns.parts = ns.parts + [d.condition ~ ',' ~ hi ~ ',' ~ lo ~ ',' ~ nm] %} + {% endfor %} + {{ ns.parts | join('|') }} diff --git a/base/screens/weather.yaml b/base/screens/weather.yaml new file mode 100644 index 0000000..4e0fff6 --- /dev/null +++ b/base/screens/weather.yaml @@ -0,0 +1,541 @@ +# Weather - carousel screen (base id 5). GENERATED by scripts/gen_weather.py. +# A dial of forecast days around the rim (knob scrolls), animated condition icon + colour-coded +# temperature in the centre, a glow that slides to the selected day. Forecast comes from a Home +# Assistant helper sensor (see base/screens/weather.ha-helper.yaml). Remove: drop from `files:`. + +esphome: + on_boot: + - priority: 655 + then: + - lambda: 'id(g_present)[5] = true;' + - priority: 650 + then: + - lambda: |- + int g = id(g_wgrp_n)++; + id(g_wgrp_labels) += (g ? "\n" : ""); id(g_wgrp_labels) += "Weather"; + int o = id(g_wopt_n)++; + id(g_wopt_group)[o] = g; id(g_wopt_label)[o] = "Fahrenheit"; id(g_wopt_kind)[o] = 0; id(g_wopt_ptr)[o] = &id(g_wx_fahrenheit); + +globals: + # --- forecast data --- + - id: g_wx_fahrenheit + type: bool + restore_value: yes + initial_value: 'false' + - id: g_wx_now + type: int + restore_value: no + initial_value: '-127' + - id: g_wx_state_cur + type: std::string + restore_value: no + - id: g_wx_cond + type: std::string[7] + restore_value: no + - id: g_wx_icon + type: std::string[7] + restore_value: no + - id: g_wx_name + type: std::string[7] + restore_value: no + - id: g_wx_hi + type: int[7] + restore_value: no + - id: g_wx_lo + type: int[7] + restore_value: no + - id: g_wx_n + type: int + restore_value: no + initial_value: '0' + - id: g_wx_day + type: int + restore_value: no + initial_value: '0' + # --- render cache --- + - id: g_wx_sel + type: std::string + restore_value: no + - id: g_wx_tc + type: int + restore_value: no + initial_value: '-127' + - id: g_wx_lastcond + type: std::string + restore_value: no + - id: g_wx_ikey + type: std::string + restore_value: no + - id: g_wx_gpos + type: float + restore_value: no + initial_value: '0.0' + +font: + - file: "https://github.com/Templarian/MaterialDesign-Webfont/raw/v7.4.47/fonts/materialdesignicons-webfont.ttf" + id: icons_wx + size: 30 + bpp: 4 + glyphs: ["󰖙", "󰖔", "󰖕", "󰖑", "󰖝", "󰖓", "󰖖", "󰖗", "󰖘", "󰖐"] + +image: + - id: img_wx_sun0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/sun0.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_sun1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/sun1.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_sun2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/sun2.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_sun3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/sun3.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_sun4 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/sun4.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_sun5 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/sun5.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_sun6 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/sun6.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_sun7 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/sun7.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_partly0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/partly0.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_partly1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/partly1.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_partly2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/partly2.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_partly3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/partly3.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_partly4 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/partly4.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_partly5 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/partly5.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_partly6 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/partly6.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_partly7 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/partly7.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_moon0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/moon0.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_moon1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/moon1.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_moon2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/moon2.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_moon3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/moon3.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_moon4 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/moon4.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_moon5 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/moon5.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_moon6 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/moon6.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_moon7 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/moon7.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_cloud0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/cloud0.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_cloud1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/cloud1.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_cloud2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/cloud2.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_cloud3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/cloud3.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_cloud4 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/cloud4.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_cloud5 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/cloud5.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_cloud6 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/cloud6.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_cloud7 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/cloud7.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_rain0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/rain0.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_rain1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/rain1.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_rain2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/rain2.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_rain3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/rain3.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_rain4 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/rain4.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_rain5 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/rain5.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_rain6 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/rain6.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_rain7 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/rain7.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_storm0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/storm0.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_storm1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/storm1.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_storm2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/storm2.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_storm3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/storm3.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_storm4 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/storm4.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_storm5 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/storm5.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_storm6 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/storm6.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_storm7 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/storm7.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_snow0 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/snow0.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_snow1 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/snow1.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_snow2 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/snow2.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_snow3 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/snow3.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_snow4 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/snow4.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_snow5 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/snow5.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_snow6 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/snow6.png" + type: RGB565 + transparency: alpha_channel + - id: img_wx_snow7 + file: "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/snow7.png" + type: RGB565 + transparency: alpha_channel + +script: + - id: wx_render + then: + - lambda: |- + int d = id(g_wx_day); + bool has = (id(g_wx_n) > 0 && d < id(g_wx_n)); + id(g_wx_sel) = has ? id(g_wx_cond)[d] : id(g_wx_state_cur); + id(g_wx_tc) = has ? id(g_wx_hi)[d] : id(g_wx_now); + - lvgl.label.update: + id: wx_day + text: !lambda |- + int d = id(g_wx_day); + if (id(g_wx_n) <= 0 || d >= id(g_wx_n)) return std::string("TODAY"); + std::string s = id(g_wx_name)[d]; + if (s == "Mon") return std::string("MONDAY"); + if (s == "Tue") return std::string("TUESDAY"); + if (s == "Wed") return std::string("WEDNESDAY"); + if (s == "Thu") return std::string("THURSDAY"); + if (s == "Fri") return std::string("FRIDAY"); + if (s == "Sat") return std::string("SATURDAY"); + if (s == "Sun") return std::string("SUNDAY"); + return s; + - lvgl.label.update: + id: wx_htemp + text: !lambda |- + int d = id(g_wx_day); + bool f = id(g_wx_fahrenheit); + char u = f ? 'F' : 'C'; + if (id(g_wx_n) <= 0 || d >= id(g_wx_n)) { + if (id(g_wx_now) > -100) { int n = id(g_wx_now); if (f) n = n*9/5+32; char b[12]; snprintf(b,sizeof(b),"%d°%c",n,u); return std::string(b); } + return std::string("--"); + } + int hi = id(g_wx_hi)[d], lo = id(g_wx_lo)[d]; + if (f) { hi = hi*9/5+32; lo = lo*9/5+32; } + char b[24]; snprintf(b,sizeof(b),"%d° / %d°",hi,lo); return std::string(b); + - lvgl.label.update: + id: wx_cond + text: !lambda |- + if (id(g_wx_n) <= 0) return std::string("Add the HA forecast helper"); + std::string s = id(g_wx_sel); + if (s == "sunny") return std::string("Sunny"); + if (s == "clear-night") return std::string("Clear"); + if (s == "partlycloudy") return std::string("Partly Cloudy"); + if (s == "cloudy") return std::string("Cloudy"); + if (s == "fog") return std::string("Fog"); + if (s == "rainy") return std::string("Rainy"); + if (s == "pouring") return std::string("Heavy Rain"); + if (s == "lightning" || s == "lightning-rainy") return std::string("Storm"); + if (s == "snowy" || s == "snowy-rainy") return std::string("Snow"); + if (s == "hail") return std::string("Hail"); + if (s == "windy" || s == "windy-variant") return std::string("Windy"); + if (s == "exceptional") return std::string("Severe"); + return s; + - if: + condition: { lambda: 'return id(g_wx_sel) != id(g_wx_lastcond);' } + then: + - lambda: |- + id(g_wx_lastcond) = id(g_wx_sel); + std::string s = id(g_wx_sel), k; + if (s == "sunny") k = "sun"; + else if (s == "clear-night") k = "moon"; + else if (s == "partlycloudy") k = "partly"; + else if (s == "rainy" || s == "pouring") k = "rain"; + else if (s == "lightning" || s == "lightning-rainy") k = "storm"; + else if (s == "snowy" || s == "snowy-rainy" || s == "hail") k = "snow"; + else k = "cloud"; + id(g_wx_ikey) = k; + - lvgl.animimg.stop: wx_a_sun + - lvgl.animimg.stop: wx_a_partly + - lvgl.animimg.stop: wx_a_moon + - lvgl.animimg.stop: wx_a_cloud + - lvgl.animimg.stop: wx_a_rain + - lvgl.animimg.stop: wx_a_storm + - lvgl.animimg.stop: wx_a_snow + - lvgl.widget.hide: wx_a_sun + - lvgl.widget.hide: wx_a_partly + - lvgl.widget.hide: wx_a_moon + - lvgl.widget.hide: wx_a_cloud + - lvgl.widget.hide: wx_a_rain + - lvgl.widget.hide: wx_a_storm + - lvgl.widget.hide: wx_a_snow + - if: { condition: { lambda: 'return id(g_wx_ikey) == "sun";' }, then: [ lvgl.widget.show: wx_a_sun, lvgl.animimg.start: wx_a_sun ] } + - if: { condition: { lambda: 'return id(g_wx_ikey) == "partly";' }, then: [ lvgl.widget.show: wx_a_partly, lvgl.animimg.start: wx_a_partly ] } + - if: { condition: { lambda: 'return id(g_wx_ikey) == "moon";' }, then: [ lvgl.widget.show: wx_a_moon, lvgl.animimg.start: wx_a_moon ] } + - if: { condition: { lambda: 'return id(g_wx_ikey) == "cloud";' }, then: [ lvgl.widget.show: wx_a_cloud, lvgl.animimg.start: wx_a_cloud ] } + - if: { condition: { lambda: 'return id(g_wx_ikey) == "rain";' }, then: [ lvgl.widget.show: wx_a_rain, lvgl.animimg.start: wx_a_rain ] } + - if: { condition: { lambda: 'return id(g_wx_ikey) == "storm";' }, then: [ lvgl.widget.show: wx_a_storm, lvgl.animimg.start: wx_a_storm ] } + - if: { condition: { lambda: 'return id(g_wx_ikey) == "snow";' }, then: [ lvgl.widget.show: wx_a_snow, lvgl.animimg.start: wx_a_snow ] } + - lvgl.widget.update: { id: wx_glow, hidden: !lambda 'return id(g_wx_n) <= 0;' } + - lvgl.widget.update: { id: wx_d0, hidden: !lambda 'return 0 >= id(g_wx_n);', x: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(140.0f * sinf(0 * 6.2831853f / n));', y: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(-140.0f * cosf(0 * 6.2831853f / n));' } + - lvgl.label.update: { id: wx_d0, text: !lambda 'return id(g_wx_n) > 0 ? id(g_wx_icon)[0] : std::string("");' } + - lvgl.widget.update: { id: wx_d1, hidden: !lambda 'return 1 >= id(g_wx_n);', x: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(140.0f * sinf(1 * 6.2831853f / n));', y: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(-140.0f * cosf(1 * 6.2831853f / n));' } + - lvgl.label.update: { id: wx_d1, text: !lambda 'return id(g_wx_n) > 1 ? id(g_wx_icon)[1] : std::string("");' } + - lvgl.widget.update: { id: wx_d2, hidden: !lambda 'return 2 >= id(g_wx_n);', x: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(140.0f * sinf(2 * 6.2831853f / n));', y: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(-140.0f * cosf(2 * 6.2831853f / n));' } + - lvgl.label.update: { id: wx_d2, text: !lambda 'return id(g_wx_n) > 2 ? id(g_wx_icon)[2] : std::string("");' } + - lvgl.widget.update: { id: wx_d3, hidden: !lambda 'return 3 >= id(g_wx_n);', x: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(140.0f * sinf(3 * 6.2831853f / n));', y: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(-140.0f * cosf(3 * 6.2831853f / n));' } + - lvgl.label.update: { id: wx_d3, text: !lambda 'return id(g_wx_n) > 3 ? id(g_wx_icon)[3] : std::string("");' } + - lvgl.widget.update: { id: wx_d4, hidden: !lambda 'return 4 >= id(g_wx_n);', x: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(140.0f * sinf(4 * 6.2831853f / n));', y: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(-140.0f * cosf(4 * 6.2831853f / n));' } + - lvgl.label.update: { id: wx_d4, text: !lambda 'return id(g_wx_n) > 4 ? id(g_wx_icon)[4] : std::string("");' } + - lvgl.widget.update: { id: wx_d5, hidden: !lambda 'return 5 >= id(g_wx_n);', x: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(140.0f * sinf(5 * 6.2831853f / n));', y: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(-140.0f * cosf(5 * 6.2831853f / n));' } + - lvgl.label.update: { id: wx_d5, text: !lambda 'return id(g_wx_n) > 5 ? id(g_wx_icon)[5] : std::string("");' } + - lvgl.widget.update: { id: wx_d6, hidden: !lambda 'return 6 >= id(g_wx_n);', x: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(140.0f * sinf(6 * 6.2831853f / n));', y: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(-140.0f * cosf(6 * 6.2831853f / n));' } + - lvgl.label.update: { id: wx_d6, text: !lambda 'return id(g_wx_n) > 6 ? id(g_wx_icon)[6] : std::string("");' } + - lvgl.label.update: { id: wx_htemp, text_color: 0xFFFFFF } + - if: { condition: { lambda: 'return id(g_wx_tc) > -100 && id(g_wx_tc) <= 0;' }, then: [ lvgl.label.update: { id: wx_htemp, text_color: 0x7FB2FF } ] } + - if: { condition: { lambda: 'return id(g_wx_tc) > 0 && id(g_wx_tc) < 10;' }, then: [ lvgl.label.update: { id: wx_htemp, text_color: 0x6FD0E8 } ] } + - if: { condition: { lambda: 'return id(g_wx_tc) >= 10 && id(g_wx_tc) < 18;' }, then: [ lvgl.label.update: { id: wx_htemp, text_color: 0x7FE6B0 } ] } + - if: { condition: { lambda: 'return id(g_wx_tc) >= 18 && id(g_wx_tc) < 25;' }, then: [ lvgl.label.update: { id: wx_htemp, text_color: 0xFFD061 } ] } + - if: { condition: { lambda: 'return id(g_wx_tc) >= 25;' }, then: [ lvgl.label.update: { id: wx_htemp, text_color: 0xFF7A4D } ] } + +sensor: + - platform: homeassistant + id: wx_now_temp + entity_id: ${weather_entity} + attribute: temperature + on_value: + - lambda: 'id(g_wx_now) = (int) roundf(x);' + - script.execute: wx_render + +text_sensor: + - platform: homeassistant + id: wx_now_state + entity_id: ${weather_entity} + on_value: + - lambda: 'id(g_wx_state_cur) = x;' + - script.execute: wx_render + - platform: homeassistant + id: wx_week + entity_id: ${forecast_entity} + on_value: + - lambda: |- + auto glyph = [](const std::string &s) -> std::string { + if (s == "sunny") return std::string("󰖙"); + if (s == "clear-night") return std::string("󰖔"); + if (s == "partlycloudy") return std::string("󰖕"); + if (s == "fog") return std::string("󰖑"); + if (s == "windy" || s == "windy-variant") return std::string("󰖝"); + if (s == "lightning" || s == "lightning-rainy") return std::string("󰖓"); + if (s == "pouring") return std::string("󰖖"); + if (s == "rainy") return std::string("󰖗"); + if (s == "snowy" || s == "snowy-rainy" || s == "hail") return std::string("󰖘"); + return std::string("󰖐"); + }; + std::string s = x; + for (int i = 0; i < 7; i++) { id(g_wx_icon)[i] = ""; id(g_wx_cond)[i] = ""; } + int n = 0; size_t pos = 0; + while (n < 7) { + size_t bar = s.find('|', pos); + std::string seg = (bar == std::string::npos) ? s.substr(pos) : s.substr(pos, bar - pos); + size_t c1 = seg.find(','); + if (c1 != std::string::npos) { + size_t c2 = seg.find(',', c1 + 1); + size_t c3 = (c2 != std::string::npos) ? seg.find(',', c2 + 1) : std::string::npos; + if (c2 != std::string::npos && c3 != std::string::npos) { + std::string cond = seg.substr(0, c1); + id(g_wx_cond)[n] = cond; + id(g_wx_icon)[n] = glyph(cond); + id(g_wx_hi)[n] = atoi(seg.substr(c1 + 1, c2 - c1 - 1).c_str()); + id(g_wx_lo)[n] = atoi(seg.substr(c2 + 1, c3 - c2 - 1).c_str()); + id(g_wx_name)[n] = seg.substr(c3 + 1); + n++; + } + } + if (bar == std::string::npos) break; + pos = bar + 1; + } + id(g_wx_n) = n; + if (id(g_wx_day) >= n) id(g_wx_day) = 0; + - script.execute: wx_render + +interval: + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_nav_req) && id(g_base)==5;' } + then: + - lambda: 'id(g_nav_req) = false; id(g_knob_capture) = true; id(g_wx_day) = 0; id(g_wx_gpos) = 0.0f; id(g_wx_lastcond) = "";' + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_weather, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_weather, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_weather, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_weather, animation: move_bottom, time: 250ms }] } + - script.execute: wx_render + - interval: 120ms + then: + - if: + condition: { lambda: 'return id(g_base)==5 && id(g_knob_delta) != 0;' } + then: + - lambda: |- + id(g_wx_day) += id(g_knob_delta); id(g_knob_delta) = 0; + int mx = (id(g_wx_n) > 0) ? id(g_wx_n) - 1 : 0; + if (id(g_wx_day) < 0) id(g_wx_day) = 0; + if (id(g_wx_day) > mx) id(g_wx_day) = mx; + - script.execute: wx_render + - interval: 30ms + then: + - if: + condition: { lambda: 'return id(g_base)==5;' } + then: + - lambda: |- + float p = id(g_wx_gpos), tgt = (float) id(g_wx_day); + if (fabsf(tgt - p) > 0.001f) { // only while actually sliding + int n = id(g_wx_n); if (n < 1) n = 1; + p += (tgt - p) * 0.35f; + if (fabsf(tgt - p) < 0.02f) p = tgt; + id(g_wx_gpos) = p; + float rx = 140.0f * sinf(p * 6.2831853f / n); + float ry = -140.0f * cosf(p * 6.2831853f / n); + lv_obj_set_pos(id(wx_glow), 180 + (int) lroundf(rx) - 28, 180 + (int) lroundf(ry) - 28); + } + +lvgl: + pages: + - id: page_weather + bg_color: 0x0B1020 + widgets: + - obj: { id: wx_bg, align: CENTER, x: 0, y: 0, width: 360, height: 360, radius: 0, bg_color: 0x1C2838, bg_opa: COVER, border_width: 0 } + - obj: { id: wx_ring_out, align: CENTER, width: 348, height: 348, radius: 174, bg_opa: TRANSP, border_width: 2, border_color: 0x33455C, border_opa: 55%, clickable: false, scrollable: false } + - obj: { id: wx_glow, x: 152, y: 12, width: 56, height: 56, radius: 28, bg_color: 0xCAD6E8, bg_opa: 30%, border_width: 0, hidden: true } + - label: { id: wx_d0, align: CENTER, x: 0, y: 0, text: "", text_font: icons_wx, text_color: 0xDCE6F2, hidden: true } + - label: { id: wx_d1, align: CENTER, x: 0, y: 0, text: "", text_font: icons_wx, text_color: 0xDCE6F2, hidden: true } + - label: { id: wx_d2, align: CENTER, x: 0, y: 0, text: "", text_font: icons_wx, text_color: 0xDCE6F2, hidden: true } + - label: { id: wx_d3, align: CENTER, x: 0, y: 0, text: "", text_font: icons_wx, text_color: 0xDCE6F2, hidden: true } + - label: { id: wx_d4, align: CENTER, x: 0, y: 0, text: "", text_font: icons_wx, text_color: 0xDCE6F2, hidden: true } + - label: { id: wx_d5, align: CENTER, x: 0, y: 0, text: "", text_font: icons_wx, text_color: 0xDCE6F2, hidden: true } + - label: { id: wx_d6, align: CENTER, x: 0, y: 0, text: "", text_font: icons_wx, text_color: 0xDCE6F2, hidden: true } + - animimg: { id: wx_a_sun, align: CENTER, x: 0, y: -52, duration: 1400ms, repeat_count: 65535, auto_start: false, hidden: true, src: [ img_wx_sun0, img_wx_sun1, img_wx_sun2, img_wx_sun3, img_wx_sun4, img_wx_sun5, img_wx_sun6, img_wx_sun7 ] } + - animimg: { id: wx_a_partly, align: CENTER, x: 0, y: -52, duration: 1400ms, repeat_count: 65535, auto_start: false, hidden: true, src: [ img_wx_partly0, img_wx_partly1, img_wx_partly2, img_wx_partly3, img_wx_partly4, img_wx_partly5, img_wx_partly6, img_wx_partly7 ] } + - animimg: { id: wx_a_moon, align: CENTER, x: 0, y: -52, duration: 1400ms, repeat_count: 65535, auto_start: false, hidden: true, src: [ img_wx_moon0, img_wx_moon1, img_wx_moon2, img_wx_moon3, img_wx_moon4, img_wx_moon5, img_wx_moon6, img_wx_moon7 ] } + - animimg: { id: wx_a_cloud, align: CENTER, x: 0, y: -52, duration: 1400ms, repeat_count: 65535, auto_start: false, hidden: true, src: [ img_wx_cloud0, img_wx_cloud1, img_wx_cloud2, img_wx_cloud3, img_wx_cloud4, img_wx_cloud5, img_wx_cloud6, img_wx_cloud7 ] } + - animimg: { id: wx_a_rain, align: CENTER, x: 0, y: -52, duration: 1400ms, repeat_count: 65535, auto_start: false, hidden: true, src: [ img_wx_rain0, img_wx_rain1, img_wx_rain2, img_wx_rain3, img_wx_rain4, img_wx_rain5, img_wx_rain6, img_wx_rain7 ] } + - animimg: { id: wx_a_storm, align: CENTER, x: 0, y: -52, duration: 1400ms, repeat_count: 65535, auto_start: false, hidden: true, src: [ img_wx_storm0, img_wx_storm1, img_wx_storm2, img_wx_storm3, img_wx_storm4, img_wx_storm5, img_wx_storm6, img_wx_storm7 ] } + - animimg: { id: wx_a_snow, align: CENTER, x: 0, y: -52, duration: 1400ms, repeat_count: 65535, auto_start: false, hidden: true, src: [ img_wx_snow0, img_wx_snow1, img_wx_snow2, img_wx_snow3, img_wx_snow4, img_wx_snow5, img_wx_snow6, img_wx_snow7 ] } + - label: { id: wx_day, align: CENTER, x: 0, y: -10, text: "", text_font: font_artist, text_color: 0x9AA6B2 } + - label: { id: wx_htemp, align: CENTER, x: 0, y: 24, text: "--", text_font: montserrat_34, text_color: 0xFFFFFF } + - label: { id: wx_cond, align: CENTER, x: 0, y: 62, text: "", text_font: font_med, text_color: 0xCFE0F2 } diff --git a/base/watchfaces/demo.yaml b/base/watchfaces/demo.yaml new file mode 100644 index 0000000..a604ee2 --- /dev/null +++ b/base/watchfaces/demo.yaml @@ -0,0 +1,118 @@ +# ═════════════════════════════════════════════════════════════════════════════ +# DEMO WATCHFACE - a minimal, heavily-commented template. Copy this file to +# base/watchfaces/.yaml, change the 4 spots marked >>> EDIT <<< and +# you have your own home-screen look. +# +# How watchfaces work (same idea as the carousel screens): +# - The home base (the clock area) can show one of several "faces". +# - "Classic" is built into core and is always the fallback (face id 0). +# - Every OTHER face is an optional file like this one, switched on by adding it +# to the `files:` list in your thin config, and chosen at runtime in +# Settings > Home > Watchface (turn the knob to preview, tap to keep). +# - Core can't reference a page that might not be compiled in, so each face +# SHOWS ITSELF: core just raises a flag (g_wf_req) and the active face's own +# interval calls lvgl.page.show. Same trick the screens use (g_nav_req). +# +# Everything you can read from core (no extra entities needed): +# id(ha_time).now() - current time (ESPTime) -> strftime("%H"), etc. +# id(wx_temp).state - outdoor temperature (float) + .has_state() +# id(room_temp).state - room temperature (float) +# id(room_hum).state - room humidity (float) +# id(g_nf_glyph) - weather icon glyph (std::string, MDI in font icons34) +# id(g_nf_cond) - weather condition text, UPPERCASE +# id(g_batt) / id(g_charging) - battery % (int, -1 = unknown) / charging (bool) +# id(g_show_weather) / id(g_show_climate) - the Display toggles (bool) +# Fonts already provided by core: montserrat_18/48, font_med, font_artist, +# font_sel, icons18, icons34. Define your own under `font:` if you want more. +# ═════════════════════════════════════════════════════════════════════════════ + +# ── 1. REGISTER the face at boot ────────────────────────────────────────────── +# Pick a UNIQUE id (0 = Classic, 1 = Neon are taken; use 2, 3, ...). The name is +# what shows in the picker. Priority 655 runs after core but before the boot +# finalize that validates the saved choice. +esphome: + on_boot: + - priority: 655 + then: + - lambda: 'id(g_wf_present)[2] = true; id(g_wf_name)[2] = "Demo";' # >>> EDIT <<< id + name + +# ── 2. (optional) your own fonts ────────────────────────────────────────────── +# This demo reuses core's fonts, so nothing here. Add a `font:` block if needed. + +# ── 3. THE PAGE ─────────────────────────────────────────────────────────────── +# A full-screen page. Give it a unique id (page_home_) and unique widget ids. +lvgl: + pages: + - id: page_home_demo # >>> EDIT <<< unique page id + bg_color: 0x000000 + widgets: + - label: # big centered clock + id: dwf_time + align: CENTER + y: -16 + text: "00:00" + text_font: montserrat_48 + text_color: 0xFFFFFF + - label: # small date under it + id: dwf_date + align: CENTER + y: 30 + text: "" + text_font: font_med + text_color: 0x6f9a92 + +# ── 4. INTERVALS: self-show + refresh ───────────────────────────────────────── +interval: + # Self-show: core raises g_wf_req when it wants the home base shown. If we are + # the active face, show OUR page (and render once so it isn't blank), then clear + # the flag. Keep the id check (== 2) matching the one you registered above. + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_wf_req) && id(g_watchface) == 2;' } # >>> EDIT <<< id + then: + - lambda: 'id(g_wf_req) = false;' + # slide in matching the carousel direction (like Classic / the screens) - no laggy fade + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_home_demo, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_home_demo, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_home_demo, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_home_demo, animation: move_bottom, time: 250ms }] } + - script.execute: dwf_render + # Refresh once a second while we are the active home face. + - interval: 1s + then: + - if: + condition: { lambda: 'return id(g_watchface) == 2 && id(g_base) == 0;' } # >>> EDIT <<< id + then: [ script.execute: dwf_render ] + +# ── 5. RENDER: fill your widgets from core's data ───────────────────────────── +script: + - id: dwf_render # >>> EDIT <<< unique script id + then: + # Time - HH:MM (24h). Use "%I:%M %p" for 12-hour. + - lvgl.label.update: + id: dwf_time + text: !lambda 'return id(ha_time).now().is_valid() ? id(ha_time).now().strftime("%H:%M") : std::string("00:00");' + # Date - e.g. "Mon 19 Jun" + - lvgl.label.update: + id: dwf_date + text: !lambda |- + auto t = id(ha_time).now(); + if (!t.is_valid()) return std::string(""); + static const char* d[8] = {"","Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; + std::string day = (t.day_of_week >= 1 && t.day_of_week <= 7) ? std::string(d[t.day_of_week]) : std::string(""); + return day + t.strftime(" %d %b"); + # ── Want more? Uncomment and add widgets above, then fill them here. ── + # Example: show room temperature (respecting the "Show climate" toggle) + # - lvgl.label.update: + # id: dwf_temp + # text: !lambda |- + # if (!id(g_show_climate) || !id(room_temp).has_state()) return std::string(""); + # char b[12]; snprintf(b, sizeof(b), "%.1f\xC2\xB0", id(room_temp).state); return std::string(b); + # Example: weather icon (font icons34) + outdoor temp + # - lvgl.label.update: { id: dwf_wx, text: !lambda 'return id(g_show_weather) ? id(g_nf_glyph) : std::string("");' } + +# ── 6. TURN IT ON ───────────────────────────────────────────────────────────── +# Add this file to the `files:` list in your thin config: +# - base/watchfaces/demo.yaml +# then rebuild and pick "Demo" in Settings > Home > Watchface. diff --git a/base/watchfaces/minecraft.yaml b/base/watchfaces/minecraft.yaml new file mode 100644 index 0000000..4aea2c7 --- /dev/null +++ b/base/watchfaces/minecraft.yaml @@ -0,0 +1,191 @@ +# ═════════════════════════════════════════════════════════════════════════════ +# MINECRAFT watchface (optional, id 3). A blocky scene with a day/night cycle: +# sky + textured grass/dirt ground, the sun by day / moon + stars by night, pixel +# flowers on the grass, a blocky pixel font for the time (classic drop shadow), +# outdoor temp, and a Minecraft heart bar (HUD) for the battery. +# +# PERFORMANCE: the per-second tick is just a tiny lambda; the heavy redraws (the big +# pixel digits, the day/night swap) only run when the minute actually changes or the +# day/night state flips - so it stays light. No animated clouds. +# +# Assets (font + sprites) are fetched from this repo's /main/ raw GitHub URLs at compile time. +# Watchface contract: see base/watchfaces/demo.yaml. +# ═════════════════════════════════════════════════════════════════════════════ + +esphome: + on_boot: + - priority: 655 + then: + - lambda: 'id(g_wf_present)[3] = true; id(g_wf_name)[3] = "Minecraft";' + +globals: + - { id: g_mc_last_min, type: int, restore_value: no, initial_value: '-1' } + - { id: g_mc_last_night, type: int, restore_value: no, initial_value: '-1' } + - { id: g_mc_upd_min, type: bool, restore_value: no, initial_value: 'false' } + - { id: g_mc_upd_night, type: bool, restore_value: no, initial_value: 'false' } + +font: + - file: https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/fonts/pixel-font.ttf + id: mc_font_big + size: 78 + glyphs: "0123456789:" + - file: https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/fonts/pixel-font.ttf + id: mc_font_sm + size: 26 + glyphs: " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" # date only (uppercased) - no GF_Latin_Core, avoids missing-glyph warnings + +image: + - { id: img_mc_ground, file: https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/minecraft/ground.png, type: RGB565 } + - { id: img_mc_sun, file: https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/minecraft/sun.png, type: RGB565, transparency: alpha_channel } + - { id: img_mc_moon, file: https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/minecraft/moon.png, type: RGB565, transparency: alpha_channel } + - { id: img_mc_flower, file: https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/minecraft/flower.png, type: RGB565, transparency: alpha_channel } + +lvgl: + pages: + - id: page_home_mc + bg_color: 0x000000 + widgets: + # sky (recoloured day/night) - full screen, behind everything + - obj: { id: mc_sky, x: 0, y: 0, width: 360, height: 360, bg_color: 0x5A9BD4, bg_opa: COVER, radius: 0, border_width: 0, clickable: false, scrollable: false } + # stars (night only) + - obj: { id: mc_star1, x: 44, y: 64, width: 6, height: 6, bg_color: 0xEAECF5, bg_opa: COVER, radius: 0, border_width: 0, clickable: false, scrollable: false, hidden: true } + - obj: { id: mc_star2, x: 120, y: 42, width: 6, height: 6, bg_color: 0xEAECF5, bg_opa: COVER, radius: 0, border_width: 0, clickable: false, scrollable: false, hidden: true } + - obj: { id: mc_star3, x: 250, y: 48, width: 6, height: 6, bg_color: 0xEAECF5, bg_opa: COVER, radius: 0, border_width: 0, clickable: false, scrollable: false, hidden: true } + - obj: { id: mc_star4, x: 70, y: 116, width: 5, height: 5, bg_color: 0xC9D0E4, bg_opa: COVER, radius: 0, border_width: 0, clickable: false, scrollable: false, hidden: true } + - obj: { id: mc_star5, x: 300, y: 120, width: 5, height: 5, bg_color: 0xC9D0E4, bg_opa: COVER, radius: 0, border_width: 0, clickable: false, scrollable: false, hidden: true } + # textured ground (flush to the bottom edge) + - image: { id: mc_ground, src: img_mc_ground, align: BOTTOM_MID, y: 0 } + # pixel flowers standing on the grass + - image: { id: mc_flower1, src: img_mc_flower, x: 56, y: 200 } + - image: { id: mc_flower2, src: img_mc_flower, x: 282, y: 206 } + # sun by day / moon by night (top-right) + - image: { id: mc_sun, src: img_mc_sun, align: TOP_RIGHT, x: -38, y: 38 } + - image: { id: mc_moon, src: img_mc_moon, align: TOP_RIGHT, x: -38, y: 38, hidden: true } + - label: { id: mc_sun_temp, align: TOP_RIGHT, x: -52, y: 100, text: "--°", text_font: font_artist, text_color: 0x16314A } + # time: dark shadow behind + white in front (the Minecraft look) + - label: { id: mc_time_sh, align: CENTER, x: 3, y: -41, text: "00:00", text_font: mc_font_big, text_color: 0x1A2A40 } + - label: { id: mc_time, align: CENTER, x: 0, y: -44, text: "00:00", text_font: mc_font_big, text_color: 0xFFFFFF } + - label: { id: mc_date, align: CENTER, x: 0, y: 14, text: "", text_font: mc_font_sm, text_color: 0xEAF2FB } + # battery as a Minecraft heart bar (HUD), low: 5 dark "empty" slots + red fill on top + - label: { id: mc_hearts_bg, align: BOTTOM_LEFT, x: 128, y: -16, text: "󰋑󰋑󰋑󰋑󰋑", text_font: icons18, text_color: 0x3A1614 } + - label: { id: mc_hearts, align: BOTTOM_LEFT, x: 128, y: -16, text: "", text_font: icons18, text_color: 0xE2453F } + # timer badge (active timer - minutes left) + - label: + id: mc_timer + align: TOP_MID + y: 6 + text: "" + text_font: montserrat_18 + text_color: 0xFFFFFF + bg_color: 0xFF3B30 + bg_opa: COVER + radius: 12 + pad_left: 11 + pad_right: 11 + pad_top: 4 + pad_bottom: 4 + hidden: true + +interval: + - interval: 50ms # self-show when core requests the home base + then: + - if: + condition: { lambda: 'return id(g_wf_req) && id(g_watchface) == 3;' } + then: + - lambda: 'id(g_wf_req) = false; id(g_mc_last_min) = -1; id(g_mc_last_night) = -1;' # force a full redraw on entry + # slide in matching the carousel direction (like Classic / the screens) - no laggy fade + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_home_mc, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_home_mc, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_home_mc, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_home_mc, animation: move_bottom, time: 250ms }] } + - script.execute: mc_render + - interval: 1s # cheap tick - only redraws on a real change + then: + - if: + condition: { lambda: 'return id(g_watchface) == 3 && id(g_base) == 0;' } + then: [ script.execute: mc_render ] + +script: + - id: mc_render + then: + # Decide what actually changed (so the second-by-second tick is nearly free) + - lambda: |- + id(g_mc_upd_min) = false; id(g_mc_upd_night) = false; + auto t = id(ha_time).now(); + if (!t.is_valid()) return; + int mn = t.hour * 60 + t.minute; + if (mn != id(g_mc_last_min)) { id(g_mc_last_min) = mn; id(g_mc_upd_min) = true; } + int night = (t.hour < 6 || t.hour >= 20) ? 1 : 0; + if (night != id(g_mc_last_night)) { id(g_mc_last_night) = night; id(g_mc_upd_night) = true; } + # Minute changed -> redraw time / date / temp / hearts + - if: + condition: { lambda: 'return id(g_mc_upd_min);' } + then: + - lvgl.label.update: { id: mc_time, text: !lambda 'return id(ha_time).now().strftime("%H:%M");' } + - lvgl.label.update: { id: mc_time_sh, text: !lambda 'return id(ha_time).now().strftime("%H:%M");' } + - lvgl.label.update: + id: mc_date + text: !lambda |- + auto t = id(ha_time).now(); + static const char* d[8] = {"","SUN","MON","TUE","WED","THU","FRI","SAT"}; + std::string day = (t.day_of_week >= 1 && t.day_of_week <= 7) ? std::string(d[t.day_of_week]) : std::string(""); + std::string s = day + t.strftime(" %d %b"); + for (auto &c : s) { if (c >= 'a' && c <= 'z') c -= 32; } + return s; + - if: + condition: { lambda: 'return id(g_show_weather);' } + then: + - lvgl.widget.show: mc_sun_temp + - lvgl.label.update: + id: mc_sun_temp + text: !lambda |- + if (!id(wx_temp).has_state()) return std::string("--°"); + char b[10]; snprintf(b, sizeof(b), "%d°", (int) roundf(id(wx_temp).state)); return std::string(b); + else: [ lvgl.widget.hide: mc_sun_temp ] + - lvgl.label.update: + id: mc_hearts + text: !lambda |- + int p = id(g_batt); + int n = (p < 0) ? 5 : (p + 10) / 20; if (n < 0) n = 0; if (n > 5) n = 5; + std::string s; for (int i = 0; i < n; i++) s += "󰋑"; + return s; + # Day/night flipped -> swap sky tint, sun<->moon, stars, temp colour + - if: + condition: { lambda: 'return id(g_mc_upd_night);' } + then: + - if: + condition: { lambda: 'return id(g_mc_last_night) == 1;' } + then: + - lvgl.widget.update: { id: mc_sky, bg_color: 0x101A33 } + - lvgl.widget.hide: mc_sun + - lvgl.widget.show: mc_moon + - lvgl.widget.show: mc_star1 + - lvgl.widget.show: mc_star2 + - lvgl.widget.show: mc_star3 + - lvgl.widget.show: mc_star4 + - lvgl.widget.show: mc_star5 + - lvgl.label.update: { id: mc_sun_temp, text_color: 0xC8D2E2 } + else: + - lvgl.widget.update: { id: mc_sky, bg_color: 0x5A9BD4 } + - lvgl.widget.show: mc_sun + - lvgl.widget.hide: mc_moon + - lvgl.widget.hide: mc_star1 + - lvgl.widget.hide: mc_star2 + - lvgl.widget.hide: mc_star3 + - lvgl.widget.hide: mc_star4 + - lvgl.widget.hide: mc_star5 + - lvgl.label.update: { id: mc_sun_temp, text_color: 0x16314A } + # Timer badge (active timer - minutes left), kept responsive (outside the minute gate) + - if: + condition: { lambda: 'return id(g_timer_active);' } + then: + - lvgl.widget.show: mc_timer + - lvgl.label.update: + id: mc_timer + text: !lambda |- + int s = id(g_timer_secs); if (s < 0) s = 0; + int m = (s + 59) / 60; + char b[24]; snprintf(b, sizeof(b), " %d min", m); + return std::string(b); + else: + - lvgl.widget.hide: mc_timer diff --git a/base/watchfaces/neon.yaml b/base/watchfaces/neon.yaml new file mode 100644 index 0000000..9f13ba3 --- /dev/null +++ b/base/watchfaces/neon.yaml @@ -0,0 +1,245 @@ +# ───────────────────────────────────────────────────────────────────────────── +# NEON watchface (optional home face). Add to the thin config's files: list to +# enable; the picker (Settings > Home > Watchface) then offers it. Watchface id 1. +# +# A full-screen home page with huge two-tone digits, neon rim rings, weather + +# room/battery stats. It reuses core's clock/weather/room/battery sensors and the +# g_show_weather / g_show_climate display toggles - no extra entities needed. +# +# Contract (mirrors the screen carousel): self-registers in the watchface registry +# at boot, renders itself while active, and self-shows its page when core requests +# the home base (core can't reference an optional page id, hence the g_wf_req flag). +# ───────────────────────────────────────────────────────────────────────────── + +esphome: + on_boot: + - priority: 655 + then: + - lambda: 'id(g_wf_present)[1] = true; id(g_wf_name)[1] = "Neon";' + +globals: + - { id: g_nf_last_min, type: int, restore_value: no, initial_value: '-1' } # perf: only redraw on minute change + - { id: g_nf_upd_min, type: bool, restore_value: no, initial_value: 'false' } + +font: + # Huge bold digits - only 0-9 needed, so it stays tiny in flash + - file: { type: gfonts, family: "Roboto", weight: 900 } + id: font_neon + size: 108 + glyphs: "0123456789" + +lvgl: + pages: + - id: page_home_neon + bg_color: 0x000000 + widgets: + - obj: # outer neon ring at the rim + id: nf_ring_out + align: CENTER + width: 352 + height: 352 + radius: 176 + bg_opa: TRANSP + border_width: 4 + border_color: 0xFF2E88 + border_opa: 60% + clickable: false + scrollable: false + - obj: # second neon ring, also hugging the rim (interior left free for content) + id: nf_ring_in + align: CENTER + width: 320 + height: 320 + radius: 160 + bg_opa: TRANSP + border_width: 4 + border_color: 0x9A5BFF + border_opa: 55% + clickable: false + scrollable: false + - label: # weather icon + id: nf_wx_ic + align: CENTER + x: -34 + y: -103 + text: "󰖕" + text_font: icons34 + text_color: 0xBFD2DA + - label: # outdoor temperature + id: nf_wx_temp + align: CENTER + x: 26 + y: -106 + text: "--°" + text_font: font_sel + text_color: 0xFFFFFF + - label: # condition text (UPPERCASE) + id: nf_wx_cond + align: CENTER + y: -76 + text: "" + text_font: font_artist + text_color: 0x8A93A0 + - label: # HOURS + id: nf_hh + align: CENTER + x: -70 + y: -4 + text: "00" + text_font: font_neon + text_color: 0xFF2E88 + - label: # MINUTES + id: nf_mm + align: CENTER + x: 70 + y: -4 + text: "00" + text_font: font_neon + text_color: 0x9A5BFF + - label: # date + id: nf_date + align: CENTER + y: 70 + text: "" + text_font: font_med + text_color: 0x6f9a92 + - label: # room temperature icon + id: nf_ct_ic + align: CENTER + x: -60 + y: 99 + text: "󰔏" + text_font: icons18 + text_color: 0xFF8A5C + - label: + id: nf_ct_val + align: CENTER + x: -32 + y: 99 + text: "--" + text_font: font_artist + text_color: 0x9AA3B0 + - label: # room humidity icon + id: nf_hum_ic + align: CENTER + x: 16 + y: 99 + text: "󰖎" + text_font: icons18 + text_color: 0x3FA9D6 + - label: + id: nf_hum_val + align: CENTER + x: 42 + y: 99 + text: "--" + text_font: font_artist + text_color: 0x9AA3B0 + - label: # battery (glyph + %), mirrored from core's lbl_batt (same LVGL symbols) + id: nf_bt + align: CENTER + y: 126 + text: "" + text_font: montserrat_18 + text_color: 0x6f9a92 + - label: # timer badge (active timer - mins left), mirrors the classic face + id: nf_timer + align: TOP_MID + y: 22 + text: "" + text_font: montserrat_18 + text_color: 0xFFFFFF + bg_color: 0xFF3B30 + bg_opa: COVER + radius: 14 + pad_left: 12 + pad_right: 12 + pad_top: 5 + pad_bottom: 5 + hidden: true + +interval: + # Self-show: core sets g_wf_req when it wants the home base shown; we own page_home_neon. + - interval: 50ms + then: + - if: + condition: { lambda: 'return id(g_wf_req) && id(g_watchface) == 1;' } + then: + - lambda: 'id(g_wf_req) = false; id(g_nf_last_min) = -1;' # force a full redraw on entry + # slide in matching the carousel direction (like Classic / the screens) - no laggy fade + - if: { condition: { lambda: 'return id(g_nav_anim)==0;' }, then: [lvgl.page.show: { id: page_home_neon, animation: move_left, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==1;' }, then: [lvgl.page.show: { id: page_home_neon, animation: move_right, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==2;' }, then: [lvgl.page.show: { id: page_home_neon, animation: move_top, time: 250ms }] } + - if: { condition: { lambda: 'return id(g_nav_anim)==3;' }, then: [lvgl.page.show: { id: page_home_neon, animation: move_bottom, time: 250ms }] } + - script.execute: neon_render + # Refresh while this is the active home face + - interval: 1s + then: + - if: + condition: { lambda: 'return id(g_watchface) == 1 && id(g_base) == 0;' } + then: [ script.execute: neon_render ] + +script: + - id: neon_render + then: + # Cheap tick: figure out if the minute actually changed (heavy redraws only then) + - lambda: |- + id(g_nf_upd_min) = false; + auto t = id(ha_time).now(); + if (!t.is_valid()) return; + int mn = t.hour * 60 + t.minute; + if (mn != id(g_nf_last_min)) { id(g_nf_last_min) = mn; id(g_nf_upd_min) = true; } + - if: + condition: { lambda: 'return id(g_nf_upd_min);' } + then: + - lvgl.label.update: { id: nf_hh, text: !lambda 'return id(ha_time).now().strftime("%H");' } + - lvgl.label.update: { id: nf_mm, text: !lambda 'return id(ha_time).now().strftime("%M");' } + - lvgl.label.update: { id: nf_wx_ic, text: !lambda 'return id(g_nf_glyph).empty() ? std::string("󰖕") : id(g_nf_glyph);' } + - lvgl.label.update: { id: nf_wx_cond, text: !lambda 'return id(g_nf_cond);' } + - lvgl.label.update: + id: nf_wx_temp + text: !lambda |- + if (!id(wx_temp).has_state()) return std::string("--°"); + char b[10]; snprintf(b, sizeof(b), "%d°", (int) roundf(id(wx_temp).state)); return std::string(b); + - lvgl.label.update: + id: nf_date + text: !lambda |- + auto t = id(ha_time).now(); + static const char* d[8] = {"","Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; + std::string day = (t.day_of_week >= 1 && t.day_of_week <= 7) ? std::string(d[t.day_of_week]) : std::string(""); + return t.strftime("%d %b") + " " + day; + - lvgl.label.update: + id: nf_ct_val + text: !lambda 'if (!id(room_temp).has_state()) return std::string("--"); char b[12]; snprintf(b, sizeof(b), "%.1f°", id(room_temp).state); return std::string(b);' + - lvgl.label.update: + id: nf_hum_val + text: !lambda 'if (!id(room_hum).has_state()) return std::string("--"); char b[8]; snprintf(b, sizeof(b), "%d%%", (int) roundf(id(room_hum).state)); return std::string(b);' + # Battery: mirror core's lbl_batt text + colour + - lambda: 'lv_label_set_text(id(nf_bt), lv_label_get_text(id(lbl_batt)));' + - if: + condition: { lambda: 'return id(g_charging);' } + then: [ lvgl.label.update: { id: nf_bt, text_color: 0x35c759 } ] + else: [ lvgl.label.update: { id: nf_bt, text_color: 0x6f9a92 } ] + # Weather / climate visibility obey the Display toggles + - if: + condition: { lambda: 'return id(g_show_weather);' } + then: [ lvgl.widget.show: nf_wx_ic, lvgl.widget.show: nf_wx_temp, lvgl.widget.show: nf_wx_cond ] + else: [ lvgl.widget.hide: nf_wx_ic, lvgl.widget.hide: nf_wx_temp, lvgl.widget.hide: nf_wx_cond ] + - if: + condition: { lambda: 'return id(g_show_climate);' } + then: [ lvgl.widget.show: nf_ct_ic, lvgl.widget.show: nf_ct_val, lvgl.widget.show: nf_hum_ic, lvgl.widget.show: nf_hum_val ] + else: [ lvgl.widget.hide: nf_ct_ic, lvgl.widget.hide: nf_ct_val, lvgl.widget.hide: nf_hum_ic, lvgl.widget.hide: nf_hum_val ] + # Timer badge - kept responsive (cheap) so it appears/updates promptly + - if: + condition: { lambda: 'return id(g_timer_active);' } + then: + - lvgl.widget.show: nf_timer + - lvgl.label.update: + id: nf_timer + text: !lambda |- + int s = id(g_timer_secs); if (s < 0) s = 0; + int m = (s + 59) / 60; + char b[24]; snprintf(b, sizeof(b), " %d min", m); + return std::string(b); + else: + - lvgl.widget.hide: nf_timer diff --git a/guition-va.yaml b/guition-va.yaml new file mode 100644 index 0000000..3a0dd32 --- /dev/null +++ b/guition-va.yaml @@ -0,0 +1,94 @@ +# ============================================================================= +# Voice Knob - YOUR config. This is the only firmware file you keep locally. +# ----------------------------------------------------------------------------- +# Copy THIS file (+ partitions.csv + a secrets.yaml) next to each other, edit the +# entities/tiles below to match your Home Assistant, then: +# +# esphome run guition-va.yaml +# +# Everything else (the firmware core + the optional screens) is pulled from GitHub +# at compile time - see the `packages:` block at the bottom - so you only ever +# maintain this one file. To get the latest changes: +# +# esphome clean guition-va.yaml # clears the package cache +# esphome run guition-va.yaml +# ============================================================================= + +substitutions: + name: voice-knob + friendly_name: "Voice Knob" + timezone: "Australia/Brisbane" # IANA name; core maps common zones to POSIX. Zone not listed? paste a POSIX string (github.com/nayarsystems/posix_tz_db). DST automatic. + # For album art: base URL of your HA + the media_player entity + ha_base_url: "http://ha.iosull.com" + player_entity: media_player.voice_knob_player + # On-device wake word. Built-in models: alexa, okay_nabu, hey_jarvis, hey_mycroft. + wake_word: "alexa" + # Home screen (weather + room climate). Change to your entities. + weather_entity: weather.home + room_temp_entity: sensor.room_temperature + room_hum_entity: sensor.room_humidity + # Weather screen forecast: an HA helper sensor (see base/screens/weather.ha-helper.yaml) + forecast_entity: sensor.va_weekly_forecast + # Thermostat screen: the climate.* entity it controls + climate_entity: climate.living_room + + # Control tiles (swipe up): 4 toggles, each entity + icon + name. Works on any HA domain. + # tileN_icon = a name from the built-in set: bulb lamp led power plug switch fan ac heater + # thermometer lock unlock door blinds tv speaker bell wifi home flash. Same icon may repeat. + # (For an icon outside the set: paste its glyph or a "\U000Fxxxx" code from pictogrammers.com.) + tile1_entity: light.living_room + tile1_icon: "bulb" + tile1_name: "Light" + tile2_entity: switch.desk_neon + tile2_icon: "flash" + tile2_name: "Neon" + tile3_entity: switch.power_socket + tile3_icon: "power" + tile3_name: "Socket" + + # Sensors screen (knob cycles): up to 6 read-only HA entities, shown one at a time, big. + # Set as many glanceN as you want; leave the rest at their default (a non-existent entity) + # and they're skipped. Icon name is optional (same set as the tiles); empty = no icon. + glance1_entity: sensor.power + glance1_name: "Power" + glance1_icon: "flash" + glance2_entity: sensor.va_glance_unused2 + glance2_name: "" + glance2_icon: "" + glance3_entity: sensor.va_glance_unused3 + glance3_name: "" + glance3_icon: "" + glance4_entity: sensor.va_glance_unused4 + glance4_name: "" + glance4_icon: "" + glance5_entity: sensor.va_glance_unused5 + glance5_name: "" + glance5_icon: "" + glance6_entity: sensor.va_glance_unused6 + glance6_name: "" + glance6_icon: "" + + # Carousel order, left to right. Reorder/remove names freely; screens you did not + # include in "files:" below are skipped automatically. + screen_order: "clock,player,timer,cars,space,snake,knobuss,weather,thermostat,sensors,demo" + +packages: + - !include base/core.yaml + - !include base/screens/player.yaml # optional: music player (album art + transport) + - !include base/screens/timer.yaml # optional: timer screen in the carousel (voice timers/alarm stay regardless) + - !include base/screens/cool-cars.yaml # optional: Cool Cars game + - !include base/screens/space-wars.yaml # optional: Space Wars game + - !include base/screens/snake.yaml # optional: Snake game (knob steers) + - !include base/screens/knobuss.yaml # optional: Knobuss - a Gyruss-style tube shooter (knob aims, auto-fire) + - !include base/screens/weather.yaml # optional: weather (today + 7-day, knob scrolls days) + - !include base/screens/thermostat.yaml # optional: thermostat (climate.* entity; knob sets target, tap on/off) + - !include base/screens/sensors.yaml # optional: sensors glance (1-6 HA entities, knob cycles) + #- !include base/screens/demo.yaml # optional: commented example screen (TAP ME!) + Widgets settings + # Watchfaces (optional home-screen looks; Classic is built into core and always available). + # Comment one out to drop it; pick the active one in Settings > Home > Watchface. + - !include base/watchfaces/neon.yaml # optional: Neon - big two-tone digits + neon rings + - !include base/watchfaces/minecraft.yaml # optional: Minecraft - blocky scene + pixel clock + - !include base/watchfaces/demo.yaml # optional: Demo - minimal, heavily-commented template to copy + +# Wi-Fi comes from your local secrets.yaml (the core uses !secret wifi_ssid / wifi_password). +# Keep a secrets.yaml and partitions.csv next to this file (both must be local paths). diff --git a/partitions.csv b/partitions.csv new file mode 100644 index 0000000..c88b18a --- /dev/null +++ b/partitions.csv @@ -0,0 +1,8 @@ +# ESP32-S3 16 MB flash, dual-OTA, MAXIMUM app partitions. +# In ESPHome the images/fonts/animations are compiled into the app -> bigger app = more room for the UI. +# ~7.94 MB per slot (app0/app1). Changing the partition layout needs a USB flash (not OTA). +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x7F0000, +app1, app, ota_1, 0x800000, 0x7F0000, diff --git a/scripts/esplog.py b/scripts/esplog.py new file mode 100644 index 0000000..f521592 --- /dev/null +++ b/scripts/esplog.py @@ -0,0 +1,25 @@ +import asyncio, sys, time +from aioesphomeapi import APIClient, LogLevel + +HOST = "voice-knob.local" # your device hostname or IP +PORT = 6053 +DURATION = int(sys.argv[1]) if len(sys.argv) > 1 else 15 +FILTER = sys.argv[2] if len(sys.argv) > 2 else "" # optional substring filter + +async def main(): + cli = APIClient(HOST, PORT, None) # no password, no encryption + await cli.connect(login=True) + info = await cli.device_info() + print(f"# connected: {info.name} esphome={info.esphome_version} uptime-ok", flush=True) + + def on_log(msg): + line = msg.message.decode("utf-8", "replace") + if FILTER and FILTER.lower() not in line.lower(): + return + print(line, flush=True) + + cli.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_DEBUG) + await asyncio.sleep(DURATION) + print(f"# done ({DURATION}s)", flush=True) + +asyncio.run(main()) diff --git a/scripts/gen_snake.py b/scripts/gen_snake.py new file mode 100644 index 0000000..2349e38 --- /dev/null +++ b/scripts/gen_snake.py @@ -0,0 +1,647 @@ +#!/usr/bin/env python3 +# Generates base/screens/snake.yaml (modular carousel screen, base id 9). Run from anywhere: +# python scripts/gen_snake.py +# +# Smooth-steering 360-degree snake. The head glides every tick (knob rotates its heading); the body is a TRAIL +# of dots the head lays down every SEG_DIST of travel. Dots do NOT move once placed - each tick only +# the head moves and (when the head advanced one spacing) one dot is added at the head / dropped at +# the tail. Per-tick render cost is constant regardless of length, which keeps the main loop fast +# enough not to starve the knob input (a chain-follow body that moved every segment every tick made +# turning degrade as the snake grew). +# +# IMPORTANT (why segments are moved from a lambda, not YAML actions): ESPHome plays a sequence of +# synchronous actions by recursion, so a long YAML action list (e.g. a 56-way "if slot==k" dispatch +# or a 56-widget redraw) becomes ~56 nested calls and overflows the task stack. So all body/head +# positioning is done inside ONE lambda via lv_obj_set_pos on an array of lv_obj_t* (indexed at +# runtime). Hidden = parked off-screen (pos 400,400) - avoids any LVGL flag-API version differences. +# Coords: a centre-offset (cx,cy) maps to a top-left lv_obj_set_pos of (180+cx-SEG/2, 180+cy-SEG/2). +import os + +POOL = 100 # max body dots = number of body widgets (max snake length = POOL-1). + # Render is O(1) per tick (trail model), so a big pool only costs RAM (obj + # widgets, allocated in PSRAM) - not per-tick time. +SEG = 9 # dot square (px) +SEGR = 4 # dot corner radius +HALF = SEG // 2 # 4 +SEG_DIST = 8.0 # spacing between trail dots (px) +R = 168.0 # max head distance from centre (inside the ring) +RING_D = 344 # ring diameter (radius 172) -> just inside the 360 round display +SPEED0 = 3.2 # head speed px/tick (50ms tick) +TURN = 0.26 # radians turned per knob detent +EAT = 12.0 # head-to-fruit distance that counts as eating +SKHIT = 13.0 # head-to-skull distance that kills +SELF_SKIP = 6 # newest body dots skipped in self-collision (the neck) +SELF_DIST = 8.0 # head-to-body distance that kills (~ the two 9px sprites touching) +GROW = 3 # body dots added per fruit eaten +START_LEN = 5 + +NFRUIT = 12 # fruit sprites in the rotation +NFSLOT = 3 # max fruit on the board +NSKULL = 2 # max skulls +FRUIT_WAIT = 90 # ticks between fruit top-ups (~4.5s); halved when only 1 fruit is left +SKULL_WAIT = 200 # ticks between skull spawns (~10s) +SKULL_LIFE = 220 # ticks a skull stays (~11s) + +BASE = "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/snake/" + +PAGE_BG = "0x0D1E15" +RING = "0x3A8C4E" +HEAD = "0xB8E86A" +BODY = "0x52A049" +ACCENT = "0xA6D85F" +ACCENT_TXT = "0x12241A" +BTN2 = "0x244A30" +BTN2_TXT = "0xCFE6C4" +GRAY = "0x3A3A3D" +TXT = "0xC6DCC0" +BADGE = "0x6FB23F" +HUD = "0xCFEAE4" +WORDMARK = "0x12212D" + +M = POOL * 4 # safe positive-modulo offset +# C++ array of the body-segment lv_obj_t* (built locally in each rendering lambda) +SEGINIT = "lv_obj_t *SS[" + str(POOL) + "] = { " + ", ".join(f"id(sn_seg{k})" for k in range(POOL)) + " };" + + +def body_widgets(): + # no align (top-left origin); parked off-screen until positioned by a lambda + return "\n".join( + f" - obj: {{ id: sn_seg{k}, x: 400, y: 400, width: {SEG}, height: {SEG}, " + f"radius: {SEGR}, border_width: 0, bg_color: {BODY}, bg_opa: COVER }}" + for k in range(POOL)) + + +def fruit_widgets(): + return "\n".join( + f" - image: {{ id: sn_fruit{s}, align: CENTER, x: 0, y: 0, src: img_sn_fruit0, hidden: true }}" + for s in range(NFSLOT)) + + +def skull_widgets(): + return "\n".join( + f" - image: {{ id: sn_skull{s}, align: CENTER, x: 0, y: 0, src: img_sn_skull, hidden: true }}" + for s in range(NSKULL)) + + +def fspr_dispatch(): + return "\n".join( + f" - if: {{ condition: {{ lambda: 'return id(g_sn_newfruit) == {s};' }}, " + f"then: [ script.execute: sn_fspr{s} ] }}" + for s in range(NFSLOT)) + + +def skull_dispatch(): + return "\n".join( + f" - if: {{ condition: {{ lambda: 'return id(g_sn_newskull) == {s};' }}, " + f"then: [ lvgl.widget.update: {{ id: sn_skull{s}, hidden: false, " + f"x: !lambda 'return (int) lroundf(id(g_sn_skx)[{s}]);', " + f"y: !lambda 'return (int) lroundf(id(g_sn_sky)[{s}]);' }} ] }}" + for s in range(NSKULL)) + + +def items_hidden(): + out = [] + for s in range(NFSLOT): + out.append(f" - lvgl.widget.update: {{ id: sn_fruit{s}, hidden: !lambda 'return !id(g_sn_fon)[{s}];' }}") + for s in range(NSKULL): + out.append(f" - lvgl.widget.update: {{ id: sn_skull{s}, hidden: !lambda 'return !id(g_sn_skon)[{s}];' }}") + return "\n".join(out) + + +def item_hides(): + out = [f" - lvgl.widget.hide: sn_fruit{s}" for s in range(NFSLOT)] + out += [f" - lvgl.widget.hide: sn_skull{s}" for s in range(NSKULL)] + return "\n".join(out) + + +def fruit_setters(): + blocks = [] + for s in range(NFSLOT): + lines = [f" - id: sn_fspr{s}", " then:", + f" - lvgl.widget.update: {{ id: sn_fruit{s}, hidden: false, " + f"x: !lambda 'return (int) lroundf(id(g_sn_fx)[{s}]);', " + f"y: !lambda 'return (int) lroundf(id(g_sn_fy)[{s}]);' }}"] + for k in range(NFRUIT): + lines.append( + f" - if: {{ condition: {{ lambda: 'return id(g_sn_fi)[{s}] == {k};' }}, " + f"then: [ lvgl.image.update: {{ id: sn_fruit{s}, src: img_sn_fruit{k} }} ] }}") + blocks.append("\n".join(lines)) + return "\n".join(blocks) + + +def fruit_images(): + return "\n".join( +f""" - id: img_sn_fruit{k} + file: "{BASE}fruit{k}.png" + type: RGB565 + transparency: alpha_channel""" for k in range(NFRUIT)) + + +YAML = f"""# Snake - optional carousel screen (base id 9). Smooth-steering 360-degree snake steered by the knob. +# Remove it: drop this file from your config's package "files:" list. Nothing else to edit. +# Scores (g_sn_top / g_sn_best) + the "Reset scores" settings action live here - fully self-contained. +# +# GENERATED by scripts/gen_snake.py - edit the generator, not the blocks. Body = a trail of dots +# (ring buffer) rendered from a lambda (lv_obj_set_pos), so per-tick cost is constant + shallow. + +esphome: + on_boot: + - priority: 655 + then: + - lambda: 'id(g_present)[9] = true;' + - priority: 650 + then: + - lambda: |- + int g = id(g_wgrp_n)++; + id(g_wgrp_labels) += (g ? "\\n" : ""); id(g_wgrp_labels) += "Snake"; + int o = id(g_wopt_n)++; + id(g_wopt_group)[o] = g; id(g_wopt_label)[o] = "Reset scores"; id(g_wopt_kind)[o] = 1; id(g_wopt_ptr)[o] = &id(g_sn_reset); + +globals: + - id: g_sn_best + type: int + restore_value: yes + initial_value: '0' + - id: g_sn_top + type: int[10] + restore_value: yes + - id: g_sn_reset + type: bool + restore_value: no + initial_value: 'false' + - id: g_sn_state # 0=menu 1=playing 2=game over 3=how-to 4=scores + type: int + restore_value: no + initial_value: '0' + - id: g_sn_len # body dots in the snake + type: int + restore_value: no + initial_value: '{START_LEN}' + - id: g_sn_score + type: int + restore_value: no + initial_value: '0' + - id: g_sn_tx # body trail dots X (ring buffer; centre coords px) + type: float[{POOL}] + restore_value: no + - id: g_sn_ty + type: float[{POOL}] + restore_value: no + - id: g_sn_head # ring index of the newest dot + type: int + restore_value: no + initial_value: '0' + - id: g_sn_hx # smooth head position (leads the newest dot) + type: float + restore_value: no + initial_value: '0.0' + - id: g_sn_hy + type: float + restore_value: no + initial_value: '0.0' + - id: g_sn_ang # heading (radians) + type: float + restore_value: no + initial_value: '0.0' + - id: g_sn_grow # pending growth (appends that won't drop the tail) + type: int + restore_value: no + initial_value: '0' + - id: g_sn_dead + type: bool + restore_value: no + initial_value: 'false' + - id: g_sn_add # ring slot to show/position this tick (-1 none) + type: int + restore_value: no + initial_value: '-1' + - id: g_sn_del # ring slot to park off-screen this tick (-1 none) + type: int + restore_value: no + initial_value: '-1' + - id: g_sn_fx + type: float[{NFSLOT}] + restore_value: no + - id: g_sn_fy + type: float[{NFSLOT}] + restore_value: no + - id: g_sn_fi + type: int[{NFSLOT}] + restore_value: no + - id: g_sn_fon + type: bool[{NFSLOT}] + restore_value: no + - id: g_sn_fclk + type: int + restore_value: no + initial_value: '0' + - id: g_sn_newfruit + type: int + restore_value: no + initial_value: '-1' + - id: g_sn_skx + type: float[{NSKULL}] + restore_value: no + - id: g_sn_sky + type: float[{NSKULL}] + restore_value: no + - id: g_sn_skon + type: bool[{NSKULL}] + restore_value: no + - id: g_sn_skage + type: int[{NSKULL}] + restore_value: no + - id: g_sn_skclk + type: int + restore_value: no + initial_value: '0' + - id: g_sn_newskull + type: int + restore_value: no + initial_value: '-1' + +script: +{fruit_setters()} + - id: sn_hud + then: + - lvgl.label.update: {{ id: lbl_sn_score, text: !lambda 'char b[12]; snprintf(b,sizeof(b),"%d",id(g_sn_score)); return std::string(b);' }} + - id: sn_items + then: +{items_hidden()} + # full body redraw - used at start / when (re)entering the play screen (one lambda, not per tick) + - id: sn_drawfull + then: + - lambda: |- + {SEGINIT} + for (int k = 0; k < {POOL}; k++) {{ + int d = (id(g_sn_head) - k + {M}) % {POOL}; + if (d < id(g_sn_len)) lv_obj_set_pos(SS[k], 180 + (int) lroundf(id(g_sn_tx)[k]) - {HALF}, 180 + (int) lroundf(id(g_sn_ty)[k]) - {HALF}); + else lv_obj_set_pos(SS[k], 400, 400); + }} + lv_obj_set_pos(id(sn_head), 180 + (int) lroundf(id(g_sn_hx)) - {HALF}, 180 + (int) lroundf(id(g_sn_hy)) - {HALF}); + - script.execute: sn_items + # hide every gameplay widget (park body off-screen + hide fruit/skulls) + - id: sn_clear + then: + - lambda: |- + {SEGINIT} + for (int k = 0; k < {POOL}; k++) lv_obj_set_pos(SS[k], 400, 400); + lv_obj_set_pos(id(sn_head), 400, 400); +{item_hides()} + - id: sn_start + then: + - lambda: |- + id(g_sn_state) = 1; id(g_sn_score) = 0; id(g_sn_dead) = false; + id(g_sn_ang) = -1.5708f; id(g_sn_grow) = 0; // heading up + id(g_sn_len) = {START_LEN}; id(g_sn_head) = {START_LEN - 1}; + for (int i = 0; i < {POOL}; i++) {{ id(g_sn_tx)[i] = 0.0f; id(g_sn_ty)[i] = 0.0f; }} + for (int k = 0; k < {START_LEN}; k++) {{ id(g_sn_tx)[k] = 0.0f; id(g_sn_ty)[k] = ({START_LEN - 1} - k) * {SEG_DIST}f; }} + id(g_sn_hx) = 0.0f; id(g_sn_hy) = 0.0f; + for (int s = 0; s < {NFSLOT}; s++) id(g_sn_fon)[s] = false; + for (int s = 0; s < {NSKULL}; s++) {{ id(g_sn_skon)[s] = false; id(g_sn_skage)[s] = 0; }} + id(g_sn_fclk) = 0; id(g_sn_skclk) = 0; + id(g_sn_newfruit) = -1; id(g_sn_newskull) = -1; id(g_sn_add) = -1; id(g_sn_del) = -1; + id(g_knob_capture) = true; id(g_knob_delta) = 0; + float a = (esp_random() % 62832) / 10000.0f; float rad = 60.0f + (esp_random() % 80); + id(g_sn_fx)[0] = cosf(a)*rad; id(g_sn_fy)[0] = sinf(a)*rad; + id(g_sn_fi)[0] = esp_random() % {NFRUIT}; id(g_sn_fon)[0] = true; + - script.execute: sn_fspr0 + - script.execute: sn_screen + - script.execute: sn_drawfull + - script.execute: sn_hud + - id: sn_over + then: + - lambda: |- + id(g_sn_state) = 2; + int s = id(g_sn_score); + if (s > id(g_sn_best)) id(g_sn_best) = s; + for (int i = 0; i < 10; i++) {{ + if (s > id(g_sn_top)[i]) {{ + for (int j = 9; j > i; j--) id(g_sn_top)[j] = id(g_sn_top)[j-1]; + id(g_sn_top)[i] = s; + break; + }} + }} + - script.execute: sn_screen + # screen state + the single knob-capture authority (captured only in play + game over) + - id: sn_screen + then: + - lambda: 'id(g_knob_capture) = (id(g_sn_state) == 1 || id(g_sn_state) == 2);' + - lvgl.widget.hide: sn_ring + - script.execute: sn_clear + - lvgl.widget.hide: lbl_sn_score + - lvgl.widget.hide: sn_logo + - lvgl.widget.hide: btn_sn_start + - lvgl.widget.hide: btn_sn_howto + - lvgl.widget.hide: btn_sn_scores + - lvgl.widget.hide: lbl_sn_over + - lvgl.widget.hide: lbl_sn_rec + - lvgl.widget.hide: btn_sn_replay + - lvgl.widget.hide: btn_sn_exit + - lvgl.widget.hide: lbl_sn_howto_t + - lvgl.widget.hide: sn_b1 + - lvgl.widget.hide: sn_b2 + - lvgl.widget.hide: sn_b3 + - lvgl.widget.hide: sn_b4 + - lvgl.widget.hide: lbl_sn_howto1 + - lvgl.widget.hide: lbl_sn_howto2 + - lvgl.widget.hide: lbl_sn_howto3 + - lvgl.widget.hide: lbl_sn_howto4 + - lvgl.widget.hide: lbl_sn_scores_t + - lvgl.widget.hide: lbl_sn_scores1 + - lvgl.widget.hide: lbl_sn_scores2 + - lvgl.widget.hide: btn_sn_back + - if: # MENU + condition: {{ lambda: 'return id(g_sn_state) == 0;' }} + then: + - lvgl.widget.show: sn_logo + - lvgl.widget.show: btn_sn_start + - lvgl.widget.show: btn_sn_howto + - lvgl.widget.show: btn_sn_scores + - if: # PLAY + condition: {{ lambda: 'return id(g_sn_state) == 1;' }} + then: + - lvgl.widget.show: sn_ring + - lvgl.widget.show: lbl_sn_score + - script.execute: sn_drawfull + - if: # GAME OVER + condition: {{ lambda: 'return id(g_sn_state) == 2;' }} + then: + - lvgl.widget.show: sn_logo + - lvgl.widget.show: lbl_sn_over + - lvgl.widget.show: lbl_sn_rec + - lvgl.widget.show: btn_sn_replay + - lvgl.widget.show: btn_sn_exit + - lvgl.label.update: {{ id: lbl_sn_over, text: !lambda 'char b[24]; snprintf(b,sizeof(b),"Score: %d", id(g_sn_score)); return std::string(b);' }} + - lvgl.label.update: {{ id: lbl_sn_rec, text: !lambda 'char b[24]; snprintf(b,sizeof(b),"Best: %d", id(g_sn_best)); return std::string(b);' }} + - if: # HOW TO PLAY + condition: {{ lambda: 'return id(g_sn_state) == 3;' }} + then: + - lvgl.widget.show: lbl_sn_howto_t + - lvgl.widget.show: sn_b1 + - lvgl.widget.show: sn_b2 + - lvgl.widget.show: sn_b3 + - lvgl.widget.show: sn_b4 + - lvgl.widget.show: lbl_sn_howto1 + - lvgl.widget.show: lbl_sn_howto2 + - lvgl.widget.show: lbl_sn_howto3 + - lvgl.widget.show: lbl_sn_howto4 + - lvgl.widget.show: btn_sn_back + - if: # SCORES (top 10) + condition: {{ lambda: 'return id(g_sn_state) == 4;' }} + then: + - lvgl.widget.show: lbl_sn_scores_t + - lvgl.widget.show: lbl_sn_scores1 + - lvgl.widget.show: lbl_sn_scores2 + - lvgl.widget.show: btn_sn_back + - lvgl.label.update: + id: lbl_sn_scores1 + text: !lambda |- + std::string s; char b[24]; + for (int i = 0; i < 5; i++) {{ if(i) s += "\\n"; if(id(g_sn_top)[i]>0){{ snprintf(b,sizeof(b),"%d. %d",i+1,id(g_sn_top)[i]); }} else {{ snprintf(b,sizeof(b),"%d. -",i+1); }} s += b; }} + return s; + - lvgl.label.update: + id: lbl_sn_scores2 + text: !lambda |- + std::string s; char b[24]; + for (int i = 5; i < 10; i++) {{ if(i>5) s += "\\n"; if(id(g_sn_top)[i]>0){{ snprintf(b,sizeof(b),"%d. %d",i+1,id(g_sn_top)[i]); }} else {{ snprintf(b,sizeof(b),"%d. -",i+1); }} s += b; }} + return s; + +interval: + - interval: 250ms + then: + - if: + condition: {{ lambda: 'return id(g_sn_reset) || id(g_factory_reset);' }} + then: + - lambda: 'for (int i = 0; i < 10; i++) id(g_sn_top)[i] = 0; id(g_sn_best) = 0; id(g_sn_reset) = false;' + - interval: 50ms + then: + - if: + condition: {{ lambda: 'return id(g_nav_req) && id(g_base)==9;' }} + then: + - lambda: 'id(g_nav_req) = false; id(g_sn_state) = 0; id(g_knob_delta) = 0;' + - if: {{ condition: {{ lambda: 'return id(g_nav_anim)==0;' }}, then: [lvgl.page.show: {{ id: page_snake, animation: move_left, time: 250ms }}] }} + - if: {{ condition: {{ lambda: 'return id(g_nav_anim)==1;' }}, then: [lvgl.page.show: {{ id: page_snake, animation: move_right, time: 250ms }}] }} + - if: {{ condition: {{ lambda: 'return id(g_nav_anim)==2;' }}, then: [lvgl.page.show: {{ id: page_snake, animation: move_top, time: 250ms }}] }} + - if: {{ condition: {{ lambda: 'return id(g_nav_anim)==3;' }}, then: [lvgl.page.show: {{ id: page_snake, animation: move_bottom, time: 250ms }}] }} + - script.execute: sn_screen + # Game tick: steer + glide head; lay/drop one trail dot when the head advanced SEG_DIST. + # All body/head positioning is done IN this lambda (lv_obj_set_pos) to keep the action chain shallow. + - interval: 50ms + then: + - if: + condition: {{ lambda: 'return id(g_base) == 9 && id(g_sn_state) == 1;' }} + then: + - lambda: |- + id(g_sn_add) = -1; id(g_sn_del) = -1; id(g_sn_newfruit) = -1; id(g_sn_newskull) = -1; + int kd = id(g_knob_delta); id(g_knob_delta) = 0; + id(g_sn_ang) += kd * {TURN}f; + float spd = {SPEED0}f + id(g_sn_score) / 400.0f; if (spd > 5.5f) spd = 5.5f; + float hx = id(g_sn_hx) + cosf(id(g_sn_ang)) * spd; + float hy = id(g_sn_hy) + sinf(id(g_sn_ang)) * spd; + if (hx*hx + hy*hy > {R}f * {R}f) {{ id(g_sn_dead) = true; }} // hit the ring + else {{ + id(g_sn_hx) = hx; id(g_sn_hy) = hy; + int len = id(g_sn_len), head = id(g_sn_head); + for (int i = {SELF_SKIP}; i < len; i++) {{ + int sl = (head - i + {M}) % {POOL}; + float dx = hx - id(g_sn_tx)[sl], dy = hy - id(g_sn_ty)[sl]; + if (dx*dx + dy*dy < {SELF_DIST}f * {SELF_DIST}f) {{ id(g_sn_dead) = true; break; }} + }} + for (int s = 0; s < {NSKULL}; s++) if (id(g_sn_skon)[s]) {{ + float dx = hx - id(g_sn_skx)[s], dy = hy - id(g_sn_sky)[s]; + if (dx*dx + dy*dy < {SKHIT}f * {SKHIT}f) {{ id(g_sn_dead) = true; }} + }} + for (int s = 0; s < {NFSLOT} && !id(g_sn_dead); s++) if (id(g_sn_fon)[s]) {{ + float dx = hx - id(g_sn_fx)[s], dy = hy - id(g_sn_fy)[s]; + if (dx*dx + dy*dy < {EAT}f * {EAT}f) {{ id(g_sn_fon)[s] = false; id(g_sn_score) += 10; id(g_sn_grow) += {GROW}; }} + }} + if (!id(g_sn_dead)) {{ + float dx = hx - id(g_sn_tx)[head], dy = hy - id(g_sn_ty)[head]; + if (dx*dx + dy*dy >= {SEG_DIST}f * {SEG_DIST}f) {{ + int nh = (head + 1) % {POOL}; + id(g_sn_tx)[nh] = hx; id(g_sn_ty)[nh] = hy; + id(g_sn_head) = nh; id(g_sn_add) = nh; + if (id(g_sn_grow) > 0 && id(g_sn_len) < {POOL - 1}) {{ id(g_sn_grow)--; id(g_sn_len)++; id(g_sn_del) = -1; }} // cap at POOL-1: the ring needs one free slot or add/drop collide + else {{ if (id(g_sn_grow) > 0) id(g_sn_grow)--; id(g_sn_del) = (head - id(g_sn_len) + 1 + {M}) % {POOL}; }} + }} + }} + }} + if (!id(g_sn_dead)) {{ + int fc = 0; for (int s = 0; s < {NFSLOT}; s++) if (id(g_sn_fon)[s]) fc++; + id(g_sn_fclk)++; + int wait = (fc <= 1) ? {FRUIT_WAIT // 2} : {FRUIT_WAIT}; + if (fc < {NFSLOT} && id(g_sn_fclk) >= wait) {{ + id(g_sn_fclk) = 0; + int sl = -1; for (int s = 0; s < {NFSLOT}; s++) if (!id(g_sn_fon)[s]) {{ sl = s; break; }} + if (sl >= 0) {{ + float a = (esp_random() % 62832) / 10000.0f; float rad = 25.0f + (esp_random() % 140); + id(g_sn_fx)[sl] = cosf(a)*rad; id(g_sn_fy)[sl] = sinf(a)*rad; + id(g_sn_fi)[sl] = esp_random() % {NFRUIT}; id(g_sn_fon)[sl] = true; id(g_sn_newfruit) = sl; + }} + }} + int sc = 0; for (int s = 0; s < {NSKULL}; s++) if (id(g_sn_skon)[s]) sc++; + id(g_sn_skclk)++; + if (sc < {NSKULL} && id(g_sn_skclk) >= {SKULL_WAIT}) {{ + id(g_sn_skclk) = 0; + int sl = -1; for (int s = 0; s < {NSKULL}; s++) if (!id(g_sn_skon)[s]) {{ sl = s; break; }} + if (sl >= 0) {{ + float a = (esp_random() % 62832) / 10000.0f; float rad = 30.0f + (esp_random() % 130); + id(g_sn_skx)[sl] = cosf(a)*rad; id(g_sn_sky)[sl] = sinf(a)*rad; + id(g_sn_skon)[sl] = true; id(g_sn_skage)[sl] = 0; id(g_sn_newskull) = sl; + }} + }} + for (int s = 0; s < {NSKULL}; s++) if (id(g_sn_skon)[s]) {{ id(g_sn_skage)[s]++; if (id(g_sn_skage)[s] >= {SKULL_LIFE}) id(g_sn_skon)[s] = false; }} + // render (constant cost): head every tick + the one added/dropped dot + lv_obj_set_pos(id(sn_head), 180 + (int) lroundf(id(g_sn_hx)) - {HALF}, 180 + (int) lroundf(id(g_sn_hy)) - {HALF}); + if (id(g_sn_add) >= 0 || id(g_sn_del) >= 0) {{ + {SEGINIT} + if (id(g_sn_add) >= 0) lv_obj_set_pos(SS[id(g_sn_add)], 180 + (int) lroundf(id(g_sn_tx)[id(g_sn_add)]) - {HALF}, 180 + (int) lroundf(id(g_sn_ty)[id(g_sn_add)]) - {HALF}); + if (id(g_sn_del) >= 0) lv_obj_set_pos(SS[id(g_sn_del)], 400, 400); + }} + }} + - if: + condition: {{ lambda: 'return id(g_sn_dead);' }} + then: + - script.execute: sn_over + else: + - if: + condition: {{ lambda: 'return id(g_sn_newfruit) >= 0;' }} + then: +{fspr_dispatch()} + - if: + condition: {{ lambda: 'return id(g_sn_newskull) >= 0;' }} + then: +{skull_dispatch()} + - script.execute: sn_items + - script.execute: sn_hud + +image: + - id: img_sn_logo + file: "{BASE}snake-logo.png" + type: RGB565 +{fruit_images()} + - id: img_sn_skull + file: "{BASE}skull.png" + type: RGB565 + transparency: alpha_channel + +lvgl: + pages: + - id: page_snake + bg_color: {PAGE_BG} + scrollbar_mode: "off" + scrollable: false + widgets: + - obj: {{ id: sn_ring, align: CENTER, x: 0, y: 0, width: {RING_D}, height: {RING_D}, radius: {RING_D // 2}, bg_opa: TRANSP, border_width: 4, border_color: {RING}, hidden: true }} +{fruit_widgets()} +{skull_widgets()} +{body_widgets()} + - obj: {{ id: sn_head, x: 400, y: 400, width: {SEG}, height: {SEG}, radius: {SEGR}, border_width: 0, bg_color: {HEAD}, bg_opa: COVER }} + - label: {{ id: lbl_sn_score, align: CENTER, x: 0, y: -150, text: "0", text_font: font_med, text_color: {HUD}, hidden: true }} + - image: {{ id: sn_logo, align: CENTER, x: 0, y: 0, src: img_sn_logo, hidden: true }} + - button: + id: btn_sn_start + align: CENTER + y: 4 + width: 150 + height: 44 + radius: 22 + bg_color: {ACCENT} + bg_opa: COVER + border_width: 0 + on_click: [ script.execute: sn_start ] + widgets: + - label: {{ align: CENTER, text: "Start", text_font: font_med, text_color: {ACCENT_TXT} }} + - button: + id: btn_sn_howto + align: CENTER + y: 54 + width: 150 + height: 44 + radius: 22 + bg_color: {BTN2} + bg_opa: COVER + border_width: 0 + on_click: [ lambda: 'id(g_sn_state) = 3;', script.execute: sn_screen ] + widgets: + - label: {{ align: CENTER, text: "How to play?", text_font: font_med, text_color: {BTN2_TXT} }} + - button: + id: btn_sn_scores + align: CENTER + y: 104 + width: 150 + height: 44 + radius: 22 + bg_color: {BTN2} + bg_opa: COVER + border_width: 0 + on_click: [ lambda: 'id(g_sn_state) = 4;', script.execute: sn_screen ] + widgets: + - label: {{ align: CENTER, text: "Scores", text_font: font_med, text_color: {BTN2_TXT} }} + - label: {{ id: lbl_sn_over, align: CENTER, x: 0, y: -10, text_align: CENTER, text: "", text_font: font_sel, text_color: {WORDMARK}, hidden: true }} + - label: {{ id: lbl_sn_rec, align: CENTER, x: 0, y: 22, text_align: CENTER, text: "", text_font: font_artist, text_color: {WORDMARK}, hidden: true }} + - button: + id: btn_sn_replay + align: CENTER + x: -66 + y: 62 + width: 112 + height: 48 + radius: 24 + bg_color: {ACCENT} + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ script.execute: sn_start ] + widgets: + - label: {{ align: CENTER, text: "Play", text_font: font_med, text_color: {ACCENT_TXT} }} + - button: + id: btn_sn_exit + align: CENTER + x: 66 + y: 62 + width: 112 + height: 48 + radius: 24 + bg_color: {GRAY} + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ lambda: 'id(g_sn_state) = 0;', script.execute: sn_screen ] + widgets: + - label: {{ align: CENTER, text: "Exit", text_font: font_med, text_color: 0xFFFFFF }} + - label: {{ id: lbl_sn_howto_t, align: CENTER, y: -104, text: "HOW TO PLAY?", text_font: font_sel, text_color: {ACCENT}, hidden: true }} + - obj: {{ id: sn_b1, align: CENTER, x: -96, y: -52, width: 24, height: 24, radius: 12, bg_color: {BADGE}, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: {{ align: CENTER, text: "1", text_font: font_artist, text_color: 0xFFFFFF }} ] }} + - obj: {{ id: sn_b2, align: CENTER, x: -96, y: -16, width: 24, height: 24, radius: 12, bg_color: {BADGE}, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: {{ align: CENTER, text: "2", text_font: font_artist, text_color: 0xFFFFFF }} ] }} + - obj: {{ id: sn_b3, align: CENTER, x: -96, y: 20, width: 24, height: 24, radius: 12, bg_color: {BADGE}, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: {{ align: CENTER, text: "3", text_font: font_artist, text_color: 0xFFFFFF }} ] }} + - obj: {{ id: sn_b4, align: CENTER, x: -96, y: 56, width: 24, height: 24, radius: 12, bg_color: 0xE05A4A, bg_opa: COVER, border_width: 0, pad_all: 0, hidden: true, widgets: [ label: {{ align: CENTER, text: "!", text_font: font_artist, text_color: 0xFFFFFF }} ] }} + - label: {{ id: lbl_sn_howto1, align_to: {{ id: sn_b1, align: OUT_RIGHT_MID, x: 8 }}, text: "Turn knob to steer", text_font: font_med, text_color: {TXT}, hidden: true }} + - label: {{ id: lbl_sn_howto2, align_to: {{ id: sn_b2, align: OUT_RIGHT_MID, x: 8 }}, text: "Eat fruit to grow", text_font: font_med, text_color: {TXT}, hidden: true }} + - label: {{ id: lbl_sn_howto3, align_to: {{ id: sn_b3, align: OUT_RIGHT_MID, x: 8 }}, text: "Avoid the ring & tail", text_font: font_med, text_color: {TXT}, hidden: true }} + - label: {{ id: lbl_sn_howto4, align_to: {{ id: sn_b4, align: OUT_RIGHT_MID, x: 8 }}, text: "Skulls kill - avoid!", text_font: font_med, text_color: {TXT}, hidden: true }} + - label: {{ id: lbl_sn_scores_t, align: CENTER, y: -116, text: "SCORES", text_font: font_sel, text_color: {ACCENT}, hidden: true }} + - label: {{ id: lbl_sn_scores1, align: CENTER, x: -52, y: 0, text: "", text_align: LEFT, text_font: font_artist, text_color: {TXT}, hidden: true }} + - label: {{ id: lbl_sn_scores2, align: CENTER, x: 56, y: 0, text: "", text_align: LEFT, text_font: font_artist, text_color: {TXT}, hidden: true }} + - button: + id: btn_sn_back + align: CENTER + y: 120 + width: 130 + height: 42 + radius: 21 + bg_color: {GRAY} + bg_opa: COVER + border_width: 0 + hidden: true + on_click: [ lambda: 'id(g_sn_state) = 0;', script.execute: sn_screen ] + widgets: + - label: {{ align: CENTER, text: "Back", text_font: font_med, text_color: 0xFFFFFF }} +""" + +here = os.path.dirname(os.path.abspath(__file__)) +out = os.path.join(here, "..", "base", "screens", "snake.yaml") +out = os.path.normpath(out) +with open(out, "w", encoding="utf-8", newline="\n") as f: + f.write(YAML) +print("wrote", out, "(", len(YAML), "bytes )") diff --git a/scripts/gen_weather.py b/scripts/gen_weather.py new file mode 100644 index 0000000..a781c46 --- /dev/null +++ b/scripts/gen_weather.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +# Generates base/screens/weather.yaml (the Weather carousel screen, base id 5). +# python scripts/gen_weather.py +# +# Modern look: fixed partly-cloudy palette, colour-coded centre temperature, ANIMATED condition +# icons (animimg frame loops, every HA condition maps to one of 7 icons), and a glow that slides +# around the rim to the selected day. Self-contained: it owns the HA forecast data layer (sensors + +# parsing into g_wx_*) AND the rendering. Animated frames: assets/sprites/weather/0..7.png. +# +# DATA layer (sensors/parser/globals) is kept as raw strings; the modern render/page is an f-string. +import os + +ICONS = ["sun", "partly", "moon", "cloud", "rain", "storm", "snow"] +NF = 8 +DUR = "1400ms" +BASE = "https://raw.githubusercontent.com/MichalZaniewicz/esphome-guition-jc3636k718c-va/main/assets/sprites/weather/" +BG = "0x1C2838"; GLOW = "0xCAD6E8"; RIM = "0xDCE6F2"; ACCENT = "0x9AA6B2"; CONDC = "0xCFE0F2"; PAGE = "0x0B1020" + +# ---------- data layer (raw - HA sensors + forecast parser + data globals + font) ---------- +HEAD = """# Weather - carousel screen (base id 5). GENERATED by scripts/gen_weather.py. +# A dial of forecast days around the rim (knob scrolls), animated condition icon + colour-coded +# temperature in the centre, a glow that slides to the selected day. Forecast comes from a Home +# Assistant helper sensor (see base/screens/weather.ha-helper.yaml). Remove: drop from `files:`. + +esphome: + on_boot: + - priority: 655 + then: + - lambda: 'id(g_present)[5] = true;' + - priority: 650 + then: + - lambda: |- + int g = id(g_wgrp_n)++; + id(g_wgrp_labels) += (g ? "\\n" : ""); id(g_wgrp_labels) += "Weather"; + int o = id(g_wopt_n)++; + id(g_wopt_group)[o] = g; id(g_wopt_label)[o] = "Fahrenheit"; id(g_wopt_kind)[o] = 0; id(g_wopt_ptr)[o] = &id(g_wx_fahrenheit); + +globals: + # --- forecast data --- + - id: g_wx_fahrenheit + type: bool + restore_value: yes + initial_value: 'false' + - id: g_wx_now + type: int + restore_value: no + initial_value: '-127' + - id: g_wx_state_cur + type: std::string + restore_value: no + - id: g_wx_cond + type: std::string[7] + restore_value: no + - id: g_wx_icon + type: std::string[7] + restore_value: no + - id: g_wx_name + type: std::string[7] + restore_value: no + - id: g_wx_hi + type: int[7] + restore_value: no + - id: g_wx_lo + type: int[7] + restore_value: no + - id: g_wx_n + type: int + restore_value: no + initial_value: '0' + - id: g_wx_day + type: int + restore_value: no + initial_value: '0' + # --- render cache --- + - id: g_wx_sel + type: std::string + restore_value: no + - id: g_wx_tc + type: int + restore_value: no + initial_value: '-127' + - id: g_wx_lastcond + type: std::string + restore_value: no + - id: g_wx_ikey + type: std::string + restore_value: no + - id: g_wx_gpos + type: float + restore_value: no + initial_value: '0.0' + +font: + - file: "https://github.com/Templarian/MaterialDesign-Webfont/raw/v7.4.47/fonts/materialdesignicons-webfont.ttf" + id: icons_wx + size: 30 + bpp: 4 + glyphs: ["\U000f0599", "\U000f0594", "\U000f0595", "\U000f0591", "\U000f059d", "\U000f0593", "\U000f0596", "\U000f0597", "\U000f0598", "\U000f0590"] +""" + +SENSORS = """ +sensor: + - platform: homeassistant + id: wx_now_temp + entity_id: ${weather_entity} + attribute: temperature + on_value: + - lambda: 'id(g_wx_now) = (int) roundf(x);' + - script.execute: wx_render + +text_sensor: + - platform: homeassistant + id: wx_now_state + entity_id: ${weather_entity} + on_value: + - lambda: 'id(g_wx_state_cur) = x;' + - script.execute: wx_render + - platform: homeassistant + id: wx_week + entity_id: ${forecast_entity} + on_value: + - lambda: |- + auto glyph = [](const std::string &s) -> std::string { + if (s == "sunny") return std::string("\U000f0599"); + if (s == "clear-night") return std::string("\U000f0594"); + if (s == "partlycloudy") return std::string("\U000f0595"); + if (s == "fog") return std::string("\U000f0591"); + if (s == "windy" || s == "windy-variant") return std::string("\U000f059d"); + if (s == "lightning" || s == "lightning-rainy") return std::string("\U000f0593"); + if (s == "pouring") return std::string("\U000f0596"); + if (s == "rainy") return std::string("\U000f0597"); + if (s == "snowy" || s == "snowy-rainy" || s == "hail") return std::string("\U000f0598"); + return std::string("\U000f0590"); + }; + std::string s = x; + for (int i = 0; i < 7; i++) { id(g_wx_icon)[i] = ""; id(g_wx_cond)[i] = ""; } + int n = 0; size_t pos = 0; + while (n < 7) { + size_t bar = s.find('|', pos); + std::string seg = (bar == std::string::npos) ? s.substr(pos) : s.substr(pos, bar - pos); + size_t c1 = seg.find(','); + if (c1 != std::string::npos) { + size_t c2 = seg.find(',', c1 + 1); + size_t c3 = (c2 != std::string::npos) ? seg.find(',', c2 + 1) : std::string::npos; + if (c2 != std::string::npos && c3 != std::string::npos) { + std::string cond = seg.substr(0, c1); + id(g_wx_cond)[n] = cond; + id(g_wx_icon)[n] = glyph(cond); + id(g_wx_hi)[n] = atoi(seg.substr(c1 + 1, c2 - c1 - 1).c_str()); + id(g_wx_lo)[n] = atoi(seg.substr(c2 + 1, c3 - c2 - 1).c_str()); + id(g_wx_name)[n] = seg.substr(c3 + 1); + n++; + } + } + if (bar == std::string::npos) break; + pos = bar + 1; + } + id(g_wx_n) = n; + if (id(g_wx_day) >= n) id(g_wx_day) = 0; + - script.execute: wx_render +""" + + +def image_block(): + return "\n".join( +f""" - id: img_wx_{k}{i} + file: "{BASE}{k}{i}.png" + type: RGB565 + transparency: alpha_channel""" for k in ICONS for i in range(NF)) + + +def animimg_widgets(): + out = [] + for k in ICONS: + src = ", ".join(f"img_wx_{k}{i}" for i in range(NF)) + out.append( + f" - animimg: {{ id: wx_a_{k}, align: CENTER, x: 0, y: -52, duration: {DUR}, " + f"repeat_count: 65535, auto_start: false, hidden: true, src: [ {src} ] }}") + return "\n".join(out) + + +def dispatch_stop_hide(): + out = [f" - lvgl.animimg.stop: wx_a_{k}" for k in ICONS] + out += [f" - lvgl.widget.hide: wx_a_{k}" for k in ICONS] + return "\n".join(out) + + +def dispatch_show(): + return "\n".join( + f" - if: {{ condition: {{ lambda: 'return id(g_wx_ikey) == \"{k}\";' }}, " + f"then: [ lvgl.widget.show: wx_a_{k}, lvgl.animimg.start: wx_a_{k} ] }}" + for k in ICONS) + + +def rim_updates(): + out = [] + for i in range(7): + out.append( + f" - lvgl.widget.update: {{ id: wx_d{i}, hidden: !lambda 'return {i} >= id(g_wx_n);', " + f"x: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(140.0f * sinf({i} * 6.2831853f / n));', " + f"y: !lambda 'int n=id(g_wx_n); if(n<1)n=1; return (int) lroundf(-140.0f * cosf({i} * 6.2831853f / n));' }}") + out.append( + f" - lvgl.label.update: {{ id: wx_d{i}, text: !lambda 'return id(g_wx_n) > {i} ? id(g_wx_icon)[{i}] : std::string(\"\");' }}") + return "\n".join(out) + + +def rim_widgets(): + return "\n".join( + f" - label: {{ id: wx_d{i}, align: CENTER, x: 0, y: 0, text: \"\", text_font: icons_wx, text_color: {RIM}, hidden: true }}" + for i in range(7)) + + +MODERN = f""" +image: +{image_block()} + +script: + - id: wx_render + then: + - lambda: |- + int d = id(g_wx_day); + bool has = (id(g_wx_n) > 0 && d < id(g_wx_n)); + id(g_wx_sel) = has ? id(g_wx_cond)[d] : id(g_wx_state_cur); + id(g_wx_tc) = has ? id(g_wx_hi)[d] : id(g_wx_now); + - lvgl.label.update: + id: wx_day + text: !lambda |- + int d = id(g_wx_day); + if (id(g_wx_n) <= 0 || d >= id(g_wx_n)) return std::string("TODAY"); + std::string s = id(g_wx_name)[d]; + if (s == "Mon") return std::string("MONDAY"); + if (s == "Tue") return std::string("TUESDAY"); + if (s == "Wed") return std::string("WEDNESDAY"); + if (s == "Thu") return std::string("THURSDAY"); + if (s == "Fri") return std::string("FRIDAY"); + if (s == "Sat") return std::string("SATURDAY"); + if (s == "Sun") return std::string("SUNDAY"); + return s; + - lvgl.label.update: + id: wx_htemp + text: !lambda |- + int d = id(g_wx_day); + bool f = id(g_wx_fahrenheit); + char u = f ? 'F' : 'C'; + if (id(g_wx_n) <= 0 || d >= id(g_wx_n)) {{ + if (id(g_wx_now) > -100) {{ int n = id(g_wx_now); if (f) n = n*9/5+32; char b[12]; snprintf(b,sizeof(b),"%d°%c",n,u); return std::string(b); }} + return std::string("--"); + }} + int hi = id(g_wx_hi)[d], lo = id(g_wx_lo)[d]; + if (f) {{ hi = hi*9/5+32; lo = lo*9/5+32; }} + char b[24]; snprintf(b,sizeof(b),"%d° / %d°",hi,lo); return std::string(b); + - lvgl.label.update: + id: wx_cond + text: !lambda |- + if (id(g_wx_n) <= 0) return std::string("Add the HA forecast helper"); + std::string s = id(g_wx_sel); + if (s == "sunny") return std::string("Sunny"); + if (s == "clear-night") return std::string("Clear"); + if (s == "partlycloudy") return std::string("Partly Cloudy"); + if (s == "cloudy") return std::string("Cloudy"); + if (s == "fog") return std::string("Fog"); + if (s == "rainy") return std::string("Rainy"); + if (s == "pouring") return std::string("Heavy Rain"); + if (s == "lightning" || s == "lightning-rainy") return std::string("Storm"); + if (s == "snowy" || s == "snowy-rainy") return std::string("Snow"); + if (s == "hail") return std::string("Hail"); + if (s == "windy" || s == "windy-variant") return std::string("Windy"); + if (s == "exceptional") return std::string("Severe"); + return s; + - if: + condition: {{ lambda: 'return id(g_wx_sel) != id(g_wx_lastcond);' }} + then: + - lambda: |- + id(g_wx_lastcond) = id(g_wx_sel); + std::string s = id(g_wx_sel), k; + if (s == "sunny") k = "sun"; + else if (s == "clear-night") k = "moon"; + else if (s == "partlycloudy") k = "partly"; + else if (s == "rainy" || s == "pouring") k = "rain"; + else if (s == "lightning" || s == "lightning-rainy") k = "storm"; + else if (s == "snowy" || s == "snowy-rainy" || s == "hail") k = "snow"; + else k = "cloud"; + id(g_wx_ikey) = k; +{dispatch_stop_hide()} +{dispatch_show()} + - lvgl.widget.update: {{ id: wx_glow, hidden: !lambda 'return id(g_wx_n) <= 0;' }} +{rim_updates()} + - lvgl.label.update: {{ id: wx_htemp, text_color: 0xFFFFFF }} + - if: {{ condition: {{ lambda: 'return id(g_wx_tc) > -100 && id(g_wx_tc) <= 0;' }}, then: [ lvgl.label.update: {{ id: wx_htemp, text_color: 0x7FB2FF }} ] }} + - if: {{ condition: {{ lambda: 'return id(g_wx_tc) > 0 && id(g_wx_tc) < 10;' }}, then: [ lvgl.label.update: {{ id: wx_htemp, text_color: 0x6FD0E8 }} ] }} + - if: {{ condition: {{ lambda: 'return id(g_wx_tc) >= 10 && id(g_wx_tc) < 18;' }}, then: [ lvgl.label.update: {{ id: wx_htemp, text_color: 0x7FE6B0 }} ] }} + - if: {{ condition: {{ lambda: 'return id(g_wx_tc) >= 18 && id(g_wx_tc) < 25;' }}, then: [ lvgl.label.update: {{ id: wx_htemp, text_color: 0xFFD061 }} ] }} + - if: {{ condition: {{ lambda: 'return id(g_wx_tc) >= 25;' }}, then: [ lvgl.label.update: {{ id: wx_htemp, text_color: 0xFF7A4D }} ] }} +{SENSORS} +interval: + - interval: 50ms + then: + - if: + condition: {{ lambda: 'return id(g_nav_req) && id(g_base)==5;' }} + then: + - lambda: 'id(g_nav_req) = false; id(g_knob_capture) = true; id(g_wx_day) = 0; id(g_wx_gpos) = 0.0f; id(g_wx_lastcond) = "";' + - if: {{ condition: {{ lambda: 'return id(g_nav_anim)==0;' }}, then: [lvgl.page.show: {{ id: page_weather, animation: move_left, time: 250ms }}] }} + - if: {{ condition: {{ lambda: 'return id(g_nav_anim)==1;' }}, then: [lvgl.page.show: {{ id: page_weather, animation: move_right, time: 250ms }}] }} + - if: {{ condition: {{ lambda: 'return id(g_nav_anim)==2;' }}, then: [lvgl.page.show: {{ id: page_weather, animation: move_top, time: 250ms }}] }} + - if: {{ condition: {{ lambda: 'return id(g_nav_anim)==3;' }}, then: [lvgl.page.show: {{ id: page_weather, animation: move_bottom, time: 250ms }}] }} + - script.execute: wx_render + - interval: 120ms + then: + - if: + condition: {{ lambda: 'return id(g_base)==5 && id(g_knob_delta) != 0;' }} + then: + - lambda: |- + id(g_wx_day) += id(g_knob_delta); id(g_knob_delta) = 0; + int mx = (id(g_wx_n) > 0) ? id(g_wx_n) - 1 : 0; + if (id(g_wx_day) < 0) id(g_wx_day) = 0; + if (id(g_wx_day) > mx) id(g_wx_day) = mx; + - script.execute: wx_render + - interval: 30ms + then: + - if: + condition: {{ lambda: 'return id(g_base)==5;' }} + then: + - lambda: |- + float p = id(g_wx_gpos), tgt = (float) id(g_wx_day); + if (fabsf(tgt - p) > 0.001f) {{ // only while actually sliding + int n = id(g_wx_n); if (n < 1) n = 1; + p += (tgt - p) * 0.35f; + if (fabsf(tgt - p) < 0.02f) p = tgt; + id(g_wx_gpos) = p; + float rx = 140.0f * sinf(p * 6.2831853f / n); + float ry = -140.0f * cosf(p * 6.2831853f / n); + lv_obj_set_pos(id(wx_glow), 180 + (int) lroundf(rx) - 28, 180 + (int) lroundf(ry) - 28); + }} + +lvgl: + pages: + - id: page_weather + bg_color: {PAGE} + widgets: + - obj: {{ id: wx_bg, align: CENTER, x: 0, y: 0, width: 360, height: 360, radius: 0, bg_color: {BG}, bg_opa: COVER, border_width: 0 }} + - obj: {{ id: wx_glow, x: 152, y: 12, width: 56, height: 56, radius: 28, bg_color: {GLOW}, bg_opa: 30%, border_width: 0, hidden: true }} +{rim_widgets()} +{animimg_widgets()} + - label: {{ id: wx_day, align: CENTER, x: 0, y: -10, text: "", text_font: font_artist, text_color: {ACCENT} }} + - label: {{ id: wx_htemp, align: CENTER, x: 0, y: 24, text: "--", text_font: montserrat_34, text_color: 0xFFFFFF }} + - label: {{ id: wx_cond, align: CENTER, x: 0, y: 62, text: "", text_font: font_med, text_color: {CONDC} }} +""" + +YAML = HEAD + MODERN + +here = os.path.dirname(os.path.abspath(__file__)) +out = os.path.normpath(os.path.join(here, "..", "base", "screens", "weather.yaml")) +with open(out, "w", encoding="utf-8", newline="\n") as f: + f.write(YAML) +print("wrote", out, "(", len(YAML), "bytes )") diff --git a/scripts/make_sounds.py b/scripts/make_sounds.py new file mode 100644 index 0000000..90c0d06 --- /dev/null +++ b/scripts/make_sounds.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate wake.wav (wake-word confirmation) and alarm.wav (timer alarm). + +Pure stdlib (wave + math), no dependencies. Output: 16-bit PCM, mono, 16 kHz. +Run: python make_sounds.py +""" +import math +import struct +import wave +import os + +RATE = 16000 +SOUNDS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets", "sounds") +os.makedirs(SOUNDS, exist_ok=True) + + +def _tone(freq, dur, vol=0.6, rate=RATE): + """One sine tone with short fade in/out to avoid clicks.""" + n = int(rate * dur) + fade = max(1, int(rate * 0.005)) # 5 ms fade + out = [] + for i in range(n): + env = 1.0 + if i < fade: + env = i / fade + elif i > n - fade: + env = (n - i) / fade + out.append(vol * env * math.sin(2 * math.pi * freq * i / rate)) + return out + + +def _silence(dur, rate=RATE): + return [0.0] * int(rate * dur) + + +def _write(path, samples, rate=RATE): + with wave.open(path, "w") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(rate) + frames = b"".join( + struct.pack("