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

View File

@@ -33,10 +33,19 @@ std::atomic<bool> 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<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)) {
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<esp_mqtt_event_handle_t>(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<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