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 <noreply@anthropic.com>
This commit is contained in:
Ronny Eia
2026-07-12 09:17:06 +02:00
parent 3fee19c60a
commit eae6d3af5a
12 changed files with 328 additions and 122 deletions

View File

@@ -1,7 +1,19 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
include($ENV{IDF_PATH}/tools/cmake/project.cmake) include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(kvida_os)
# 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 <limits.h> 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)

View File

@@ -1,3 +1,7 @@
idf_component_register( 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
) )

View File

@@ -1,5 +1,25 @@
# lua_runtime # 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.

View File

@@ -0,0 +1,2 @@
dependencies:
espressif/lua: "==5.5.0"

View File

@@ -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

View File

@@ -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

View File

@@ -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<double>(value.val1) + static_cast<double>(value.val2) * 1e-6;
lua_pushnumber(L, celsius);
return 1;
}
int l_set_led(lua_State *L)
{
auto r = static_cast<uint8_t>(luaL_checkinteger(L, 1));
auto g = static_cast<uint8_t>(luaL_checkinteger(L, 2));
auto b = static_cast<uint8_t>(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

View File

@@ -1,42 +1,42 @@
# transport # 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. 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). 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. 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 ## Wi-Fi commissioning
`include/commissioning.h` / `src/commissioning.cpp` implement first-time Wi-Fi setup, no companion app required: `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. - 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. - 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. - 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. - 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. - **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. - 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 ## 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. `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). - 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. - **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. - **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. - 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. - **`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/<id>/<name>` (state) and `homeassistant/sensor/<id>/<name>/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 ## 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()`: 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`. - `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/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. - `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). 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`. **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. 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.

View File

@@ -7,18 +7,21 @@ namespace kvida {
// Starts mDNS-based MQTT broker discovery (_mqtt._tcp) and, once found, // Starts mDNS-based MQTT broker discovery (_mqtt._tcp) and, once found,
// connects the MQTT client and publishes Home Assistant MQTT Discovery // connects the MQTT client and publishes Home Assistant MQTT Discovery
// for a diagnostic "connectivity" entity. Safe to call multiple times // for a diagnostic "connectivity" entity. Safe to call multiple times
// (no-op if already connecting/connected). No public-facing semantic // (no-op if already connecting/connected).
// publish(topic, value) API yet -- deferred until `profiles` has real
// sensor data to publish (see components/transport/README.md).
void start_mqtt(); void start_mqtt();
bool mqtt_connected(); bool mqtt_connected();
// Publishes (retained) Home Assistant MQTT Discovery for a diagnostic // Publishes a named numeric value as a Home Assistant MQTT Discovery
// "Chip temperature" sensor on first call, then the reading itself. // sensor: `kvida/<id>/<name>` state topic, `homeassistant/sensor/<id>/<name>/config`
// Quick end-to-end proof that a real Sensor implementation's data can // discovery topic. This is the semantic publish(name, value) API AGENTS.md
// reach MQTT/HA -- not the eventual generic publish(topic, value) API. // describes -- driven by `lua_runtime`'s `kvida.publish()`, not called
void publish_chip_temperature(float celsius); // 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 // Registered by main to receive "set LED color" commands from Home
// Assistant (an RGB light entity) without transport depending on // Assistant (an RGB light entity) without transport depending on

View File

@@ -43,7 +43,6 @@ char g_availability_topic[48];
char g_discovery_topic[80]; char g_discovery_topic[80];
char g_discovery_payload[512]; char g_discovery_payload[512];
char g_temp_state_topic[48];
char g_led_command_topic[48]; char g_led_command_topic[48];
char g_led_rgb_command_topic[48]; char g_led_rgb_command_topic[48];
char g_led_state_topic[48]; char g_led_state_topic[48];
@@ -537,7 +536,7 @@ bool mqtt_connected()
return g_connected.load(); return g_connected.load();
} }
void publish_chip_temperature(float celsius) void publish_value(const char *name, double value)
{ {
if (!g_client || !g_connected.load()) { if (!g_client || !g_connected.load()) {
return; return;
@@ -545,42 +544,45 @@ void publish_chip_temperature(float celsius)
char id[16]; char id[16];
device_id(id, sizeof(id)); 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; char state_topic[64];
if (!discovery_published) { snprintf(state_topic, sizeof(state_topic), "kvida/%s/%s", id, name);
char discovery_topic[80];
snprintf(discovery_topic, sizeof(discovery_topic),
"homeassistant/sensor/%s/chip_temperature/config", id);
char payload[512]; // Discovery is republished (retained, so idempotent) every call rather
snprintf(payload, sizeof(payload), // than cached behind a per-name "already announced" flag -- see the
"{" // comment on publish_value() in mqtt.h.
"\"name\":\"Chip temperature\"," char discovery_topic[96];
"\"device_class\":\"temperature\"," snprintf(discovery_topic, sizeof(discovery_topic), "homeassistant/sensor/%s/%s/config", id, name);
"\"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);
esp_mqtt_client_publish(g_client, discovery_topic, payload, 0, 1, true); // No device_class/unit_of_measurement: this is a generic named-value
discovery_published = true; // 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]; esp_mqtt_client_publish(g_client, discovery_topic, payload, 0, 1, true);
snprintf(value, sizeof(value), "%.1f", static_cast<double>(celsius));
esp_mqtt_client_publish(g_client, g_temp_state_topic, value, 0, 0, false); 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) void set_led_color_handler(LedColorHandler handler)

View File

@@ -1,5 +1,5 @@
idf_component_register( idf_component_register(
SRCS "main.cpp" SRCS "main.cpp"
INCLUDE_DIRS "." INCLUDE_DIRS "."
REQUIRES drivers sensors profiles transport lua_runtime esp_timer REQUIRES drivers transport lua_runtime esp_timer
) )

View File

@@ -1,27 +1,14 @@
#include "chip_temperature_sensor.h"
#include "commissioning.h" #include "commissioning.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_timer.h" #include "esp_timer.h"
#include "lua_runtime.h"
#include "mqtt.h" #include "mqtt.h"
#include "rgb_led.h" #include "rgb_led.h"
namespace { namespace {
constexpr const char *TAG = "kvida_os"; constexpr const char *TAG = "kvida_os";
kvida::ChipTemperatureSensor g_chip_temp_sensor; esp_timer_handle_t g_lua_tick_timer = nullptr;
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<float>(value.val1) + static_cast<float>(value.val2) * 1e-6f;
kvida::publish_chip_temperature(celsius);
}
}
void on_led_color(uint8_t red, uint8_t green, uint8_t blue) 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"); ESP_LOGI(TAG, "kvida-os starting");
// Quick end-to-end data-flow proof (real Sensor impl -> MQTT/HA, and // The default Lua script (components/lua_runtime/src/default_script.h)
// HA -> MQTT -> actuator) using only what's on the ESP32-C6-DevKitC-1 // reads chip temperature, drives the LED, and publishes to Home
// itself: no sensor profiles or hardware wiring exist yet. // Assistant -- see that component's README for what's real here vs.
g_chip_temp_sensor.begin(); // still a "quick proof" using only what's on the ESP32-C6-DevKitC-1.
kvida::drivers::rgb_led_init(); kvida::drivers::rgb_led_init();
kvida::lua_runtime_init();
kvida::set_led_color_handler(&on_led_color); kvida::set_led_color_handler(&on_led_color);
const esp_timer_create_args_t timer_args = { const esp_timer_create_args_t timer_args = {
.callback = &publish_chip_temperature_tick, .callback = [](void *) { kvida::lua_runtime_tick(); },
.arg = nullptr, .arg = nullptr,
.dispatch_method = ESP_TIMER_TASK, .dispatch_method = ESP_TIMER_TASK,
.name = "chip_temp_publish", .name = "lua_tick",
.skip_unhandled_events = true, .skip_unhandled_events = true,
}; };
ESP_ERROR_CHECK(esp_timer_create(&timer_args, &g_chip_temp_timer)); ESP_ERROR_CHECK(esp_timer_create(&timer_args, &g_lua_tick_timer));
ESP_ERROR_CHECK(esp_timer_start_periodic(g_chip_temp_timer, 30LL * 1000000)); ESP_ERROR_CHECK(esp_timer_start_periodic(g_lua_tick_timer, 30LL * 1000000));
// First boot / no stored Wi-Fi credentials -> fall straight into // First boot / no stored Wi-Fi credentials -> fall straight into
// commissioning (nothing to protect yet, so no physical-interaction // commissioning (nothing to protect yet, so no physical-interaction