From eae6d3af5a1d33d9330022c9531106c6ad7dc50a Mon Sep 17 00:00:00 2001 From: Ronny Eia <3652665+eiaro@users.noreply.github.com> Date: Sun, 12 Jul 2026 09:17:06 +0200 Subject: [PATCH] Add Lua runtime: chip temp -> LED color -> HA, verified live Sandboxed Lua state (espressif/lua v5.5.0, base/table/string/math only, no io/os/package/debug) exposes a kvida API table (chip_temperature, set_led, publish) to a compiled-in default script that reads chip temperature, maps it to an LED color, and publishes the reading to Home Assistant. Generalizes transport's publish_chip_temperature() into publish_value(name, value), the first real use of AGENTS.md's semantic publish API. Requires -DLUA_32BITS globally since this toolchain's long long support isn't visible to Lua's default build. Verified on real ESP32-C6 hardware: no crashes/errors across multiple serial monitor windows, LED changes color, chip_temperature sensor appears in Home Assistant via MQTT Discovery. Co-Authored-By: Claude Sonnet 5 --- CMakeLists.txt | 26 +++- components/lua_runtime/CMakeLists.txt | 6 +- components/lua_runtime/README.md | 24 +++- components/lua_runtime/idf_component.yml | 2 + components/lua_runtime/include/lua_runtime.h | 17 +++ components/lua_runtime/src/default_script.h | 28 ++++ components/lua_runtime/src/lua_runtime.cpp | 130 +++++++++++++++++++ components/transport/README.md | 84 ++++++------ components/transport/include/mqtt.h | 19 +-- components/transport/src/mqtt.cpp | 70 +++++----- main/CMakeLists.txt | 10 +- main/main.cpp | 34 ++--- 12 files changed, 328 insertions(+), 122 deletions(-) create mode 100644 components/lua_runtime/idf_component.yml create mode 100644 components/lua_runtime/include/lua_runtime.h create mode 100644 components/lua_runtime/src/default_script.h create mode 100644 components/lua_runtime/src/lua_runtime.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 59fa3e0..de6579e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,19 @@ -cmake_minimum_required(VERSION 3.16) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) - -include($ENV{IDF_PATH}/tools/cmake/project.cmake) -project(kvida_os) +cmake_minimum_required(VERSION 3.16) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# The espressif/lua component's luaconf.h defaults to 64-bit +# (long long) Lua integers and errors out at compile time on this +# toolchain ("Compiler does not support 'long long'" -- LLONG_MAX isn't +# visible to it here, even though is included). Lua's own +# suggested fix for constrained/32-bit platforms: build with 32-bit +# integers/floats instead. Set globally (not just in lua_runtime's own +# CMakeLists.txt) so the Lua component's own .c files see the same +# definition -- otherwise its library and our calling code would +# disagree on the size of lua_Integer. +idf_build_set_property(COMPILE_DEFINITIONS "-DLUA_32BITS" APPEND) + +project(kvida_os) diff --git a/components/lua_runtime/CMakeLists.txt b/components/lua_runtime/CMakeLists.txt index 240f6af..cee9ed9 100644 --- a/components/lua_runtime/CMakeLists.txt +++ b/components/lua_runtime/CMakeLists.txt @@ -1,3 +1,7 @@ idf_component_register( - REQUIRES profiles transport + SRCS "src/lua_runtime.cpp" + INCLUDE_DIRS "include" + PRIV_INCLUDE_DIRS "src" + REQUIRES drivers profiles transport + PRIV_REQUIRES espressif__lua ) diff --git a/components/lua_runtime/README.md b/components/lua_runtime/README.md index c737246..a0080a9 100644 --- a/components/lua_runtime/README.md +++ b/components/lua_runtime/README.md @@ -1,5 +1,25 @@ # lua_runtime -Executes the sandboxed Lua program (compiled from Blockly) that defines device behaviour. Exposes the Kvida API (e.g. `publish("temperature", 21.3)`) to Lua scripts; Lua never touches ESP-IDF or `transport` directly. `REQUIRES profiles transport`. +Executes the sandboxed Lua program (compiled from Blockly) that defines device behaviour. Exposes the Kvida API (e.g. `publish("temperature", 21.3)`) to Lua scripts; Lua never touches ESP-IDF or `transport` directly. `REQUIRES drivers profiles transport`. -TODO: needs an actual Lua interpreter dependency, pinned via `idf_component.yml` once a specific IDF Component Registry package/version is confirmed (not guessed here, same caution as pinning `west.yml`'s NCS revision previously). +Built on `espressif/lua` (v5.5.0, pinned in `idf_component.yml`) -- Espressif's own official IDF Component Registry package, MIT licensed, built specifically for embedding Lua in ESP-IDF apps. No JS/exotic bindings: standard `lua_State`/`luaL_newstate`/`lua_pushcfunction` C API. The project's fourth external managed component (after `mdns`, `mqtt`, `led_strip`). + +## What's here + +- `lua_runtime_init()` creates the Lua state, opens only `base`/`table`/`string`/`math` (deliberately *not* `luaL_openlibs()`, which would also expose `io`, `os`, `package`/`require`, `debug` -- see AGENTS.md's "Lua should never access ESP-IDF directly"), registers the `kvida` API table, then loads the default script (`src/default_script.h`, a compiled-in string) which just *defines* `on_tick()`. +- `lua_runtime_tick()` calls `on_tick()` -- one "wake, execute Lua, publish changes, sleep" cycle. A Lua runtime error is logged, not fatal. +- The `kvida` table exposed to scripts: + - `kvida.chip_temperature()` -- reads the ESP32-C6's internal die temperature via `profiles::ChipTemperatureSensor` (the same `Sensor` implementation `main.cpp` used directly before this component existed). + - `kvida.set_led(r, g, b)` -- sets the onboard WS2812 via `drivers::rgb_led_set_color()`. + - `kvida.publish(name, value)` -- calls `transport::publish_value()`, AGENTS.md's semantic publish API, built for the first time here. +- The default script reads chip temperature, maps it to a blue(cool)-to-red(hot) LED color (linear interpolation, 20-60C, clamped), and publishes the reading to Home Assistant. Verified end-to-end on real hardware. + +## Known limitation, accepted for now + +The onboard LED is also controllable manually from Home Assistant (an MQTT light entity, see `components/transport/src/mqtt.cpp`). The two aren't reconciled: `on_tick()` overwrites a manually-chosen HA color every 30s. Accepted as a conflict between two "quick proof" demos rather than solved now -- a real answer (e.g. a Lua-settable "mode" toggle, or the MQTT light entity deferring to Lua) needs product input, not a guess. + +## Not yet done + +- The script is compiled in, not loaded from storage -- there's no upload mechanism yet. Once `kvida-sdk` can push a script (and `drivers` mounts the `storage` LittleFS partition -- both still TODO), scripts should be loaded from there instead. +- No Blockly-to-Lua compiler exists yet (that's `kvida-sdk`'s job per AGENTS.md's "Level 2"). +- No memory/CPU/runtime limits on the Lua state beyond the restricted library set -- a script with an infinite loop would hang `lua_runtime_tick()` (and, since it currently runs on the same timer callback, block other `esp_timer` callbacks too). Not a concern for a compiled-in, developer-authored script; becomes one once arbitrary user scripts can be uploaded. diff --git a/components/lua_runtime/idf_component.yml b/components/lua_runtime/idf_component.yml new file mode 100644 index 0000000..8842cf1 --- /dev/null +++ b/components/lua_runtime/idf_component.yml @@ -0,0 +1,2 @@ +dependencies: + espressif/lua: "==5.5.0" diff --git a/components/lua_runtime/include/lua_runtime.h b/components/lua_runtime/include/lua_runtime.h new file mode 100644 index 0000000..04feaed --- /dev/null +++ b/components/lua_runtime/include/lua_runtime.h @@ -0,0 +1,17 @@ +#pragma once + +namespace kvida { + +// Creates the sandboxed Lua state and loads the default script (see +// src/default_script.h). The script only *defines* the on_tick() +// function -- call lua_runtime_tick() to actually run it. Call once at +// boot. +void lua_runtime_init(); + +// Calls the script's on_tick() global function -- one "wake, execute +// Lua, publish changes, sleep" cycle, per AGENTS.md's power philosophy. +// A Lua runtime error is logged, not fatal: a script bug shouldn't take +// down the device. +void lua_runtime_tick(); + +} // namespace kvida diff --git a/components/lua_runtime/src/default_script.h b/components/lua_runtime/src/default_script.h new file mode 100644 index 0000000..7367d9a --- /dev/null +++ b/components/lua_runtime/src/default_script.h @@ -0,0 +1,28 @@ +#pragma once + +namespace kvida { + +// Default on-device script, compiled in for now -- there's no upload +// mechanism yet (that's a kvida-sdk + LittleFS concern for later), so a +// real filesystem-backed script store would be premature. Demonstrates +// the whole point of lua_runtime: read a sensor, drive an actuator, and +// publish a value to Home Assistant, entirely from Lua, with no +// firmware rebuild for behavior changes. +inline const char *default_script() +{ + return R"lua( +function on_tick() + local temp = kvida.chip_temperature() + + local min_temp, max_temp = 20, 60 + local t = (temp - min_temp) / (max_temp - min_temp) + if t < 0 then t = 0 end + if t > 1 then t = 1 end + + kvida.set_led(math.floor(t * 255), 0, math.floor((1 - t) * 255)) + kvida.publish("chip_temperature", temp) +end +)lua"; +} + +} // namespace kvida diff --git a/components/lua_runtime/src/lua_runtime.cpp b/components/lua_runtime/src/lua_runtime.cpp new file mode 100644 index 0000000..443913f --- /dev/null +++ b/components/lua_runtime/src/lua_runtime.cpp @@ -0,0 +1,130 @@ +#include "lua_runtime.h" + +#include "default_script.h" + +#include "chip_temperature_sensor.h" +#include "mqtt.h" +#include "rgb_led.h" + +#include "esp_log.h" + +extern "C" { +#include "lauxlib.h" +#include "lua.h" +#include "lualib.h" +} + +namespace kvida { + +namespace { + +constexpr const char *TAG = "lua_runtime"; + +lua_State *g_L = nullptr; +ChipTemperatureSensor g_chip_temp_sensor; + +// Deliberately not luaL_openlibs(), which would also expose `io`, `os`, +// `package`/`require`, and `debug` -- AGENTS.md: "Lua should never +// access ESP-IDF directly. Lua interacts only through the Kvida API." +void open_safe_libs(lua_State *L) +{ + luaL_requiref(L, LUA_GNAME, luaopen_base, 1); + lua_pop(L, 1); + luaL_requiref(L, LUA_TABLIBNAME, luaopen_table, 1); + lua_pop(L, 1); + luaL_requiref(L, LUA_STRLIBNAME, luaopen_string, 1); + lua_pop(L, 1); + luaL_requiref(L, LUA_MATHLIBNAME, luaopen_math, 1); + lua_pop(L, 1); +} + +int l_chip_temperature(lua_State *L) +{ + if (!g_chip_temp_sensor.fetch()) { + return luaL_error(L, "failed to read chip temperature"); + } + SensorValue value{}; + if (!g_chip_temp_sensor.get(SensorChannel::ChipTemperature, value)) { + return luaL_error(L, "chip temperature channel unavailable"); + } + double celsius = static_cast(value.val1) + static_cast(value.val2) * 1e-6; + lua_pushnumber(L, celsius); + return 1; +} + +int l_set_led(lua_State *L) +{ + auto r = static_cast(luaL_checkinteger(L, 1)); + auto g = static_cast(luaL_checkinteger(L, 2)); + auto b = static_cast(luaL_checkinteger(L, 3)); + drivers::rgb_led_set_color(r, g, b); + return 0; +} + +int l_publish(lua_State *L) +{ + const char *name = luaL_checkstring(L, 1); + double value = luaL_checknumber(L, 2); + publish_value(name, value); + return 0; +} + +void register_kvida_api(lua_State *L) +{ + lua_newtable(L); + + lua_pushcfunction(L, l_chip_temperature); + lua_setfield(L, -2, "chip_temperature"); + + lua_pushcfunction(L, l_set_led); + lua_setfield(L, -2, "set_led"); + + lua_pushcfunction(L, l_publish); + lua_setfield(L, -2, "publish"); + + lua_setglobal(L, "kvida"); +} + +} // namespace + +void lua_runtime_init() +{ + if (g_L) { + return; + } + + g_L = luaL_newstate(); + if (!g_L) { + ESP_LOGE(TAG, "luaL_newstate failed"); + return; + } + + open_safe_libs(g_L); + g_chip_temp_sensor.begin(); + register_kvida_api(g_L); + + if (luaL_dostring(g_L, default_script()) != LUA_OK) { + ESP_LOGE(TAG, "Failed to load default script: %s", lua_tostring(g_L, -1)); + lua_pop(g_L, 1); + } +} + +void lua_runtime_tick() +{ + if (!g_L) { + return; + } + + lua_getglobal(g_L, "on_tick"); + if (!lua_isfunction(g_L, -1)) { + lua_pop(g_L, 1); + return; + } + + if (lua_pcall(g_L, 0, 0, 0) != LUA_OK) { + ESP_LOGE(TAG, "Lua error in on_tick: %s", lua_tostring(g_L, -1)); + lua_pop(g_L, 1); + } +} + +} // namespace kvida diff --git a/components/transport/README.md b/components/transport/README.md index e86512c..7c4ee02 100644 --- a/components/transport/README.md +++ b/components/transport/README.md @@ -1,42 +1,42 @@ -# transport - -Wi-Fi connectivity, MQTT client, and Home Assistant MQTT Discovery payloads. Exposes a semantic `publish(topic, value)`-style API — application code (including Lua) never depends on MQTT directly, per the transport abstraction principle in `AGENTS.md`. No dependency on other Kvida components. - -Also owns Wi-Fi/MQTT configuration storage for now (not broken out into its own component since `AGENTS.md` doesn't call out a separate config layer — revisit if this grows). - -Future transports (Zigbee, Thread, Matter) should implement the same publish API as alternate backends behind this component's interface, without changing callers. - -## Wi-Fi commissioning - -`include/commissioning.h` / `src/commissioning.cpp` implement first-time Wi-Fi setup, no companion app required: - -- On boot, `try_stored_credentials()` attempts to connect using credentials saved in NVS from a previous session. -- If none exist (or the caller chooses to), `start_commissioning()` opens a SoftAP (`Kvida-XXXXXX`, derived from the MAC) with a captive portal: a DNS server (`src/dns_server.c`/`.h`, vendored from ESP-IDF's official `captive_portal` example, Unlicense/CC0) answers every query with the AP's own IP, and DHCP option 114 plus a 404-redirects-to-`/` HTTP handler get most phones/laptops to auto-open the setup page (`src/portal.html`) in a plain browser. -- Submitting the form (`POST /api/wifi`, JSON body `{"ssid","password"}`) saves the credentials to NVS and switches Wi-Fi to STA mode. The AP is open (no password) by design -- see the comment above `ap_config.ap.authmode` in `commissioning.cpp` for the reasoning and the tradeoff. -- A 5-minute `esp_timer` closes the commissioning window automatically if nobody submits credentials. -- **IPv6**: once STA gets its IPv4 address (`IP_EVENT_STA_GOT_IP` -- not `WIFI_EVENT_STA_CONNECTED`, see the comment in `commissioning.cpp` for why that timing matters), `esp_netif_create_ip6_linklocal()` requests a link-local address (`CONFIG_LWIP_IPV6=y` is set explicitly in `sdkconfig.defaults`); `IP_EVENT_GOT_IP6` is logged when it arrives. Verified end-to-end on real ESP32-C6 hardware. -- Not yet wired up: re-entering commissioning on an already-configured device needs a physical trigger (the user button) per AGENTS.md's "configuration mode requires physical user interaction" principle -- blocked on `drivers` having a real button implementation. Network scanning (SSID dropdown instead of free text) was left out of this first pass to keep scope tight. - -## MQTT + Home Assistant Discovery - -`include/mqtt.h` / `src/mqtt.cpp`. Neither `esp_mqtt_client` (`mqtt_client.h`) nor `mdns.h` ship with ESP-IDF core in this version (both were moved to the IDF Component Registry) -- pulled in via `idf_component.yml` (`espressif/mqtt`, `mdns`), the project's first external managed dependencies. - -- Once Wi-Fi STA has an IPv4 address, `start_mqtt()` advertises the device on the LAN as `kvida-xxxxxx.local` (mDNS hostname, matching the commissioning AP's SSID) and starts a persistent settings HTTP server there -- the ongoing configuration channel for anything beyond the one-time Wi-Fi bootstrap (MQTT broker/credentials now, more later). -- **Broker address**: the settings page has a required host/port field, used directly and given priority over mDNS auto-discovery. mDNS discovery of `_mqtt._tcp` (`mdns_query_ptr`) is attempted as a fallback only if no manual host is saved, retried every 30s -- **verified against a real Home Assistant + Mosquitto add-on setup that this mDNS discovery does not reliably find the broker** (confirmed with a from-scratch raw mDNS query test from the host machine: no PTR answer for `_mqtt._tcp.local` was ever received on that network), hence the manual field being the primary path rather than a rarely-needed fallback. -- **Credentials**: also collected on the same settings page (blank username = anonymous; blank password on a resubmit means "keep the current one", since the password is never echoed back into the page for basic hygiene). Real brokers, including Home Assistant's Mosquitto add-on, require auth -- confirmed on real hardware. -- On `MQTT_EVENT_CONNECTED`: publishes (retained) an HA MQTT Discovery config for a diagnostic `connectivity` binary_sensor, grouped under a `device` block (`identifiers`/`name` = the same `kvida-xxxxxx` id, `manufacturer` "Xylon", `model` "Kvida"), then publishes `online` to the availability topic. MQTT's own LWT (`session.last_will`, set at client init) publishes `offline` to the same topic if the connection drops. Verified end-to-end: device appears in Home Assistant with a "Connected" Connectivity sensor. -- No generic semantic `publish(topic, value)` API yet -- deferred until `profiles` has real sensor data to publish; building it now would be speculative. - -## JSON API - -Both httpd servers (the commissioning AP's and the settings server's) expose a small JSON API, CORS-enabled (`src/http_cors.h`) so a separately-hosted web app (`kvida-sdk`, e.g. via `npm run dev`) can call them from a browser across origins -- not just the on-device pages, which use the same endpoints via `fetch()`: - -- `POST /api/wifi` (commissioning AP only) -- `{"ssid","password"}`, replaces the old form-urlencoded `/connect`. -- `GET /api/status` (settings server) -- `{"device_id","wifi_connected","mqtt_connected"}`. -- `GET /api/mqtt` / `POST /api/mqtt` (settings server) -- `{"host","port","username"[,"password"]}`; `GET` never returns the password. - -No JSON library ships with ESP-IDF v6.0.2 core (verified: no `cJSON`, no bundled `json` component). Given these payloads are tiny, flat, fixed-shape objects, `src/json_helpers.h` hand-rolls minimal `strstr`-based extraction rather than adding a 4th external registry dependency (already have `mdns`, `mqtt`, `led_strip`) -- explicitly not a general parser (no nesting, arrays, or escaping beyond what these specific request bodies need). - -**Important constraint that shaped this**: the Wi-Fi captive portal must keep being served by the device itself -- a phone connected to the isolated SoftAP has no route to any externally-run app. Only the MQTT/settings step (once the device has real LAN connectivity) can be driven by a separately-run web app. See `kvida-sdk`. - -TODO: A/B OTA update-checking belongs here, driven by ESP-IDF's native `esp_ota_ops` against the `ota_0`/`ota_1` partitions in `partitions.csv` — no extra dependency needed either. +# transport + +Wi-Fi connectivity, MQTT client, and Home Assistant MQTT Discovery payloads. Exposes a semantic `publish(topic, value)`-style API — application code (including Lua) never depends on MQTT directly, per the transport abstraction principle in `AGENTS.md`. No dependency on other Kvida components. + +Also owns Wi-Fi/MQTT configuration storage for now (not broken out into its own component since `AGENTS.md` doesn't call out a separate config layer — revisit if this grows). + +Future transports (Zigbee, Thread, Matter) should implement the same publish API as alternate backends behind this component's interface, without changing callers. + +## Wi-Fi commissioning + +`include/commissioning.h` / `src/commissioning.cpp` implement first-time Wi-Fi setup, no companion app required: + +- On boot, `try_stored_credentials()` attempts to connect using credentials saved in NVS from a previous session. +- If none exist (or the caller chooses to), `start_commissioning()` opens a SoftAP (`Kvida-XXXXXX`, derived from the MAC) with a captive portal: a DNS server (`src/dns_server.c`/`.h`, vendored from ESP-IDF's official `captive_portal` example, Unlicense/CC0) answers every query with the AP's own IP, and DHCP option 114 plus a 404-redirects-to-`/` HTTP handler get most phones/laptops to auto-open the setup page (`src/portal.html`) in a plain browser. +- Submitting the form (`POST /api/wifi`, JSON body `{"ssid","password"}`) saves the credentials to NVS and switches Wi-Fi to STA mode. The AP is open (no password) by design -- see the comment above `ap_config.ap.authmode` in `commissioning.cpp` for the reasoning and the tradeoff. +- A 5-minute `esp_timer` closes the commissioning window automatically if nobody submits credentials. +- **IPv6**: once STA gets its IPv4 address (`IP_EVENT_STA_GOT_IP` -- not `WIFI_EVENT_STA_CONNECTED`, see the comment in `commissioning.cpp` for why that timing matters), `esp_netif_create_ip6_linklocal()` requests a link-local address (`CONFIG_LWIP_IPV6=y` is set explicitly in `sdkconfig.defaults`); `IP_EVENT_GOT_IP6` is logged when it arrives. Verified end-to-end on real ESP32-C6 hardware. +- Not yet wired up: re-entering commissioning on an already-configured device needs a physical trigger (the user button) per AGENTS.md's "configuration mode requires physical user interaction" principle -- blocked on `drivers` having a real button implementation. Network scanning (SSID dropdown instead of free text) was left out of this first pass to keep scope tight. + +## MQTT + Home Assistant Discovery + +`include/mqtt.h` / `src/mqtt.cpp`. Neither `esp_mqtt_client` (`mqtt_client.h`) nor `mdns.h` ship with ESP-IDF core in this version (both were moved to the IDF Component Registry) -- pulled in via `idf_component.yml` (`espressif/mqtt`, `mdns`), the project's first external managed dependencies. + +- Once Wi-Fi STA has an IPv4 address, `start_mqtt()` advertises the device on the LAN as `kvida-xxxxxx.local` (mDNS hostname, matching the commissioning AP's SSID) and starts a persistent settings HTTP server there -- the ongoing configuration channel for anything beyond the one-time Wi-Fi bootstrap (MQTT broker/credentials now, more later). +- **Broker address**: the settings page has a required host/port field, used directly and given priority over mDNS auto-discovery. mDNS discovery of `_mqtt._tcp` (`mdns_query_ptr`) is attempted as a fallback only if no manual host is saved, retried every 30s -- **verified against a real Home Assistant + Mosquitto add-on setup that this mDNS discovery does not reliably find the broker** (confirmed with a from-scratch raw mDNS query test from the host machine: no PTR answer for `_mqtt._tcp.local` was ever received on that network), hence the manual field being the primary path rather than a rarely-needed fallback. +- **Credentials**: also collected on the same settings page (blank username = anonymous; blank password on a resubmit means "keep the current one", since the password is never echoed back into the page for basic hygiene). Real brokers, including Home Assistant's Mosquitto add-on, require auth -- confirmed on real hardware. +- On `MQTT_EVENT_CONNECTED`: publishes (retained) an HA MQTT Discovery config for a diagnostic `connectivity` binary_sensor, grouped under a `device` block (`identifiers`/`name` = the same `kvida-xxxxxx` id, `manufacturer` "Xylon", `model` "Kvida"), then publishes `online` to the availability topic. MQTT's own LWT (`session.last_will`, set at client init) publishes `offline` to the same topic if the connection drops. Verified end-to-end: device appears in Home Assistant with a "Connected" Connectivity sensor. +- **`publish_value(name, value)`**: the generic semantic publish API, finally built once there was a real caller -- `lua_runtime`'s `kvida.publish()`, not called directly by other components. Builds `kvida//` (state) and `homeassistant/sensor///config` (discovery) topics from the name. No `device_class`/`unit_of_measurement` or prettified HA display name, since neither can be inferred from just a name -- the entity shows up in HA labeled with the raw name (e.g. `chip_temperature`). + +## JSON API + +Both httpd servers (the commissioning AP's and the settings server's) expose a small JSON API, CORS-enabled (`src/http_cors.h`) so a separately-hosted web app (`kvida-sdk`, e.g. via `npm run dev`) can call them from a browser across origins -- not just the on-device pages, which use the same endpoints via `fetch()`: + +- `POST /api/wifi` (commissioning AP only) -- `{"ssid","password"}`, replaces the old form-urlencoded `/connect`. +- `GET /api/status` (settings server) -- `{"device_id","wifi_connected","mqtt_connected"}`. +- `GET /api/mqtt` / `POST /api/mqtt` (settings server) -- `{"host","port","username"[,"password"]}`; `GET` never returns the password. + +No JSON library ships with ESP-IDF v6.0.2 core (verified: no `cJSON`, no bundled `json` component). Given these payloads are tiny, flat, fixed-shape objects, `src/json_helpers.h` hand-rolls minimal `strstr`-based extraction rather than adding a 4th external registry dependency (already have `mdns`, `mqtt`, `led_strip`) -- explicitly not a general parser (no nesting, arrays, or escaping beyond what these specific request bodies need). + +**Important constraint that shaped this**: the Wi-Fi captive portal must keep being served by the device itself -- a phone connected to the isolated SoftAP has no route to any externally-run app. Only the MQTT/settings step (once the device has real LAN connectivity) can be driven by a separately-run web app. See `kvida-sdk`. + +TODO: A/B OTA update-checking belongs here, driven by ESP-IDF's native `esp_ota_ops` against the `ota_0`/`ota_1` partitions in `partitions.csv` — no extra dependency needed either. diff --git a/components/transport/include/mqtt.h b/components/transport/include/mqtt.h index 093140c..36b9b8c 100644 --- a/components/transport/include/mqtt.h +++ b/components/transport/include/mqtt.h @@ -7,18 +7,21 @@ namespace kvida { // Starts mDNS-based MQTT broker discovery (_mqtt._tcp) and, once found, // connects the MQTT client and publishes Home Assistant MQTT Discovery // for a diagnostic "connectivity" entity. Safe to call multiple times -// (no-op if already connecting/connected). No public-facing semantic -// publish(topic, value) API yet -- deferred until `profiles` has real -// sensor data to publish (see components/transport/README.md). +// (no-op if already connecting/connected). void start_mqtt(); bool mqtt_connected(); -// Publishes (retained) Home Assistant MQTT Discovery for a diagnostic -// "Chip temperature" sensor on first call, then the reading itself. -// Quick end-to-end proof that a real Sensor implementation's data can -// reach MQTT/HA -- not the eventual generic publish(topic, value) API. -void publish_chip_temperature(float celsius); +// Publishes a named numeric value as a Home Assistant MQTT Discovery +// sensor: `kvida//` state topic, `homeassistant/sensor///config` +// discovery topic. This is the semantic publish(name, value) API AGENTS.md +// describes -- driven by `lua_runtime`'s `kvida.publish()`, not called +// directly by other components. Discovery is republished (retained, so +// idempotent) on every call rather than cached behind a per-name +// "already announced" flag -- simpler, at the cost of a bit of extra +// retained-message traffic each call. TODO: cache announced names if +// that traffic becomes a real concern. +void publish_value(const char *name, double value); // Registered by main to receive "set LED color" commands from Home // Assistant (an RGB light entity) without transport depending on diff --git a/components/transport/src/mqtt.cpp b/components/transport/src/mqtt.cpp index 7790f65..2e5ce20 100644 --- a/components/transport/src/mqtt.cpp +++ b/components/transport/src/mqtt.cpp @@ -43,7 +43,6 @@ char g_availability_topic[48]; char g_discovery_topic[80]; char g_discovery_payload[512]; -char g_temp_state_topic[48]; char g_led_command_topic[48]; char g_led_rgb_command_topic[48]; char g_led_state_topic[48]; @@ -537,7 +536,7 @@ bool mqtt_connected() return g_connected.load(); } -void publish_chip_temperature(float celsius) +void publish_value(const char *name, double value) { if (!g_client || !g_connected.load()) { return; @@ -545,42 +544,45 @@ void publish_chip_temperature(float celsius) char id[16]; device_id(id, sizeof(id)); - snprintf(g_temp_state_topic, sizeof(g_temp_state_topic), "kvida/%s/chip_temperature", id); - static bool discovery_published = false; - if (!discovery_published) { - char discovery_topic[80]; - snprintf(discovery_topic, sizeof(discovery_topic), - "homeassistant/sensor/%s/chip_temperature/config", id); + char state_topic[64]; + snprintf(state_topic, sizeof(state_topic), "kvida/%s/%s", id, name); - char payload[512]; - snprintf(payload, sizeof(payload), - "{" - "\"name\":\"Chip temperature\"," - "\"device_class\":\"temperature\"," - "\"unit_of_measurement\":\"\xc2\xb0" - "C\"," - "\"unique_id\":\"%s_chip_temperature\"," - "\"state_topic\":\"%s\"," - "\"availability_topic\":\"%s\"," - "\"payload_available\":\"online\"," - "\"payload_not_available\":\"offline\"," - "\"device\":{" - "\"identifiers\":[\"%s\"]," - "\"name\":\"%s\"," - "\"manufacturer\":\"Xylon\"," - "\"model\":\"Kvida\"" - "}" - "}", - id, g_temp_state_topic, g_availability_topic, id, id); + // Discovery is republished (retained, so idempotent) every call rather + // than cached behind a per-name "already announced" flag -- see the + // comment on publish_value() in mqtt.h. + char discovery_topic[96]; + snprintf(discovery_topic, sizeof(discovery_topic), "homeassistant/sensor/%s/%s/config", id, name); - esp_mqtt_client_publish(g_client, discovery_topic, payload, 0, 1, true); - discovery_published = true; - } + // No device_class/unit_of_measurement: this is a generic named-value + // API (Lua's kvida.publish(name, value)), so there's no reliable way + // to infer units from just a name. The entity's HA display name is + // also just the raw `name` (e.g. "chip_temperature") for the same + // reason -- a future version could thread a prettier label through + // once profiles pass richer metadata. + char payload[512]; + snprintf(payload, sizeof(payload), + "{" + "\"name\":\"%s\"," + "\"unique_id\":\"%s_%s\"," + "\"state_topic\":\"%s\"," + "\"availability_topic\":\"%s\"," + "\"payload_available\":\"online\"," + "\"payload_not_available\":\"offline\"," + "\"device\":{" + "\"identifiers\":[\"%s\"]," + "\"name\":\"%s\"," + "\"manufacturer\":\"Xylon\"," + "\"model\":\"Kvida\"" + "}" + "}", + name, id, name, state_topic, g_availability_topic, id, id); - char value[16]; - snprintf(value, sizeof(value), "%.1f", static_cast(celsius)); - esp_mqtt_client_publish(g_client, g_temp_state_topic, value, 0, 0, false); + esp_mqtt_client_publish(g_client, discovery_topic, payload, 0, 1, true); + + char value_str[32]; + snprintf(value_str, sizeof(value_str), "%.2f", value); + esp_mqtt_client_publish(g_client, state_topic, value_str, 0, 0, false); } void set_led_color_handler(LedColorHandler handler) diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 9033e81..d9a1b24 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,5 +1,5 @@ -idf_component_register( - SRCS "main.cpp" - INCLUDE_DIRS "." - REQUIRES drivers sensors profiles transport lua_runtime esp_timer -) +idf_component_register( + SRCS "main.cpp" + INCLUDE_DIRS "." + REQUIRES drivers transport lua_runtime esp_timer +) diff --git a/main/main.cpp b/main/main.cpp index c62b090..498c5be 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -1,27 +1,14 @@ -#include "chip_temperature_sensor.h" #include "commissioning.h" #include "esp_log.h" #include "esp_timer.h" +#include "lua_runtime.h" #include "mqtt.h" #include "rgb_led.h" namespace { constexpr const char *TAG = "kvida_os"; -kvida::ChipTemperatureSensor g_chip_temp_sensor; -esp_timer_handle_t g_chip_temp_timer = nullptr; - -void publish_chip_temperature_tick(void * /*arg*/) -{ - if (!g_chip_temp_sensor.fetch()) { - return; - } - kvida::SensorValue value{}; - if (g_chip_temp_sensor.get(kvida::SensorChannel::ChipTemperature, value)) { - float celsius = static_cast(value.val1) + static_cast(value.val2) * 1e-6f; - kvida::publish_chip_temperature(celsius); - } -} +esp_timer_handle_t g_lua_tick_timer = nullptr; void on_led_color(uint8_t red, uint8_t green, uint8_t blue) { @@ -34,22 +21,23 @@ extern "C" void app_main(void) { ESP_LOGI(TAG, "kvida-os starting"); - // Quick end-to-end data-flow proof (real Sensor impl -> MQTT/HA, and - // HA -> MQTT -> actuator) using only what's on the ESP32-C6-DevKitC-1 - // itself: no sensor profiles or hardware wiring exist yet. - g_chip_temp_sensor.begin(); + // The default Lua script (components/lua_runtime/src/default_script.h) + // reads chip temperature, drives the LED, and publishes to Home + // Assistant -- see that component's README for what's real here vs. + // still a "quick proof" using only what's on the ESP32-C6-DevKitC-1. kvida::drivers::rgb_led_init(); + kvida::lua_runtime_init(); kvida::set_led_color_handler(&on_led_color); const esp_timer_create_args_t timer_args = { - .callback = &publish_chip_temperature_tick, + .callback = [](void *) { kvida::lua_runtime_tick(); }, .arg = nullptr, .dispatch_method = ESP_TIMER_TASK, - .name = "chip_temp_publish", + .name = "lua_tick", .skip_unhandled_events = true, }; - ESP_ERROR_CHECK(esp_timer_create(&timer_args, &g_chip_temp_timer)); - ESP_ERROR_CHECK(esp_timer_start_periodic(g_chip_temp_timer, 30LL * 1000000)); + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &g_lua_tick_timer)); + ESP_ERROR_CHECK(esp_timer_start_periodic(g_lua_tick_timer, 30LL * 1000000)); // First boot / no stored Wi-Fi credentials -> fall straight into // commissioning (nothing to protect yet, so no physical-interaction