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 <noreply@anthropic.com>
This commit is contained in:
Ronny Eia
2026-07-10 19:27:00 +02:00
parent 1bdc19b695
commit 4af0f1b248
16 changed files with 395 additions and 5 deletions

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
dependencies:
espressif/led_strip:
version: "*"

View File

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

View File

@@ -0,0 +1,11 @@
#pragma once
#include <cstdint>
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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
idf_component_register( idf_component_register(
REQUIRES sensors SRCS "src/chip_temperature_sensor.cpp"
INCLUDE_DIRS "include"
REQUIRES sensors drivers
) )

View File

@@ -1,3 +1,7 @@
# profiles # 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`. 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.

View File

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

View File

@@ -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<int32_t>(celsius);
auto val2 = static_cast<int32_t>((celsius - static_cast<float>(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

View File

@@ -11,6 +11,7 @@ enum class SensorChannel {
Contact, Contact,
Motion, Motion,
AmbientTemperature, AmbientTemperature,
ChipTemperature, // SoC die temperature, e.g. ESP32-C6's internal sensor -- distinct from AmbientTemperature since the value range/meaning differ
Humidity, Humidity,
Pressure, Pressure,
AccelX, AccelX,

View File

@@ -1,5 +1,7 @@
#pragma once #pragma once
#include <cstdint>
namespace kvida { namespace kvida {
// Starts mDNS-based MQTT broker discovery (_mqtt._tcp) and, once found, // Starts mDNS-based MQTT broker discovery (_mqtt._tcp) and, once found,
@@ -12,4 +14,16 @@ void start_mqtt();
bool mqtt_connected(); 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 } // namespace kvida

View File

@@ -33,10 +33,19 @@ std::atomic<bool> g_connected{false};
esp_mqtt_client_handle_t g_client = nullptr; esp_mqtt_client_handle_t g_client = nullptr;
esp_timer_handle_t g_retry_timer = nullptr; esp_timer_handle_t g_retry_timer = nullptr;
httpd_handle_t g_settings_httpd = nullptr; httpd_handle_t g_settings_httpd = nullptr;
LedColorHandler g_led_handler = nullptr;
char g_availability_topic[48]; 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_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 // Remembered so a settings update can reconnect without re-running mDNS
// discovery. // discovery.
char g_broker_uri[96]; // "mqtt://" + up to 63-char host + ":" + port + NUL 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); 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<uint8_t>(r), static_cast<uint8_t>(g), static_cast<uint8_t>(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<esp_mqtt_event_id_t>(event_id)) { switch (static_cast<esp_mqtt_event_id_t>(event_id)) {
case MQTT_EVENT_CONNECTED: case MQTT_EVENT_CONNECTED:
ESP_LOGI(TAG, "MQTT connected"); ESP_LOGI(TAG, "MQTT connected");
g_connected.store(true); g_connected.store(true);
publish_discovery_and_birth(); publish_discovery_and_birth();
publish_led_discovery_and_subscribe();
break; break;
case MQTT_EVENT_DISCONNECTED: case MQTT_EVENT_DISCONNECTED:
ESP_LOGW(TAG, "MQTT disconnected"); ESP_LOGW(TAG, "MQTT disconnected");
g_connected.store(false); g_connected.store(false);
break; break;
case MQTT_EVENT_DATA: {
auto *event = static_cast<esp_mqtt_event_handle_t>(event_data);
handle_led_data(event->topic, event->topic_len, event->data, event->data_len);
break;
}
default: default:
break; break;
} }
@@ -411,4 +508,55 @@ bool mqtt_connected()
return g_connected.load(); 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<double>(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 } // namespace kvida

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 REQUIRES drivers sensors profiles transport lua_runtime esp_timer
) )

View File

@@ -1,14 +1,56 @@
#include "chip_temperature_sensor.h"
#include "commissioning.h" #include "commissioning.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_timer.h"
#include "mqtt.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_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)
{
kvida::drivers::rgb_led_set_color(red, green, blue);
}
} // namespace
extern "C" void app_main(void) 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
// 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 // 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
// gate needed here). Re-entering commissioning on an already-configured // gate needed here). Re-entering commissioning on an already-configured