lua_runtime now loads the running script from a new LittleFS-backed drivers::storage (joltwallet/littlefs, the project's fifth external managed component -- mounts the "storage" partition already reserved in partitions.csv), falling back to the compiled-in default script only on first boot. transport exposes GET/POST /api/script -- raw Lua text, not JSON, since json_helpers.h can't round-trip scripts safely -- relayed to lua_runtime via the same callback-registration pattern already used for LedColorHandler, keeping transport free of a dependency on lua_runtime. lua_runtime_reload() persists and hot-swaps to a fresh Lua state immediately, no reboot. Verified on real ESP32-C6 hardware: script loads via kvida-sdk, an edited script hot-reloads within one tick, and the edit survives a power cycle (proving LittleFS persistence, not just an in-RAM change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 officialcaptive_portalexample, 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 aboveap_config.ap.authmodeincommissioning.cppfor the reasoning and the tradeoff. - A 5-minute
esp_timercloses the commissioning window automatically if nobody submits credentials. - IPv6: once STA gets its IPv4 address (
IP_EVENT_STA_GOT_IP-- notWIFI_EVENT_STA_CONNECTED, see the comment incommissioning.cppfor why that timing matters),esp_netif_create_ip6_linklocal()requests a link-local address (CONFIG_LWIP_IPV6=yis set explicitly insdkconfig.defaults);IP_EVENT_GOT_IP6is 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
drivershaving 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 askvida-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.localwas 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 diagnosticconnectivitybinary_sensor, grouped under adeviceblock (identifiers/name= the samekvida-xxxxxxid,manufacturer"Xylon",model"Kvida"), then publishesonlineto the availability topic. MQTT's own LWT (session.last_will, set at client init) publishesofflineto 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'skvida.publish(), not called directly by other components. Buildskvida/<id>/<name>(state) andhomeassistant/sensor/<id>/<name>/config(discovery) topics from the name. Nodevice_class/unit_of_measurementor 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"]};GETnever returns the password.GET /api/script/POST /api/script(settings server) -- the one endpoint in this API that is not JSON: the body is the raw Lua source astext/plain, both directions. Deliberate, not an oversight --json_helpers.his explicitly a non-escaping minimal parser, and Lua scripts routinely contain",\, and newlines it can't round-trip safely.transportdoesn't store or interpret the script itself (would mean depending onlua_runtime, breaking this component's "no dependency on other Kvida components" rule) -- it just relays through two function-pointer callbacks main.cpp wires up,set_script_change_handler/set_script_provider, the same pattern already used forLedColorHandler.POSTreads the body in a loop up to an 8KB cap (413beyond that) since, unlike the small fixed-shape JSON bodies elsewhere here,httpd_req_recv()isn't guaranteed to return a script-sized body in one call.
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.