From 4af0f1b248541a708095c28e6ffa770e67f31076 Mon Sep 17 00:00:00 2001 From: Ronny Eia <3652665+eiaro@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:27:00 +0200 Subject: [PATCH] Add chip temperature sensor and RGB LED control, verified live in HA Quick end-to-end data-flow proof using only what's on the ESP32-C6- DevKitC-1 itself (no sensors on the bare devkit -- confirmed via research: BOOT button, WS2812 LED, no external sensors): - drivers::chip_temperature_* wraps ESP-IDF's built-in temperature_sensor driver (SoC die temperature). - drivers::rgb_led_* wraps the onboard WS2812 (GPIO8) via the espressif/led_strip managed component (another external registry dependency, like mdns/mqtt). - profiles::ChipTemperatureSensor is the first real implementation of the Sensor interface designed earlier, converting the driver's float reading to the fixed-point convention once at the boundary. - transport/mqtt.cpp publishes chip temperature as an HA "temperature" sensor every 30s, and exposes the LED as an HA "light" entity (rgb_command_topic) -- the first use of MQTT subscribe in this project, not just publish. Added a ChipTemperature SensorChannel (distinct from AmbientTemperature -- different meaning/range) to sensor.h. Verified live: both the chip temperature reading and LED color control work from Home Assistant against the real device. Co-Authored-By: Claude Sonnet 5 --- components/drivers/CMakeLists.txt | 7 +- components/drivers/README.md | 3 + components/drivers/idf_component.yml | 3 + components/drivers/include/chip_temperature.h | 11 ++ components/drivers/include/rgb_led.h | 11 ++ components/drivers/src/chip_temperature.cpp | 37 +++++ components/drivers/src/rgb_led.cpp | 47 ++++++ components/profiles/CMakeLists.txt | 4 +- components/profiles/README.md | 4 + .../include/chip_temperature_sensor.h | 28 ++++ .../profiles/src/chip_temperature_sensor.cpp | 36 +++++ components/sensors/include/sensor.h | 1 + components/transport/include/mqtt.h | 14 ++ components/transport/src/mqtt.cpp | 150 +++++++++++++++++- main/CMakeLists.txt | 2 +- main/main.cpp | 42 +++++ 16 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 components/drivers/idf_component.yml create mode 100644 components/drivers/include/chip_temperature.h create mode 100644 components/drivers/include/rgb_led.h create mode 100644 components/drivers/src/chip_temperature.cpp create mode 100644 components/drivers/src/rgb_led.cpp create mode 100644 components/profiles/include/chip_temperature_sensor.h create mode 100644 components/profiles/src/chip_temperature_sensor.cpp diff --git a/components/drivers/CMakeLists.txt b/components/drivers/CMakeLists.txt index f0ba79b..f9af15a 100644 --- a/components/drivers/CMakeLists.txt +++ b/components/drivers/CMakeLists.txt @@ -1,2 +1,5 @@ -idf_component_register() - +idf_component_register( + SRCS "src/chip_temperature.cpp" "src/rgb_led.cpp" + INCLUDE_DIRS "include" + PRIV_REQUIRES esp_driver_tsens espressif__led_strip +) diff --git a/components/drivers/README.md b/components/drivers/README.md index e09fe9c..0e0453e 100644 --- a/components/drivers/README.md +++ b/components/drivers/README.md @@ -2,4 +2,7 @@ Hardware abstraction: wraps ESP32-C6 peripherals (GPIO, ADC, I2C) behind a board-independent interface used by `sensors`. No dependency on other Kvida components. +- `chip_temperature.h`/`.cpp` -- wraps ESP-IDF's built-in `temperature_sensor` driver (die temperature; there's no external ambient temperature sensor on the ESP32-C6-DevKitC-1). +- `rgb_led.h`/`.cpp` -- wraps the DevKitC-1's onboard WS2812 addressable RGB LED (GPIO8) via the `espressif/led_strip` managed component (`idf_component.yml`). + TODO: mounting the `storage` LittleFS partition (see `partitions.csv`) belongs here once a LittleFS component (e.g. `joltwallet/esp_littlefs` from the IDF Component Registry) is picked and pinned via `idf_component.yml`. diff --git a/components/drivers/idf_component.yml b/components/drivers/idf_component.yml new file mode 100644 index 0000000..ac4ab84 --- /dev/null +++ b/components/drivers/idf_component.yml @@ -0,0 +1,3 @@ +dependencies: + espressif/led_strip: + version: "*" diff --git a/components/drivers/include/chip_temperature.h b/components/drivers/include/chip_temperature.h new file mode 100644 index 0000000..60ce9d0 --- /dev/null +++ b/components/drivers/include/chip_temperature.h @@ -0,0 +1,11 @@ +#pragma once + +namespace kvida::drivers { + +// Wraps ESP-IDF's temperature_sensor driver (die temperature, not +// ambient -- there is no external temperature sensor on the ESP32-C6 +// DevKitC). Call init() once before read_celsius(). +void chip_temperature_init(); +float chip_temperature_read_celsius(); + +} // namespace kvida::drivers diff --git a/components/drivers/include/rgb_led.h b/components/drivers/include/rgb_led.h new file mode 100644 index 0000000..cdec51f --- /dev/null +++ b/components/drivers/include/rgb_led.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace kvida::drivers { + +// Wraps the WS2812 addressable RGB LED on the ESP32-C6-DevKitC-1 (GPIO8). +void rgb_led_init(); +void rgb_led_set_color(uint8_t red, uint8_t green, uint8_t blue); + +} // namespace kvida::drivers diff --git a/components/drivers/src/chip_temperature.cpp b/components/drivers/src/chip_temperature.cpp new file mode 100644 index 0000000..9a33df6 --- /dev/null +++ b/components/drivers/src/chip_temperature.cpp @@ -0,0 +1,37 @@ +#include "chip_temperature.h" + +#include "driver/temperature_sensor.h" +#include "esp_log.h" + +namespace kvida::drivers { + +namespace { +constexpr const char *TAG = "chip_temperature"; +temperature_sensor_handle_t g_handle = nullptr; +} // namespace + +void chip_temperature_init() +{ + if (g_handle) { + return; + } + temperature_sensor_config_t cfg = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); + ESP_ERROR_CHECK(temperature_sensor_install(&cfg, &g_handle)); + ESP_ERROR_CHECK(temperature_sensor_enable(g_handle)); +} + +float chip_temperature_read_celsius() +{ + if (!g_handle) { + ESP_LOGE(TAG, "chip_temperature_read_celsius() called before chip_temperature_init()"); + return 0.0f; + } + float celsius = 0.0f; + esp_err_t err = temperature_sensor_get_celsius(g_handle, &celsius); + if (err != ESP_OK) { + ESP_LOGW(TAG, "temperature_sensor_get_celsius failed: %s", esp_err_to_name(err)); + } + return celsius; +} + +} // namespace kvida::drivers diff --git a/components/drivers/src/rgb_led.cpp b/components/drivers/src/rgb_led.cpp new file mode 100644 index 0000000..9cfd20f --- /dev/null +++ b/components/drivers/src/rgb_led.cpp @@ -0,0 +1,47 @@ +#include "rgb_led.h" + +#include "esp_log.h" +#include "led_strip.h" + +namespace kvida::drivers { + +namespace { +constexpr const char *TAG = "rgb_led"; +constexpr int kGpio = 8; // WS2812 on ESP32-C6-DevKitC-1 +led_strip_handle_t g_strip = nullptr; +} // namespace + +void rgb_led_init() +{ + if (g_strip) { + return; + } + + led_strip_config_t strip_config = {}; + strip_config.strip_gpio_num = kGpio; + strip_config.max_leds = 1; + strip_config.led_model = LED_MODEL_WS2812; + strip_config.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB; + + led_strip_rmt_config_t rmt_config = {}; + rmt_config.resolution_hz = 10 * 1000 * 1000; + + esp_err_t err = led_strip_new_rmt_device(&strip_config, &rmt_config, &g_strip); + if (err != ESP_OK) { + ESP_LOGE(TAG, "led_strip_new_rmt_device failed: %s", esp_err_to_name(err)); + return; + } + led_strip_clear(g_strip); +} + +void rgb_led_set_color(uint8_t red, uint8_t green, uint8_t blue) +{ + if (!g_strip) { + ESP_LOGE(TAG, "rgb_led_set_color() called before rgb_led_init()"); + return; + } + led_strip_set_pixel(g_strip, 0, red, green, blue); + led_strip_refresh(g_strip); +} + +} // namespace kvida::drivers diff --git a/components/profiles/CMakeLists.txt b/components/profiles/CMakeLists.txt index 7c0f8ba..10688da 100644 --- a/components/profiles/CMakeLists.txt +++ b/components/profiles/CMakeLists.txt @@ -1,3 +1,5 @@ idf_component_register( - REQUIRES sensors + SRCS "src/chip_temperature_sensor.cpp" + INCLUDE_DIRS "include" + REQUIRES sensors drivers ) diff --git a/components/profiles/README.md b/components/profiles/README.md index 14b5be4..e4b5150 100644 --- a/components/profiles/README.md +++ b/components/profiles/README.md @@ -1,3 +1,7 @@ # profiles Exposes reusable sensor/actuator capabilities (reed switch, hall sensor, push button, pulse counter, analog input, DS18B20, BME280, leak sensor, PIR, generic I2C device) to the Lua runtime, backed by `sensors`. `REQUIRES sensors`. + +Concrete `Sensor` implementations live here (not in `sensors`, which owns the shared interface/types), and reach down to `drivers` directly for the actual hardware access -- `sensors` is the shared contract, not a mandatory pass-through. `REQUIRES drivers` too. + +- `ChipTemperatureSensor` (`include/chip_temperature_sensor.h`) -- the first real `Sensor` implementation, wrapping `drivers::chip_temperature_*` (the ESP32-C6's internal die temperature). Added to get real data flowing end-to-end (drivers -> profiles -> transport/MQTT) before more sensor types exist. diff --git a/components/profiles/include/chip_temperature_sensor.h b/components/profiles/include/chip_temperature_sensor.h new file mode 100644 index 0000000..35c26e7 --- /dev/null +++ b/components/profiles/include/chip_temperature_sensor.h @@ -0,0 +1,28 @@ +#pragma once + +#include "sensor.h" + +namespace kvida { + +// First real implementation of the Sensor interface (see +// components/sensors/include/sensor.h) -- wraps the ESP32-C6's internal +// die temperature sensor (kvida::drivers::chip_temperature_*). +class ChipTemperatureSensor : public Sensor { +public: + const char *id() const override { return "chip_temperature"; } + void begin() override; + + bool supports(SensorChannel channel) const override + { + return channel == SensorChannel::ChipTemperature; + } + + bool fetch() override; + bool get(SensorChannel channel, SensorValue &out) override; + +private: + bool fetched_ = false; + SensorValue last_{}; +}; + +} // namespace kvida diff --git a/components/profiles/src/chip_temperature_sensor.cpp b/components/profiles/src/chip_temperature_sensor.cpp new file mode 100644 index 0000000..750fac4 --- /dev/null +++ b/components/profiles/src/chip_temperature_sensor.cpp @@ -0,0 +1,36 @@ +#include "chip_temperature_sensor.h" + +#include "chip_temperature.h" + +namespace kvida { + +void ChipTemperatureSensor::begin() +{ + drivers::chip_temperature_init(); +} + +bool ChipTemperatureSensor::fetch() +{ + float celsius = drivers::chip_temperature_read_celsius(); + + // Convert to the fixed-point convention (val1 + val2 * 1e-6) once, at + // the boundary where the underlying driver hands us a float -- + // ESP32-C6 has no hardware FPU, see sensor.h. + auto val1 = static_cast(celsius); + auto val2 = static_cast((celsius - static_cast(val1)) * 1000000.0f); + + last_ = SensorValue{val1, val2, "C", 0}; + fetched_ = true; + return true; +} + +bool ChipTemperatureSensor::get(SensorChannel channel, SensorValue &out) +{ + if (!fetched_ || channel != SensorChannel::ChipTemperature) { + return false; + } + out = last_; + return true; +} + +} // namespace kvida diff --git a/components/sensors/include/sensor.h b/components/sensors/include/sensor.h index 378bcbe..3e5cef2 100644 --- a/components/sensors/include/sensor.h +++ b/components/sensors/include/sensor.h @@ -11,6 +11,7 @@ enum class SensorChannel { Contact, Motion, AmbientTemperature, + ChipTemperature, // SoC die temperature, e.g. ESP32-C6's internal sensor -- distinct from AmbientTemperature since the value range/meaning differ Humidity, Pressure, AccelX, diff --git a/components/transport/include/mqtt.h b/components/transport/include/mqtt.h index dd1c10e..093140c 100644 --- a/components/transport/include/mqtt.h +++ b/components/transport/include/mqtt.h @@ -1,5 +1,7 @@ #pragma once +#include + namespace kvida { // Starts mDNS-based MQTT broker discovery (_mqtt._tcp) and, once found, @@ -12,4 +14,16 @@ 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); + +// Registered by main to receive "set LED color" commands from Home +// Assistant (an RGB light entity) without transport depending on +// `drivers` directly. +using LedColorHandler = void (*)(uint8_t red, uint8_t green, uint8_t blue); +void set_led_color_handler(LedColorHandler handler); + } // namespace kvida diff --git a/components/transport/src/mqtt.cpp b/components/transport/src/mqtt.cpp index ec4e9ce..6adbe27 100644 --- a/components/transport/src/mqtt.cpp +++ b/components/transport/src/mqtt.cpp @@ -33,10 +33,19 @@ std::atomic g_connected{false}; esp_mqtt_client_handle_t g_client = nullptr; esp_timer_handle_t g_retry_timer = nullptr; httpd_handle_t g_settings_httpd = nullptr; +LedColorHandler g_led_handler = nullptr; 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]; +char g_led_rgb_state_topic[48]; +bool g_led_discovery_published = false; +bool g_led_on = false; // Remembered so a settings update can reconnect without re-running mDNS // discovery. char g_broker_uri[96]; // "mqtt://" + up to 63-char host + ":" + port + NUL @@ -149,18 +158,106 @@ void publish_discovery_and_birth() esp_mqtt_client_publish(g_client, g_availability_topic, "online", 0, 1, true); } -void mqtt_event_handler(void * /*arg*/, esp_event_base_t /*base*/, int32_t event_id, void * /*event_data*/) +// Publishes HA MQTT Discovery for an RGB light entity (once) and +// (re)subscribes to its command topics -- topic subscriptions don't +// survive a client reconnect, so this runs on every MQTT_EVENT_CONNECTED, +// not just the first. +void publish_led_discovery_and_subscribe() +{ + char id[16]; + device_id(id, sizeof(id)); + + snprintf(g_led_command_topic, sizeof(g_led_command_topic), "kvida/%s/led/set", id); + snprintf(g_led_rgb_command_topic, sizeof(g_led_rgb_command_topic), "kvida/%s/led/rgb/set", id); + snprintf(g_led_state_topic, sizeof(g_led_state_topic), "kvida/%s/led/state", id); + snprintf(g_led_rgb_state_topic, sizeof(g_led_rgb_state_topic), "kvida/%s/led/rgb/state", id); + + if (!g_led_discovery_published) { + char discovery_topic[80]; + snprintf(discovery_topic, sizeof(discovery_topic), "homeassistant/light/%s/led/config", id); + + char payload[640]; + snprintf(payload, sizeof(payload), + "{" + "\"name\":\"LED\"," + "\"unique_id\":\"%s_led\"," + "\"command_topic\":\"%s\"," + "\"state_topic\":\"%s\"," + "\"rgb_command_topic\":\"%s\"," + "\"rgb_state_topic\":\"%s\"," + "\"payload_on\":\"ON\"," + "\"payload_off\":\"OFF\"," + "\"availability_topic\":\"%s\"," + "\"payload_available\":\"online\"," + "\"payload_not_available\":\"offline\"," + "\"device\":{" + "\"identifiers\":[\"%s\"]," + "\"name\":\"%s\"," + "\"manufacturer\":\"Xylon\"," + "\"model\":\"Kvida\"" + "}" + "}", + id, g_led_command_topic, g_led_state_topic, g_led_rgb_command_topic, g_led_rgb_state_topic, + g_availability_topic, id, id); + + esp_mqtt_client_publish(g_client, discovery_topic, payload, 0, 1, true); + g_led_discovery_published = true; + } + + esp_mqtt_client_subscribe(g_client, g_led_command_topic, 1); + esp_mqtt_client_subscribe(g_client, g_led_rgb_command_topic, 1); +} + +void handle_led_data(const char *topic, size_t topic_len, const char *data, size_t data_len) +{ + char payload[32] = {0}; + size_t n = data_len < sizeof(payload) - 1 ? data_len : sizeof(payload) - 1; + memcpy(payload, data, n); + payload[n] = '\0'; + + bool is_power = topic_len == strlen(g_led_command_topic) && memcmp(topic, g_led_command_topic, topic_len) == 0; + bool is_rgb = + topic_len == strlen(g_led_rgb_command_topic) && memcmp(topic, g_led_rgb_command_topic, topic_len) == 0; + + if (is_power) { + g_led_on = strcmp(payload, "ON") == 0; + if (!g_led_on && g_led_handler) { + g_led_handler(0, 0, 0); + } + esp_mqtt_client_publish(g_client, g_led_state_topic, g_led_on ? "ON" : "OFF", 0, 1, true); + } else if (is_rgb) { + // Home Assistant's classic (non-JSON) rgb_command_topic schema + // sends "R,G,B". + int r = 0, g = 0, b = 0; + if (sscanf(payload, "%d,%d,%d", &r, &g, &b) == 3) { + g_led_on = true; + if (g_led_handler) { + g_led_handler(static_cast(r), static_cast(g), static_cast(b)); + } + esp_mqtt_client_publish(g_client, g_led_state_topic, "ON", 0, 1, true); + esp_mqtt_client_publish(g_client, g_led_rgb_state_topic, payload, 0, 1, true); + } + } +} + +void mqtt_event_handler(void * /*arg*/, esp_event_base_t /*base*/, int32_t event_id, void *event_data) { switch (static_cast(event_id)) { case MQTT_EVENT_CONNECTED: ESP_LOGI(TAG, "MQTT connected"); g_connected.store(true); publish_discovery_and_birth(); + publish_led_discovery_and_subscribe(); break; case MQTT_EVENT_DISCONNECTED: ESP_LOGW(TAG, "MQTT disconnected"); g_connected.store(false); break; + case MQTT_EVENT_DATA: { + auto *event = static_cast(event_data); + handle_led_data(event->topic, event->topic_len, event->data, event->data_len); + break; + } default: break; } @@ -411,4 +508,55 @@ bool mqtt_connected() return g_connected.load(); } +void publish_chip_temperature(float celsius) +{ + if (!g_client || !g_connected.load()) { + return; + } + + 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 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); + + esp_mqtt_client_publish(g_client, discovery_topic, payload, 0, 1, true); + discovery_published = true; + } + + 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); +} + +void set_led_color_handler(LedColorHandler handler) +{ + g_led_handler = handler; +} + } // namespace kvida diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 495c0e1..9033e81 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 + REQUIRES drivers sensors profiles transport lua_runtime esp_timer ) diff --git a/main/main.cpp b/main/main.cpp index bc339b9..c62b090 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -1,14 +1,56 @@ +#include "chip_temperature_sensor.h" #include "commissioning.h" #include "esp_log.h" +#include "esp_timer.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); + } } +void on_led_color(uint8_t red, uint8_t green, uint8_t blue) +{ + kvida::drivers::rgb_led_set_color(red, green, blue); +} + +} // namespace + 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(); + kvida::drivers::rgb_led_init(); + kvida::set_led_color_handler(&on_led_color); + + const esp_timer_create_args_t timer_args = { + .callback = &publish_chip_temperature_tick, + .arg = nullptr, + .dispatch_method = ESP_TIMER_TASK, + .name = "chip_temp_publish", + .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)); + // First boot / no stored Wi-Fi credentials -> fall straight into // commissioning (nothing to protect yet, so no physical-interaction // gate needed here). Re-entering commissioning on an already-configured