diff --git a/.cargo/config.toml b/.cargo/config.toml index b9f7bc2..8dc2265 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -6,6 +6,10 @@ target = "xtensa-esp32-espidf" sim = "run --no-default-features --features simulator --target x86_64-unknown-linux-gnu" simmac = "run --no-default-features --features simulator --target aarch64-apple-darwin" simwin = "run --no-default-features --features simulator --target x86_64-pc-windows-msvc" +# Simulator with HA MQTT Discovery enabled +simha = "run --no-default-features --features simulator,home-assistant --target x86_64-unknown-linux-gnu" +simmacHA = "run --no-default-features --features simulator,home-assistant --target aarch64-apple-darwin" +simwinHA = "run --no-default-features --features simulator,home-assistant --target x86_64-pc-windows-msvc" [target.xtensa-esp32-espidf] linker = "ldproxy" diff --git a/CLAUDE.md b/CLAUDE.md index 840b55e..e63ae5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,18 +96,20 @@ ffmpeg -f lavfi -i color=black:s=180x180 -i rat_barista.png \ --- -## Node-RED / Home Assistant Bridge (`docs/node-red-ha-bridge.json`) +## Home Assistant Integration (`src/home_assistant.rs`) -`docs/node-red-ha-bridge.json` is a Node-RED flow that bridges MaraTUI MQTT topics to Home Assistant via MQTT Discovery. Import it into Node-RED to get HA sensors out of the box. +Build with `--features home-assistant` to enable direct HA MQTT Discovery publishing from the firmware — no Node-RED needed. On every MQTT connect, the firmware publishes retained discovery configs to `homeassistant/{sensor,binary_sensor}/maratui_*/config` and streams state updates alongside the existing `mara/*` topics. -**Keep this file in sync when changing MQTT payloads:** +**Keep `home_assistant.rs` in sync when changing MQTT payloads:** -| Change | What to update in the flow | -|--------|---------------------------| -| Add/remove a field in `mara/status` payload (`device_status_payload` in `fsm.rs`) | `fn_status` function node + `fn_discovery` sensor list | -| Add/remove a field in `mara/telemetry` payload (`telemetry_payload` in `fsm.rs`) | `fn_telemetry` function node + `fn_discovery` sensor list | -| Add/remove an event type in `mara/events` | `fn_events` function node | -| Rename MQTT topic prefix | `mqtt_in_*` nodes (topic field) and any hardcoded topic strings in function nodes | +| Change | What to update | +|--------|----------------| +| Add/remove a field in `mara/status` payload (`device_status_payload` in `fsm.rs`) | `enqueue_status_states` in `home_assistant.rs` + sensor list in `enqueue_discovery_configs` | +| Add/remove a field in `mara/telemetry` payload (`telemetry_payload` in `fsm.rs`) | `enqueue_telemetry_states` in `home_assistant.rs` + sensor list in `enqueue_discovery_configs` | +| Add/remove an event type in `mara/events` | `enqueue_event_states` in `home_assistant.rs` | +| Rename MQTT topic prefix | Update `docs/ha-automation.yaml` topic strings | + +The cup counter requires a one-time HA automation setup — see `docs/home-assistant.md` and `docs/ha-automation.yaml`. The simulator connects with client ID `-sim` (e.g. `maratui-dev-sim`) to avoid session conflicts when device and simulator run simultaneously. diff --git a/Cargo.lock b/Cargo.lock index 2d5a0f4..ea77d5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1302,7 +1302,7 @@ dependencies = [ [[package]] name = "maratui" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "display-interface-spi", diff --git a/Cargo.toml b/Cargo.toml index e869b19..d619e4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maratui" -version = "0.3.2" +version = "0.3.3" edition = "2024" [features] @@ -9,6 +9,8 @@ default = ["device"] device = ["dep:esp-idf-svc", "dep:ili9341", "dep:display-interface-spi", "dep:embuild"] # Build UI on host. cargo sim --target simulator = ["dep:embedded-graphics-simulator", "dep:rumqttc"] +# Publish HA MQTT Discovery configs and state directly from firmware (no Node-RED needed) +home-assistant = [] [dependencies] anyhow = "1.0.100" diff --git a/Makefile b/Makefile index 8c4abfd..d636f4f 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,22 @@ ifeq ($(OS),Windows_NT) SIM_CMD = cargo simwin + SIMHA_CMD = cargo simwinHA else UNAME_S := $(shell uname -s) ifeq ($(UNAME_S),Darwin) SIM_CMD = cargo simmac + SIMHA_CMD = cargo simmacHA else SIM_CMD = cargo sim + SIMHA_CMD = cargo simha endif endif sim: $(SIM_CMD) + +sim-ha: + $(SIMHA_CMD) + +flash-ha: + cargo run --release --features home-assistant diff --git a/README.md b/README.md index 83d36d9..bb4fe9f 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,11 @@ See [docs/hardware.md](docs/hardware.md) for ESP32 pinout, display wiring. ### Home Assistant integration -See [docs/home-assistant.md](docs/home-assistant.md) for the Node-RED bridge that maps MaraTUI MQTT topics to HA entities via MQTT Discovery. +Build with `--features home-assistant` to enable direct MQTT Discovery publishing from the firmware — no Node-RED or other bridge required. On every MQTT connect the firmware publishes retained discovery configs and entities appear in HA automatically. + +> **Prerequisites:** Home Assistant must use the same MQTT broker as the ESP32, and the MQTT integration must be enabled (Settings → Devices & Services → MQTT). + +See [docs/home-assistant.md](docs/home-assistant.md) for setup instructions, entity list, and cup counter configuration. ### ESP32 (flash) diff --git a/docs/ha-automation.yaml b/docs/ha-automation.yaml new file mode 100644 index 0000000..5583327 --- /dev/null +++ b/docs/ha-automation.yaml @@ -0,0 +1,41 @@ +# Home Assistant automation: Mara shot counter +# +# Prerequisites: +# 1. Create a Counter helper in HA: +# Settings → Devices & Services → Helpers → + Create helper → Counter +# Name: "Mara Shots", entity_id: counter.coffee_counter +# +# 2. Import this automation: +# Settings → Automations → + Create automation → ⋮ → Edit in YAML +# Paste the YAML below, save, and enable. +# +# How it works: +# - Triggers on every mara/events MQTT message +# - Runs only when type == "shot_ended" AND duration > 20 s +# - Increments the counter helper +# - Publishes the new counter value back to mara/cup_counter (retained) +# so the MaraTUI display and the HA sensor both stay in sync +# +# If you changed MARATUI_MQTT_TOPIC_PREFIX from the default "mara", +# replace "mara/events" and "mara/cup_counter" below accordingly. + +alias: Mara Shot Counter +description: Increment espresso shot counter when a valid shot ends (duration > 20 s). +trigger: + - platform: mqtt + topic: mara/events +condition: + - condition: template + value_template: > + {{ trigger.payload_json.type == 'shot_ended' + and trigger.payload_json.duration | int(0) > 20 }} +action: + - service: counter.increment + target: + entity_id: counter.coffee_counter + - service: mqtt.publish + data: + topic: mara/cup_counter + payload: "{{ states('counter.coffee_counter') }}" + retain: true +mode: single diff --git a/docs/home-assistant.md b/docs/home-assistant.md index bbef9d3..94cc4d9 100644 --- a/docs/home-assistant.md +++ b/docs/home-assistant.md @@ -1,31 +1,45 @@ -# Home Assistant Integration via Node-RED +# Home Assistant Integration -This guide explains how to get Lelit Mara telemetry into Home Assistant using the Node-RED flow included in this repo. +MaraTUI can publish Home Assistant MQTT Discovery configs directly from the firmware — no Node-RED or any other bridge required. ## Architecture ``` ESP32 (MaraTUI) │ UART telemetry - └─► MQTT broker ──────────────────────────────────┐ - │ │ - │ mara/telemetry (JSON, ~1 Hz) │ - │ mara/events (JSON, on change) │ - └─► Node-RED flow │ - │ │ - │ MQTT Discovery + state topics │ - └─► Home Assistant │ - │ │ - └──────── sensors ───────┘ + │ MQTT (homeassistant/# discovery + state) + └─► MQTT broker ──────────────────────────────► Home Assistant + │ + sensors / binary sensors + auto-registered via Discovery ``` -The Node-RED flow acts as a bridge: it subscribes to the raw MaraTUI topics, transforms the data, and publishes Home Assistant MQTT Discovery configs so entities appear automatically — no manual YAML required. +On connect, the firmware publishes retained discovery configs to `homeassistant/sensor/maratui_*/config` and `homeassistant/binary_sensor/maratui_*/config`. Home Assistant picks them up automatically — no manual YAML needed. ## Prerequisites -- MQTT broker reachable by both the ESP32 and the machine running Node-RED (e.g. Mosquitto on the same host as HA) -- Node-RED with the `node-red-contrib-mqtt-broker` palette (bundled in most HA Node-RED add-ons) -- Home Assistant with the MQTT integration enabled +- MQTT broker reachable by both the ESP32 and Home Assistant (e.g. Mosquitto on the same host) +- Home Assistant with the MQTT integration enabled (Settings → Devices & Services → MQTT) + +## Enabling HA Discovery + +Add `--features home-assistant` to your build command: + +**Flash to device:** +```bash +cargo run --release --features home-assistant +# or +make flash-ha +``` + +**Run the simulator:** +```bash +cargo simha # Linux +cargo simmacHA # macOS +make sim-ha # auto-detects OS +``` + +That's it — on first MQTT connect the firmware publishes all discovery configs, and entities appear in HA within seconds. ## MQTT Topics Published by MaraTUI @@ -33,11 +47,11 @@ The Node-RED flow acts as a bridge: it subscribes to the raw MaraTUI topics, tra ```json { - "mode": "E", - "sw": "1.1", - "boiler_now_c": 93.5, - "boiler_target_c": 94.0, - "hx_now_c": 91.2, + "mode": "Coffee", + "sw": "1.10", + "boiler_now_c": 93, + "boiler_target_c": 94, + "hx_now_c": 91, "boost_countdown_s": 0, "heating_on": false, "pump_on": false, @@ -45,66 +59,55 @@ The Node-RED flow acts as a bridge: it subscribes to the raw MaraTUI topics, tra } ``` -| Field | Type | Description | -|---|---|---| -| `mode` | string | Machine operating mode (`E` = espresso, `S` = steam, etc.) | -| `sw` | string | Firmware version string from machine | -| `boiler_now_c` | float | Current boiler temperature, °C | -| `boiler_target_c` | float \| null | Boiler setpoint, °C; null when unavailable | -| `hx_now_c` | float | Heat exchanger temperature, °C | -| `boost_countdown_s` | int | Seconds remaining in boost mode (0 = inactive) | -| `heating_on` | bool | Heating element active | -| `pump_on` | bool | Pump active (shot in progress) | -| `no_water_code` | int \| null | Non-null when water reservoir is low | - -The topic prefix defaults to `mara` and is set via `MARATUI_MQTT_TOPIC_PREFIX` in `.env`. - ### `/events` — on-change JSON events | `type` | Extra fields | Description | |---|---|---| | `shot_started` | — | Pump just turned on | | `shot_ended` | `duration` (int, seconds) | Pump turned off; duration of the shot | +| `shot_aborted` | `duration` (int, seconds) | Pump ran < 10 s (rinse / pre-heat kick) | | `water_refill_needed` | `code` (int) | Water low detected | | `water_refill_cleared` | — | Water low cleared | | `mode_changed` | `from`, `to` (strings) | Machine mode transition | -## Importing the Node-RED Flow - -1. Open Node-RED (usually `http://:1880`). -2. Click the hamburger menu → **Import**. -3. Paste or upload `docs/node-red-ha-bridge.json`. -4. Click **Import**, then **Deploy**. - -On deploy the flow immediately publishes MQTT Discovery configs (retained), so entities appear in HA within a few seconds. +### `/status` — periodic device info (~30 s) -## Configuring the MQTT Broker - -The imported flow uses a broker node named **Local MQTT** pointing to `localhost:1883`. If your broker lives elsewhere: - -1. Double-click any MQTT node in the flow. -2. Click the pencil icon next to the broker field. -3. Update **Server** and **Port**. -4. Click **Update** → **Done** → **Deploy**. +```json +{ + "uptime_s": 3600, + "wifi_ssid": "MyNetwork", + "wifi_rssi": -62, + "ip": "192.168.1.42", + "free_heap_b": 180000, + "last_telemetry_age_s": 1 +} +``` -If your broker requires authentication, fill in **Username** / **Password** in the same broker config dialog. +The topic prefix defaults to `mara` and is configured via `MARATUI_MQTT_TOPIC_PREFIX` in `.env`. ## Home Assistant Entities -The flow registers the following entities under a single device named **Lelit Mara**: +All entities appear under a single device named **Lelit Mara**. ### Sensors | Entity | Unit | Notes | |---|---|---| -| Mode | — | Operating mode character | -| Firmware Version | — | Diagnostic entity | +| Mode | — | Operating mode | +| Firmware Version | — | Diagnostic | | Boiler Temperature | °C | `device_class: temperature` | | Boiler Target | °C | `device_class: temperature` | | HX Temperature | °C | `device_class: temperature` | -| Last Extraction Duration | s | Updated on shot end, retained | +| Last Extraction Duration | s | Retained; updated on shot end | | Extraction Timer | s | Live counter during shot | | Time Since Last Shot | min | Minutes since pump last stopped | +| Cup Counter | — | See cup counter setup below | +| Uptime | min | Diagnostic | +| Wi-Fi RSSI | dBm | Diagnostic | +| Wi-Fi SSID | — | Diagnostic | +| IP Address | — | Diagnostic | +| Free Heap | kB | Diagnostic | +| Telemetry Age | s | Diagnostic | ### Binary Sensors @@ -114,31 +117,55 @@ The flow registers the following entities under a single device named **Lelit Ma | Pump Active | On while shot is in progress | | Water Level Low | `device_class: problem` | -## How the Flow Works +## Cup Counter Setup -``` -[Inject on deploy] → [Build HA Discovery] → [MQTT out] -[mara/telemetry] → [Parse Telemetry] → [MQTT out] -[mara/events] → [Handle Events] → [MQTT out] -``` +The cup counter requires a short one-time setup in Home Assistant. The counter value is stored as an HA helper and published back to the broker so the MaraTUI display stays in sync. + +### 1. Create a Counter helper + +Settings → Devices & Services → Helpers → **+ Create helper** → **Counter** -**Build HA Discovery** — runs once 2 s after deploy. Publishes retained config payloads to `homeassistant/sensor/maratui_*/config` and `homeassistant/binary_sensor/maratui_*/config`. +- Name: `Mara Shots` +- Entity ID: `counter.coffee_counter` (default) +- Initial value: your current count (or 0) -**Parse Telemetry** — runs on every telemetry frame. Maintains a node context to compute the live extraction timer (seconds since `pump_on` became true) and reads a flow-level `last_shot_end` timestamp set by the events handler to compute time-since-last-shot. +### 2. Import the automation -**Handle Events** — runs on every event. Persists `last_extraction_duration` as a retained state, resets the extraction timer to 0 on `shot_started`, and syncs the water-low binary sensor directly from events (more reliable than polling the telemetry field). +Settings → Automations → **+ Create automation** → ⋮ menu → **Edit in YAML** + +Paste the contents of `docs/ha-automation.yaml`, save, and enable. + +The automation triggers on `mara/events` when `type == shot_ended` and `duration > 20 s`, increments the helper, and publishes the new value to `mara/cup_counter` (retained) so both the HA sensor and the ESP32 display update. + +### Cup counter flow + +``` +MaraTUI → mara/events {"type":"shot_ended","duration":35} + ↓ + HA automation (ha-automation.yaml) + duration > 20 s → counter.increment(counter.coffee_counter) + → mqtt.publish(mara/cup_counter, retained) + ↓ + ESP32 subscribes to mara/cup_counter → display updates + HA sensor maratui_cup_counter reads mara/cup_counter → HA updates +``` ## Troubleshooting **Entities don't appear in HA** -- Confirm the MQTT integration is enabled in HA (Settings → Devices & Services → MQTT). -- Check that the broker Node-RED connects to is the same one HA listens on. -- Re-click the **Publish Discovery** inject node manually to resend configs. +- Check that the MQTT integration is enabled (Settings → Devices & Services → MQTT). +- Confirm the broker MaraTUI connects to is the same one HA uses. +- Subscribe to `homeassistant/#` with `mosquitto_sub -t 'homeassistant/#' -v` to verify discovery configs are arriving. +- Power-cycle or reconnect the ESP32 — discovery configs are re-sent on every MQTT connect. **Values don't update** - Confirm the ESP32 is connected to Wi-Fi and MQTT (`MARATUI_MQTT_ENABLED=true` in `.env`). - Check the MaraTUI Debug screen for UART activity and connection status. -- In Node-RED, add a **debug** node after `mqtt in` to see raw messages. +- Subscribe to `mara/#` to see raw messages from the device. **Topic mismatch** -- The flow subscribes to `mara/telemetry` and `mara/events`. If you changed `MARATUI_MQTT_TOPIC_PREFIX`, update the topic fields in both `mqtt in` nodes. +- If you set `MARATUI_MQTT_TOPIC_PREFIX` to something other than `mara`, update the topics in `docs/ha-automation.yaml` accordingly. + +**Cup counter not incrementing** +- Verify the automation is enabled and the trigger topic matches your prefix. +- Check HA automation traces (Settings → Automations → Mara Shot Counter → ⋮ → Traces). diff --git a/docs/node-red-ha-bridge.json b/docs/node-red-ha-bridge.json deleted file mode 100644 index 1b4db55..0000000 --- a/docs/node-red-ha-bridge.json +++ /dev/null @@ -1,182 +0,0 @@ -[ - { - "id": "tab_maratui", - "type": "tab", - "label": "MaraTUI → HA", - "disabled": false, - "info": "MQTT bridge: Lelit Mara (MaraTUI) → Home Assistant via MQTT Discovery.\nSubscribes to maratui/telemetry, maratui/events, maratui/status.\nPublishes HA discovery configs on deploy, then streams state updates." - }, - { - "id": "broker_maratui", - "type": "mqtt-broker", - "name": "Local MQTT", - "broker": "localhost", - "port": "1883", - "clientid": "nodered-maratui-bridge", - "autoConnect": true, - "usetls": false, - "protocolVersion": "4", - "keepalive": "60", - "cleansession": true, - "birthTopic": "", - "birthQos": "0", - "birthPayload": "", - "birthMsg": {}, - "closeTopic": "", - "closeQos": "0", - "closePayload": "", - "closeMsg": {}, - "willTopic": "", - "willQos": "0", - "willPayload": "", - "willMsg": {}, - "userProps": "", - "sessionExpiry": "" - }, - { - "id": "inject_discovery", - "type": "inject", - "z": "tab_maratui", - "name": "Publish Discovery", - "props": [{"p": "payload", "v": "", "vt": "date"}], - "repeat": "", - "crontab": "", - "once": true, - "onceDelay": 2, - "topic": "", - "payload": "", - "payloadType": "date", - "x": 160, - "y": 80, - "wires": [["fn_discovery"]] - }, - { - "id": "fn_discovery", - "type": "function", - "z": "tab_maratui", - "name": "Build HA Discovery", - "func": "const DEVICE = {\n identifiers: ['maratui_esp32'],\n name: 'Lelit Mara',\n model: 'MaraTUI',\n manufacturer: 'Lelit'\n};\n\nfunction sensor(id, name, extra) {\n return {\n topic: `homeassistant/sensor/maratui_${id}/config`,\n payload: JSON.stringify(Object.assign({\n name: name,\n unique_id: `maratui_${id}`,\n state_topic: `homeassistant/sensor/maratui_${id}/state`,\n device: DEVICE\n }, extra)),\n qos: 1,\n retain: true\n };\n}\n\nfunction binary(id, name, extra) {\n return {\n topic: `homeassistant/binary_sensor/maratui_${id}/config`,\n payload: JSON.stringify(Object.assign({\n name: name,\n unique_id: `maratui_${id}`,\n state_topic: `homeassistant/binary_sensor/maratui_${id}/state`,\n payload_on: 'ON',\n payload_off: 'OFF',\n device: DEVICE\n }, extra)),\n qos: 1,\n retain: true\n };\n}\n\nconst msgs = [\n sensor('mode', 'Mode', { icon: 'mdi:coffee-maker' }),\n sensor('firmware', 'Firmware Version', { icon: 'mdi:chip', entity_category: 'diagnostic' }),\n sensor('boiler_now', 'Boiler Temperature', {\n unit_of_measurement: '\\u00b0C',\n device_class: 'temperature',\n state_class: 'measurement'\n }),\n sensor('boiler_target', 'Boiler Target', {\n unit_of_measurement: '\\u00b0C',\n device_class: 'temperature',\n state_class: 'measurement'\n }),\n sensor('hx_now', 'HX Temperature', {\n unit_of_measurement: '\\u00b0C',\n device_class: 'temperature',\n state_class: 'measurement'\n }),\n sensor('last_extraction_duration', 'Last Extraction Duration', {\n unit_of_measurement: 's',\n icon: 'mdi:coffee',\n state_class: 'measurement'\n }),\n sensor('extraction_timer', 'Extraction Timer', {\n unit_of_measurement: 's',\n icon: 'mdi:timer-play',\n state_class: 'measurement',\n force_update: true\n }),\n sensor('time_since_last_shot', 'Time Since Last Shot', {\n unit_of_measurement: 'min',\n icon: 'mdi:clock-outline',\n state_class: 'measurement'\n }),\n binary('heating', 'Heating', { device_class: 'heat' }),\n binary('pump', 'Pump Active', { icon: 'mdi:pump' }),\n binary('water_low', 'Water Level Low', { device_class: 'problem' }),\n sensor('uptime', 'Uptime', {\n unit_of_measurement: 'min',\n icon: 'mdi:timer-outline',\n state_class: 'total_increasing',\n entity_category: 'diagnostic'\n }),\n sensor('wifi_rssi', 'Wi-Fi RSSI', {\n unit_of_measurement: 'dBm',\n device_class: 'signal_strength',\n state_class: 'measurement',\n entity_category: 'diagnostic'\n }),\n sensor('wifi_ssid', 'Wi-Fi SSID', {\n icon: 'mdi:wifi',\n entity_category: 'diagnostic'\n }),\n sensor('ip', 'IP Address', {\n icon: 'mdi:ip-network',\n entity_category: 'diagnostic'\n }),\n sensor('free_heap', 'Free Heap', {\n unit_of_measurement: 'kB',\n icon: 'mdi:memory',\n state_class: 'measurement',\n entity_category: 'diagnostic'\n }),\n sensor('telemetry_age', 'Telemetry Age', {\n unit_of_measurement: 's',\n icon: 'mdi:timer-sand',\n state_class: 'measurement',\n entity_category: 'diagnostic'\n })\n];\n\nreturn [msgs];", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 370, - "y": 80, - "wires": [["mqtt_out"]] - }, - { - "id": "mqtt_in_telemetry", - "type": "mqtt in", - "z": "tab_maratui", - "name": "mara/telemetry", - "topic": "mara/telemetry", - "qos": "0", - "datatype": "json", - "broker": "broker_maratui", - "nl": false, - "rap": true, - "rh": 0, - "inputs": 0, - "x": 160, - "y": 220, - "wires": [["fn_telemetry"]] - }, - { - "id": "fn_telemetry", - "type": "function", - "z": "tab_maratui", - "name": "Parse Telemetry", - "func": "const d = msg.payload;\nif (!d) return null;\n\nconst prev = context.get('prev') || {};\nconst now = Date.now();\n\n// Live extraction timer — count seconds while pump_on\nlet extractionSecs = 0;\nif (d.pump_on) {\n if (!prev.pump_on) {\n context.set('pump_start', now);\n }\n const start = context.get('pump_start') || now;\n extractionSecs = Math.floor((now - start) / 1000);\n} else {\n context.set('pump_start', null);\n}\n\n// Time since last shot in minutes (updated from fn_events via flow context)\nconst lastShotEnd = flow.get('last_shot_end');\nlet timeSinceShotMin = null;\nif (lastShotEnd && !d.pump_on) {\n timeSinceShotMin = Math.round((now - lastShotEnd) / 60000);\n}\n\ncontext.set('prev', d);\n\nfunction s(id, val) {\n if (val === null || val === undefined) return null;\n return {\n topic: `homeassistant/sensor/maratui_${id}/state`,\n payload: String(val),\n qos: 0,\n retain: false\n };\n}\n\nfunction b(id, val) {\n return {\n topic: `homeassistant/binary_sensor/maratui_${id}/state`,\n payload: val ? 'ON' : 'OFF',\n qos: 0,\n retain: false\n };\n}\n\nconst msgs = [\n s('mode', d.mode),\n s('firmware', d.sw),\n s('boiler_now', d.boiler_now_c),\n s('boiler_target', d.boiler_target_c),\n s('hx_now', d.hx_now_c),\n d.pump_on ? s('extraction_timer', extractionSecs) : null,\n timeSinceShotMin !== null ? s('time_since_last_shot', timeSinceShotMin) : null,\n b('heating', d.heating_on),\n b('pump', d.pump_on),\n b('water_low', d.no_water_code !== null && d.no_water_code !== undefined)\n].filter(Boolean);\n\nreturn [msgs];", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 370, - "y": 220, - "wires": [["mqtt_out"]] - }, - { - "id": "mqtt_in_events", - "type": "mqtt in", - "z": "tab_maratui", - "name": "mara/events", - "topic": "mara/events", - "qos": "0", - "datatype": "json", - "broker": "broker_maratui", - "nl": false, - "rap": true, - "rh": 0, - "inputs": 0, - "x": 160, - "y": 360, - "wires": [["fn_events"]] - }, - { - "id": "fn_events", - "type": "function", - "z": "tab_maratui", - "name": "Handle Events", - "func": "const e = msg.payload;\nif (!e) return null;\n\nconst msgs = [];\n\nif (e.type === 'shot_ended') {\n flow.set('last_shot_end', Date.now());\n\n if (e.duration !== undefined) {\n msgs.push({\n topic: 'homeassistant/sensor/maratui_last_extraction_duration/state',\n payload: String(e.duration),\n qos: 1,\n retain: true\n });\n msgs.push({\n topic: 'homeassistant/sensor/maratui_extraction_timer/state',\n payload: String(e.duration),\n qos: 1,\n retain: true\n });\n }\n msgs.push({\n topic: 'homeassistant/sensor/maratui_time_since_last_shot/state',\n payload: '0',\n qos: 0,\n retain: false\n });\n}\n\nif (e.type === 'shot_started') {\n msgs.push({\n topic: 'homeassistant/sensor/maratui_extraction_timer/state',\n payload: '0',\n qos: 0,\n retain: false\n });\n}\n\nif (e.type === 'water_refill_needed') {\n msgs.push({\n topic: 'homeassistant/binary_sensor/maratui_water_low/state',\n payload: 'ON',\n qos: 1,\n retain: false\n });\n}\n\nif (e.type === 'water_refill_cleared') {\n msgs.push({\n topic: 'homeassistant/binary_sensor/maratui_water_low/state',\n payload: 'OFF',\n qos: 1,\n retain: false\n });\n}\n\nif (msgs.length === 0) return null;\nreturn [msgs];", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 370, - "y": 360, - "wires": [["mqtt_out"]] - }, - { - "id": "mqtt_in_status", - "type": "mqtt in", - "z": "tab_maratui", - "name": "mara/status", - "topic": "mara/status", - "qos": "0", - "datatype": "json", - "broker": "broker_maratui", - "nl": false, - "rap": true, - "rh": 0, - "inputs": 0, - "x": 160, - "y": 500, - "wires": [["fn_status"]] - }, - { - "id": "fn_status", - "type": "function", - "z": "tab_maratui", - "name": "Parse Status", - "func": "const d = msg.payload;\nif (!d) return null;\n\nfunction s(id, val) {\n if (val === null || val === undefined) return null;\n return {\n topic: `homeassistant/sensor/maratui_${id}/state`,\n payload: String(val),\n qos: 0,\n retain: false\n };\n}\n\nconst msgs = [\n s('uptime', Math.round(d.uptime_s / 60)),\n s('wifi_rssi', d.wifi_rssi),\n s('wifi_ssid', d.wifi_ssid),\n s('ip', d.ip),\n d.free_heap_b != null ? s('free_heap', Math.round(d.free_heap_b / 1024)) : null,\n d.last_telemetry_age_s != null ? s('telemetry_age', d.last_telemetry_age_s) : null\n].filter(Boolean);\n\nreturn [msgs];", - "outputs": 1, - "noerr": 0, - "initialize": "", - "finalize": "", - "libs": [], - "x": 370, - "y": 500, - "wires": [["mqtt_out"]] - }, - { - "id": "mqtt_out", - "type": "mqtt out", - "z": "tab_maratui", - "name": "→ Home Assistant", - "topic": "", - "qos": "", - "retain": "", - "respTopic": "", - "contentType": "", - "userProps": "", - "correl": "", - "expiry": "", - "broker": "broker_maratui", - "x": 590, - "y": 300, - "wires": [] - } -] diff --git a/src/app.rs b/src/app.rs index 0b37865..f46e026 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,7 @@ use crate::button::Button; use crate::run_app; use crate::screens::{Board, Connecting, Dashboard, Debug, Graphs, Screen}; +use crate::state::global_state::MqttOutboundMessage; use crate::state::{AppEvent, AppStateMachine, ConnectionStatus, GlobalAppState}; use crate::telemetry::TelemetryFrame; use mousefood::embedded_graphics::Drawable; @@ -32,8 +33,15 @@ pub trait MaraUiApp { true } - /// Drain app-generated MQTT outbound messages (`topic_suffix`, `payload`) - fn take_outbound_mqtt_messages(&mut self) -> Vec<(String, String)>; + /// Drain app-generated MQTT outbound messages + fn take_outbound_mqtt_messages(&mut self) -> Vec; + + /// Mirror the MQTT topic prefix into app state (call right after AppConfig::from_env) + fn set_mqtt_prefix(&mut self, prefix: &str); + + /// Enqueue HA MQTT Discovery configs (called on MQTT connect when home-assistant is enabled) + #[cfg(feature = "home-assistant")] + fn enqueue_home_assistant(&mut self, topic_prefix: &str); /// Read current network status from app state fn connection_statuses(&self) -> (ConnectionStatus, ConnectionStatus); @@ -108,12 +116,17 @@ impl MaraUiApp for MaraUi { AppStateMachine::handle_event(&mut self.state, event); } - fn take_outbound_mqtt_messages(&mut self) -> Vec<(String, String)> { - self.state - .take_outbound_mqtt_messages() - .into_iter() - .map(|msg| (msg.topic_suffix, msg.payload)) - .collect() + fn take_outbound_mqtt_messages(&mut self) -> Vec { + self.state.take_outbound_mqtt_messages() + } + + fn set_mqtt_prefix(&mut self, prefix: &str) { + self.state.mqtt_topic_prefix = prefix.to_string(); + } + + #[cfg(feature = "home-assistant")] + fn enqueue_home_assistant(&mut self, topic_prefix: &str) { + crate::home_assistant::enqueue_discovery_configs(&mut self.state, topic_prefix); } fn backlight_active(&self) -> bool { diff --git a/src/home_assistant.rs b/src/home_assistant.rs new file mode 100644 index 0000000..e537393 --- /dev/null +++ b/src/home_assistant.rs @@ -0,0 +1,256 @@ +use crate::state::global_state::{DeviceInfo, GlobalAppState}; +use crate::telemetry::AppEvent as TelemetryEvent; + +const DEVICE: &str = r#"{"identifiers":["maratui_esp32"],"name":"Lelit Mara","model":"MaraTUI","manufacturer":"Lelit"}"#; + +fn s_topic(id: &str) -> String { + format!("homeassistant/sensor/maratui/{id}") +} + +fn b_topic(id: &str) -> String { + format!("homeassistant/binary_sensor/maratui/{id}") +} + +fn sensor_config(id: &str, name: &str, extra: &str) -> (String, String) { + let extra_part = if extra.is_empty() { + String::new() + } else { + format!(",{extra}") + }; + ( + format!("homeassistant/sensor/maratui/{id}/config"), + format!( + r#"{{"name":"{name}","unique_id":"maratui_{id}","state_topic":"homeassistant/sensor/maratui/{id}","device":{DEVICE}{extra_part}}}"# + ), + ) +} + +fn binary_config(id: &str, name: &str, extra: &str) -> (String, String) { + let extra_part = if extra.is_empty() { + String::new() + } else { + format!(",{extra}") + }; + ( + format!("homeassistant/binary_sensor/maratui/{id}/config"), + format!( + r#"{{"name":"{name}","unique_id":"maratui_{id}","state_topic":"homeassistant/binary_sensor/maratui/{id}","payload_on":"ON","payload_off":"OFF","device":{DEVICE}{extra_part}}}"# + ), + ) +} + +/// Publish retained HA MQTT Discovery config payloads for all entities. +/// Called once on MQTT connect from setup.rs / setup_simulator.rs. +pub fn enqueue_discovery_configs(state: &mut GlobalAppState, topic_prefix: &str) { + let sensors: &[(&str, &str, &str)] = &[ + ("mode", "Mode", r#""icon":"mdi:coffee-maker""#), + ( + "firmware", + "Firmware Version", + r#""icon":"mdi:chip","entity_category":"diagnostic""#, + ), + ( + "boiler_now", + "Boiler Temperature", + r#""unit_of_measurement":"°C","device_class":"temperature","state_class":"measurement""#, + ), + ( + "boiler_target", + "Boiler Target", + r#""unit_of_measurement":"°C","device_class":"temperature","state_class":"measurement""#, + ), + ( + "hx_now", + "HX Temperature", + r#""unit_of_measurement":"°C","device_class":"temperature","state_class":"measurement""#, + ), + ( + "last_extraction_duration", + "Last Extraction Duration", + r#""unit_of_measurement":"s","icon":"mdi:coffee","state_class":"measurement""#, + ), + ( + "extraction_timer", + "Extraction Timer", + r#""unit_of_measurement":"s","icon":"mdi:timer-play","state_class":"measurement","force_update":true"#, + ), + ( + "time_since_last_shot", + "Time Since Last Shot", + r#""unit_of_measurement":"min","icon":"mdi:clock-outline","state_class":"measurement""#, + ), + ( + "uptime", + "Uptime", + r#""unit_of_measurement":"min","icon":"mdi:timer-outline","state_class":"total_increasing","entity_category":"diagnostic""#, + ), + ( + "wifi_rssi", + "Wi-Fi RSSI", + r#""unit_of_measurement":"dBm","device_class":"signal_strength","state_class":"measurement","entity_category":"diagnostic""#, + ), + ( + "wifi_ssid", + "Wi-Fi SSID", + r#""icon":"mdi:wifi","entity_category":"diagnostic""#, + ), + ( + "ip", + "IP Address", + r#""icon":"mdi:ip-network","entity_category":"diagnostic""#, + ), + ( + "free_heap", + "Free Heap", + r#""unit_of_measurement":"kB","icon":"mdi:memory","state_class":"measurement","entity_category":"diagnostic""#, + ), + ( + "telemetry_age", + "Telemetry Age", + r#""unit_of_measurement":"s","icon":"mdi:timer-sand","state_class":"measurement","entity_category":"diagnostic""#, + ), + ]; + + for (id, name, extra) in sensors { + let (topic, payload) = sensor_config(id, name, extra); + state.enqueue_absolute_mqtt_message(topic, payload, true); + } + + // Cup counter: state_topic is the MQTT prefix topic that HA automation publishes to. + { + let cup_state_topic = format!("{topic_prefix}/cup_counter"); + let (config_topic, _) = sensor_config("cup_counter", "Cup Counter", ""); + let payload = format!( + r#"{{"name":"Cup Counter","unique_id":"maratui_cup_counter","state_topic":"{cup_state_topic}","icon":"mdi:coffee","state_class":"total_increasing","device":{DEVICE}}}"# + ); + state.enqueue_absolute_mqtt_message(config_topic, payload, true); + } + + let binary_sensors: &[(&str, &str, &str)] = &[ + ("heating", "Heating", r#""device_class":"heat""#), + ("pump", "Pump Active", r#""icon":"mdi:pump""#), + ( + "water_low", + "Water Level Low", + r#""device_class":"problem""#, + ), + ]; + + for (id, name, extra) in binary_sensors { + let (topic, payload) = binary_config(id, name, extra); + state.enqueue_absolute_mqtt_message(topic, payload, true); + } +} + +/// Publish current telemetry values as HA sensor states (~1 Hz). +/// Called at the end of fsm::handle_telemetry_frame, after events are processed. +pub fn enqueue_telemetry_states(state: &mut GlobalAppState) { + let (mode_str, sw, boiler_now, boiler_target, hx_now, heating_on, pump_on, water_low) = { + let Some(frame) = state.machine_state.last_frame.as_ref() else { + return; + }; + ( + frame.mode.to_string(), + frame.sw_version.clone(), + frame.boiler_now_c, + frame.boiler_target_c, + frame.hx_now_c, + frame.heating_on, + frame.pump_on, + frame.no_water_code.is_some(), + ) + }; + + let timer_secs = state + .extraction_state + .elapsed() + .map(|d| d.as_secs()) + .unwrap_or(0); + let last_shot_ended_at = state.last_shot_ended_at; + let is_extracting = state.extraction_state.is_extracting(); + + state.enqueue_absolute_mqtt_message(s_topic("mode"), mode_str, false); + state.enqueue_absolute_mqtt_message(s_topic("firmware"), sw, false); + state.enqueue_absolute_mqtt_message(s_topic("boiler_now"), boiler_now.to_string(), false); + if let Some(t) = boiler_target { + state.enqueue_absolute_mqtt_message(s_topic("boiler_target"), t.to_string(), false); + } + state.enqueue_absolute_mqtt_message(s_topic("hx_now"), hx_now.to_string(), false); + state.enqueue_absolute_mqtt_message(s_topic("extraction_timer"), timer_secs.to_string(), false); + + if let Some(ended_at) = last_shot_ended_at { + if !is_extracting { + let mins = ended_at.elapsed().as_secs() / 60; + state.enqueue_absolute_mqtt_message( + s_topic("time_since_last_shot"), + mins.to_string(), + false, + ); + } + } + + state.enqueue_absolute_mqtt_message( + b_topic("heating"), + if heating_on { "ON" } else { "OFF" }, + false, + ); + state.enqueue_absolute_mqtt_message(b_topic("pump"), if pump_on { "ON" } else { "OFF" }, false); + state.enqueue_absolute_mqtt_message( + b_topic("water_low"), + if water_low { "ON" } else { "OFF" }, + false, + ); +} + +/// Publish HA sensor/binary state updates derived from telemetry events. +/// Called in the events loop of fsm::handle_telemetry_frame, before the event moves. +pub fn enqueue_event_states(state: &mut GlobalAppState, event: &TelemetryEvent) { + match event { + TelemetryEvent::ShotEnded { duration } => { + state.enqueue_absolute_mqtt_message( + s_topic("last_extraction_duration"), + duration.to_string(), + true, + ); + state.enqueue_absolute_mqtt_message( + s_topic("extraction_timer"), + duration.to_string(), + true, + ); + state.enqueue_absolute_mqtt_message(s_topic("time_since_last_shot"), "0", false); + } + TelemetryEvent::ShotStarted => { + state.enqueue_absolute_mqtt_message(s_topic("extraction_timer"), "0", false); + } + TelemetryEvent::WaterRefillNeeded { .. } => { + state.enqueue_absolute_mqtt_message(b_topic("water_low"), "ON", false); + } + TelemetryEvent::WaterRefillCleared => { + state.enqueue_absolute_mqtt_message(b_topic("water_low"), "OFF", false); + } + TelemetryEvent::ShotAborted { .. } | TelemetryEvent::ModeChanged { .. } => {} + } +} + +/// Publish HA sensor states from the device status payload. +/// Called alongside the existing `device_status_payload` enqueue in fsm::handle_event. +pub fn enqueue_status_states(state: &mut GlobalAppState, info: &DeviceInfo) { + state.enqueue_absolute_mqtt_message(s_topic("uptime"), (info.uptime_s / 60).to_string(), false); + if let Some(rssi) = info.wifi_rssi { + state.enqueue_absolute_mqtt_message(s_topic("wifi_rssi"), rssi.to_string(), false); + } + state.enqueue_absolute_mqtt_message(s_topic("wifi_ssid"), info.wifi_ssid.clone(), false); + if let Some(ip) = &info.ip { + state.enqueue_absolute_mqtt_message(s_topic("ip"), ip.clone(), false); + } + if let Some(heap_b) = info.free_heap_b { + state.enqueue_absolute_mqtt_message( + s_topic("free_heap"), + (heap_b / 1024).to_string(), + false, + ); + } + if let Some(age) = info.last_telemetry_age_s { + state.enqueue_absolute_mqtt_message(s_topic("telemetry_age"), age.to_string(), false); + } +} diff --git a/src/lib.rs b/src/lib.rs index cb416eb..c2e4c85 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ /// Application setup. pub mod app; pub mod config; +#[cfg(feature = "home-assistant")] +pub mod home_assistant; #[cfg(not(feature = "simulator"))] mod setup; #[cfg(not(feature = "simulator"))] diff --git a/src/setup.rs b/src/setup.rs index 3219b6e..e2a952e 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -2,6 +2,7 @@ use crate::app::MaraUiApp; use crate::button::{Button, ButtonState}; use crate::config::AppConfig; use crate::screens::Screen; +use crate::state::global_state::MqttOutboundMessage; use crate::state::{AppEvent, ConnectionStatus, DeviceInfo}; use crate::telemetry::TelemetryFrame; use mousefood::embedded_graphics::prelude::{DrawTarget, RgbColor}; @@ -82,6 +83,7 @@ pub fn run_app(app: impl MaraUiApp) { fn run_app_hardware(mut app: impl MaraUiApp) { let app_config = AppConfig::from_env().expect("Invalid MARATUI_* configuration"); + app.set_mqtt_prefix(&app_config.mqtt.topic_prefix); let peripherals = Peripherals::take().unwrap(); let modem = peripherals.modem; let spi_p = peripherals.spi2; @@ -230,6 +232,8 @@ fn run_app_hardware(mut app: impl MaraUiApp) { warn!("Failed to re-subscribe to {}: {:?}", topic, e); } } + #[cfg(feature = "home-assistant")] + app.enqueue_home_assistant(&app_config.mqtt.topic_prefix); } app.handle_event(AppEvent::MqttStatusChanged(status)); } @@ -294,8 +298,8 @@ fn run_app_hardware(mut app: impl MaraUiApp) { } } - for (topic_suffix, payload) in app.take_outbound_mqtt_messages() { - publish_mqtt_message(&mut mqtt_client, &app_config, &topic_suffix, &payload); + for msg in app.take_outbound_mqtt_messages() { + publish_mqtt_message(&mut mqtt_client, &app_config, &msg); } if app.backlight_active() { @@ -492,15 +496,23 @@ fn init_mqtt( fn publish_mqtt_message( client: &mut Option>, cfg: &AppConfig, - topic_suffix: &str, - payload: &str, + msg: &MqttOutboundMessage, ) { let Some(client) = client.as_mut() else { return; }; - let topic = format!("{}/{}", cfg.mqtt.topic_prefix, topic_suffix); - if let Err(e) = client.publish(&topic, QoS::AtMostOnce, false, payload.as_bytes()) { + let topic = if msg.absolute { + msg.topic_suffix.clone() + } else { + format!("{}/{}", cfg.mqtt.topic_prefix, msg.topic_suffix) + }; + let qos = if msg.retain { + QoS::AtLeastOnce + } else { + QoS::AtMostOnce + }; + if let Err(e) = client.publish(&topic, qos, msg.retain, msg.payload.as_bytes()) { warn!("Failed to publish MQTT message: {:?}", e); } } diff --git a/src/setup_simulator.rs b/src/setup_simulator.rs index c580e36..14620b4 100644 --- a/src/setup_simulator.rs +++ b/src/setup_simulator.rs @@ -1,6 +1,7 @@ use crate::app::MaraUiApp; use crate::button::{Button, ButtonPressType}; use crate::config::AppConfig; +use crate::state::global_state::MqttOutboundMessage; use crate::state::{AppEvent, ConnectionStatus, DeviceInfo}; use crate::telemetry::TelemetryFrame; use mousefood::embedded_graphics::prelude::Size; @@ -24,6 +25,7 @@ pub fn run_app(app: impl MaraUiApp) { fn run_app_simulator(mut app: impl MaraUiApp) { let app_config = AppConfig::from_env().expect("Invalid MARATUI_* configuration"); + app.set_mqtt_prefix(&app_config.mqtt.topic_prefix); app.handle_event(AppEvent::WifiStatusChanged(ConnectionStatus::Disabled)); app.handle_event(AppEvent::MqttStatusChanged(ConnectionStatus::Connecting)); @@ -150,6 +152,10 @@ fn run_app_simulator(mut app: impl MaraUiApp) { if let Some(status_rx) = mqtt_status_rx.as_mut() { while let Ok(status) = status_rx.try_recv() { + if status == ConnectionStatus::Connected { + #[cfg(feature = "home-assistant")] + app.enqueue_home_assistant(&app_config.mqtt.topic_prefix); + } app.handle_event(AppEvent::MqttStatusChanged(status)); } } @@ -180,8 +186,8 @@ fn run_app_simulator(mut app: impl MaraUiApp) { let (_, mqtt_status) = app.connection_statuses(); let messages = app.take_outbound_mqtt_messages(); if mqtt_status == ConnectionStatus::Connected { - for (topic_suffix, payload) in messages { - publish_mqtt_message(&mut mqtt, &app_config, &topic_suffix, &payload); + for msg in messages { + publish_mqtt_message(&mut mqtt, &app_config, &msg); } } @@ -260,16 +266,20 @@ fn parse_mqtt_host_port(url: &str) -> Option<(String, u16)> { Some((host_port.to_string(), 1883)) } -fn publish_mqtt_message( - client: &mut Option, - cfg: &AppConfig, - topic_suffix: &str, - payload: &str, -) { +fn publish_mqtt_message(client: &mut Option, cfg: &AppConfig, msg: &MqttOutboundMessage) { let Some(client) = client.as_mut() else { return; }; - let topic = format!("{}/{}", cfg.mqtt.topic_prefix, topic_suffix); - let _ = client.publish(topic, QoS::AtMostOnce, false, payload); + let topic = if msg.absolute { + msg.topic_suffix.clone() + } else { + format!("{}/{}", cfg.mqtt.topic_prefix, msg.topic_suffix) + }; + let qos = if msg.retain { + QoS::AtLeastOnce + } else { + QoS::AtMostOnce + }; + let _ = client.publish(topic, qos, msg.retain, msg.payload.as_bytes()); } diff --git a/src/state/fsm.rs b/src/state/fsm.rs index 6afce28..1a7c871 100644 --- a/src/state/fsm.rs +++ b/src/state/fsm.rs @@ -2,6 +2,8 @@ use log::{error, info}; use super::{AppError, AppEvent, ConnectionStatus, DeviceInfo, ExtractionState, GlobalAppState}; use crate::button::{Button, ButtonPressType}; +#[cfg(feature = "home-assistant")] +use crate::home_assistant; use crate::screens::Screen; use crate::telemetry::{TelemetryFrame, update_state_with_events}; use std::time::{Duration, Instant}; @@ -34,12 +36,14 @@ impl AppStateMachine { state.extraction_state = ExtractionState::Idle { last_extraction_duration: Some(Duration::from_secs(duration)), }; + state.last_shot_ended_at = Some(Instant::now()); } AppEvent::ShotAborted { .. } => { state.extraction_state = ExtractionState::Idle { last_extraction_duration: None, }; + state.last_shot_ended_at = Some(Instant::now()); } AppEvent::ModeChanged { from: _, to: _ } => { @@ -127,6 +131,8 @@ impl AppStateMachine { AppEvent::DeviceInfoUpdated(info) => { let payload = device_status_payload(&info); + #[cfg(feature = "home-assistant")] + home_assistant::enqueue_status_states(state, &info); state.device_info = info; state.enqueue_mqtt_message("status", payload); } @@ -204,9 +210,14 @@ impl AppStateMachine { // Process each event through the FSM for event in events { let event_payload = telemetry_event_payload(&event); + #[cfg(feature = "home-assistant")] + home_assistant::enqueue_event_states(state, &event); Self::handle_event(state, AppEvent::from_telemetry(event)); state.enqueue_mqtt_message("events", event_payload); } + + #[cfg(feature = "home-assistant")] + home_assistant::enqueue_telemetry_states(state); } /// Handle multiple events in sequence diff --git a/src/state/global_state.rs b/src/state/global_state.rs index 403872a..4988d1b 100644 --- a/src/state/global_state.rs +++ b/src/state/global_state.rs @@ -21,8 +21,13 @@ pub enum ConnectionStatus { #[derive(Clone, Debug)] pub struct MqttOutboundMessage { + /// Topic suffix (combined with prefix by publish_mqtt_message) unless `absolute` is true. pub topic_suffix: String, pub payload: String, + /// When `true`, the broker should store a retained copy of this message. + pub retain: bool, + /// When `true`, `topic_suffix` is used as-is (bypasses prefix construction). + pub absolute: bool, } /// State of the coffee extraction process @@ -127,6 +132,10 @@ pub struct GlobalAppState { pub last_activity_at: Option, /// Last time a UART telemetry frame was received (for Debug screen activity marker) pub last_uart_frame_at: Option, + /// Time when the last shot ended (pump turned off after a valid extraction) + pub last_shot_ended_at: Option, + /// MQTT topic prefix (e.g. "mara"), mirrored from AppConfig for use in HA state topics + pub mqtt_topic_prefix: String, /// Board metadata (WiFi RSSI, IP, uptime, free heap) pub device_info: DeviceInfo, /// Current boot loading stage; `None` before first stage fires, frozen at 100% while waiting for machine @@ -149,6 +158,8 @@ impl Default for GlobalAppState { backlight_on: true, last_activity_at: None, last_uart_frame_at: None, + last_shot_ended_at: None, + mqtt_topic_prefix: "mara".to_string(), device_info: DeviceInfo::default(), loading_status: None, } @@ -187,8 +198,27 @@ impl GlobalAppState { self.outbound_mqtt.push_back(MqttOutboundMessage { topic_suffix: topic_suffix.into(), payload: payload.into(), + retain: false, + absolute: false, }); + while self.outbound_mqtt.len() > 64 { + self.outbound_mqtt.pop_front(); + } + } + /// Enqueue a message with an absolute topic (not prefixed) and configurable retain flag. + pub fn enqueue_absolute_mqtt_message( + &mut self, + topic: impl Into, + payload: impl Into, + retain: bool, + ) { + self.outbound_mqtt.push_back(MqttOutboundMessage { + topic_suffix: topic.into(), + payload: payload.into(), + retain, + absolute: true, + }); while self.outbound_mqtt.len() > 64 { self.outbound_mqtt.pop_front(); }