From e397377993455b78a5a40dcf0d885b47acb1f17e Mon Sep 17 00:00:00 2001 From: eigger Date: Tue, 4 Aug 2026 08:31:48 +0900 Subject: [PATCH] feat(ws_bridge): support declaring entity types ESPHome has no domain for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home Assistant supports entity types ESPHome has no platform for — the immediate one being device_tracker (GPS location). Since there's no ESPHome device_tracker: domain to hang a `platform: ws_bridge` off, these have to be declared by calling the protocol directly from a lambda. Two things were missing for that: - send_state_object(), for states that aren't a single scalar (device_tracker carries latitude+longitude together). - an on_declare trigger. Declarations are re-sent both on connect and on every periodic re-announce, but on_connected only fires for the former — so a hand-built declaration hooked there would be silently left behind by a re-announce that heals a lost HA-side registration, which is precisely what the re-announce exists to fix. on_declare fires from declare_all_entities_() itself, keeping manual declarations in lockstep with the registered platform entities. Nothing here is device_tracker-specific: any future HA platform is reachable from YAML without further firmware changes. --- components/ws_bridge/README.md | 49 +++++++++++++++++++ components/ws_bridge/__init__.py | 14 ++++++ components/ws_bridge/automation.h | 9 ++++ components/ws_bridge/const.py | 1 + components/ws_bridge/ws_bridge.cpp | 10 ++++ components/ws_bridge/ws_bridge.h | 12 +++++ components/ws_bridge/ws_protocol.cpp | 13 +++++ components/ws_bridge/ws_protocol.h | 6 +++ .../components/ws_bridge/test.esp32-idf.yaml | 10 ++++ 9 files changed, 124 insertions(+) diff --git a/components/ws_bridge/README.md b/components/ws_bridge/README.md index 4cf6d0ed..178bbde7 100644 --- a/components/ws_bridge/README.md +++ b/components/ws_bridge/README.md @@ -191,6 +191,55 @@ build/dashboard tooling produces): - `on_connected` — the WebSocket connected and Home Assistant accepted the connection - `on_disconnected` — the connection was lost +- `on_declare` — entity declarations are being (re)sent: on connect **and** on + every periodic re-announce. Use this for hand-built declarations (see below), + not `on_connected`. + +## Declaring entity types ESPHome has no domain for + +Home Assistant supports entity types ESPHome itself has no platform for — the +main one being `device_tracker` (GPS location). Since there's no ESPHome +`device_tracker:` domain to hang a `platform: ws_bridge` off, declare these by +calling the protocol directly from a lambda: + +```yaml +ws_bridge: + id: my_ws_bridge + host: !secret ha_address + token: !secret ha_token + + on_declare: + - lambda: |- + id(my_ws_bridge)->send_entity_declare( + "car_location", "device_tracker", "Car Location", "", "", nullptr); + +interval: + - interval: 30s + then: + - lambda: |- + id(my_ws_bridge)->send_state_object("car_location", [](JsonObject v) { + v["latitude"] = id(my_gps).latitude; + v["longitude"] = id(my_gps).longitude; + v["gps_accuracy"] = 8; + }); +``` + +- **Declare from `on_declare:`, not `on_connected:`.** Declarations are re-sent + both on connect and on each periodic re-announce (see below); `on_connected` + only covers the former, so a re-announce that heals a lost Home Assistant-side + registration would silently leave your hand-built entity behind. +- `send_state_object()` is for states that aren't a single scalar — + `device_tracker` needs latitude and longitude together. For ordinary values + use `send_state_float()` / `send_state_bool()` / `send_state_string()`. +- `send_entity_declare()`'s last argument adds platform-specific declare fields; + pass `nullptr` when there are none, or a lambda taking a `JsonObject` (that's + how `select` sends its `options`, `number` its `min`/`max`/`step`, and so on). +- Everything is a plain no-op while disconnected, so these are safe to call from + any interval or trigger. + +See the integration's +[PROTOCOL.md](https://github.com/eigger/hass-ws-bridge/blob/main/docs/PROTOCOL.md) +for the full set of declarable platforms and their fields. ## Behavior / Limitations diff --git a/components/ws_bridge/__init__.py b/components/ws_bridge/__init__.py index 990a4889..62915c11 100644 --- a/components/ws_bridge/__init__.py +++ b/components/ws_bridge/__init__.py @@ -17,6 +17,7 @@ CONF_WS_DEVICE_NAME, CONF_ON_CONNECTED, CONF_ON_DISCONNECTED, + CONF_ON_DECLARE, CONF_PING_INTERVAL, CONF_PONG_TIMEOUT, CONF_RECONNECT_TIMEOUT, @@ -35,6 +36,7 @@ ConnectedTrigger = ws_bridge_ns.class_("ConnectedTrigger", automation.Trigger.template()) DisconnectedTrigger = ws_bridge_ns.class_("DisconnectedTrigger", automation.Trigger.template()) +DeclareTrigger = ws_bridge_ns.class_("DeclareTrigger", automation.Trigger.template()) def _validate_esp_idf(config): @@ -82,6 +84,15 @@ def _validate_esp_idf(config): cv.Optional(CONF_ON_DISCONNECTED): automation.validate_automation( {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DisconnectedTrigger)} ), + # Fires alongside the registered platform entities' own + # re-declaration — on connect and on every re-announce. This is + # where hand-built send_entity_declare() calls belong (e.g. for + # entity types ESPHome has no domain for, like device_tracker); + # on_connected would skip the re-announce and leave them missing + # after HA-side registration is healed. + cv.Optional(CONF_ON_DECLARE): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DeclareTrigger)} + ), } ).extend(cv.COMPONENT_SCHEMA), _validate_esp_idf, @@ -134,3 +145,6 @@ async def to_code(config): for conf in config.get(CONF_ON_DISCONNECTED, []): trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) await automation.build_automation(trigger, [], conf) + for conf in config.get(CONF_ON_DECLARE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) diff --git a/components/ws_bridge/automation.h b/components/ws_bridge/automation.h index 677d5540..6e57405f 100644 --- a/components/ws_bridge/automation.h +++ b/components/ws_bridge/automation.h @@ -19,5 +19,14 @@ class DisconnectedTrigger : public Trigger<> { } }; +// Fires whenever entity declarations are (re)sent: on connect and on every +// periodic re-announce. Hook manual send_entity_declare() calls here. +class DeclareTrigger : public Trigger<> { + public: + explicit DeclareTrigger(WsBridgeComponent *parent) { + parent->add_on_declare_callback([this]() { this->trigger(); }); + } +}; + } // namespace ws_bridge } // namespace esphome diff --git a/components/ws_bridge/const.py b/components/ws_bridge/const.py index 77e799dc..034c058e 100644 --- a/components/ws_bridge/const.py +++ b/components/ws_bridge/const.py @@ -14,6 +14,7 @@ CONF_ON_CONNECTED = "on_connected" CONF_ON_DISCONNECTED = "on_disconnected" +CONF_ON_DECLARE = "on_declare" CONF_PING_INTERVAL = "ping_interval" CONF_PONG_TIMEOUT = "pong_timeout" diff --git a/components/ws_bridge/ws_bridge.cpp b/components/ws_bridge/ws_bridge.cpp index 4f35e00a..a54f9d99 100644 --- a/components/ws_bridge/ws_bridge.cpp +++ b/components/ws_bridge/ws_bridge.cpp @@ -289,6 +289,10 @@ void WsBridgeComponent::route_command_(const WsCommand &command) { void WsBridgeComponent::declare_all_entities_() { for (auto *device : this->devices_) device->ws_bridge_declare(); + // Manual (lambda-built) declarations piggyback here rather than on + // on_connected, so they're re-sent by the periodic re-announce too — see + // add_on_declare_callback(). + this->declare_cb_.call(); } void WsBridgeComponent::send_raw_(const std::string &msg) { @@ -319,5 +323,11 @@ void WsBridgeComponent::send_state_string(const std::string &unique_id, const st this->send_raw_(build_state_string(this->next_id_(), unique_id, value)); } +void WsBridgeComponent::send_state_object(const std::string &unique_id, + const std::function &value_fn) { + if (!this->is_connected()) return; + this->send_raw_(build_state_object(this->next_id_(), unique_id, value_fn)); +} + } // namespace ws_bridge } // namespace esphome diff --git a/components/ws_bridge/ws_bridge.h b/components/ws_bridge/ws_bridge.h index d216751a..4d06bef6 100644 --- a/components/ws_bridge/ws_bridge.h +++ b/components/ws_bridge/ws_bridge.h @@ -59,6 +59,11 @@ class WsBridgeComponent : public Component { void add_on_connected_callback(std::function &&cb) { this->connected_cb_.add(std::move(cb)); } void add_on_disconnected_callback(std::function &&cb) { this->disconnected_cb_.add(std::move(cb)); } + // Fires wherever the registered platform entities re-declare themselves — + // i.e. on connect AND on every periodic re-announce. Manual (lambda-built) + // declarations must hook this rather than on_connected, so that a + // re-announce which heals a lost HA-side registration re-declares them too. + void add_on_declare_callback(std::function &&cb) { this->declare_cb_.add(std::move(cb)); } void setup() override; void loop() override; @@ -75,9 +80,15 @@ class WsBridgeComponent : public Component { // Called by platform entities (via WsBridgeDevice helpers) to push state // and declarations. No-ops while not connected; the next (re)connect will // re-declare and re-push through ws_bridge_declare(). + // + // These are also the escape hatch for declaring entity types ESPHome itself + // has no domain for (e.g. device_tracker): call them straight from a YAML + // lambda. Manually declared entities are NOT re-declared automatically on + // reconnect — drive them from the hub's on_connected: trigger. See README. void send_state_float(const std::string &unique_id, float value); void send_state_bool(const std::string &unique_id, bool value); void send_state_string(const std::string &unique_id, const std::string &value); + void send_state_object(const std::string &unique_id, const std::function &value_fn); void send_entity_declare(const std::string &unique_id, const std::string &platform, const std::string &name, const std::string &device_id, const std::string &device_name, const std::function &extra); @@ -183,6 +194,7 @@ class WsBridgeComponent : public Component { CallbackManager connected_cb_{}; CallbackManager disconnected_cb_{}; + CallbackManager declare_cb_{}; }; } // namespace ws_bridge diff --git a/components/ws_bridge/ws_protocol.cpp b/components/ws_bridge/ws_protocol.cpp index 8279ef83..f3860542 100644 --- a/components/ws_bridge/ws_protocol.cpp +++ b/components/ws_bridge/ws_protocol.cpp @@ -115,5 +115,18 @@ std::string build_state_string(uint32_t id, const std::string &unique_id, const }); } +std::string build_state_object(uint32_t id, const std::string &unique_id, + const std::function &value_fn) { + return json::build_json([&](JsonObject root) { + root["id"] = id; + root["type"] = "ws_bridge/state"; + JsonArray states = root["states"].to(); + JsonObject item = states.add(); + item["unique_id"] = unique_id; + JsonObject value = item["value"].to(); + if (value_fn) value_fn(value); + }); +} + } // namespace ws_bridge } // namespace esphome diff --git a/components/ws_bridge/ws_protocol.h b/components/ws_bridge/ws_protocol.h index 79f23499..e4decb09 100644 --- a/components/ws_bridge/ws_protocol.h +++ b/components/ws_bridge/ws_protocol.h @@ -53,5 +53,11 @@ std::string build_state_float(uint32_t id, const std::string &unique_id, float v std::string build_state_bool(uint32_t id, const std::string &unique_id, bool value); std::string build_state_string(uint32_t id, const std::string &unique_id, const std::string &value); +// State whose `value` is a JSON object rather than a scalar — for platforms +// that need several fields at once (device_tracker's latitude+longitude). +// `value_fn` is called with the (empty) value object to fill in. +std::string build_state_object(uint32_t id, const std::string &unique_id, + const std::function &value_fn); + } // namespace ws_bridge } // namespace esphome diff --git a/tests/components/ws_bridge/test.esp32-idf.yaml b/tests/components/ws_bridge/test.esp32-idf.yaml index d4845d06..0ae65d1c 100644 --- a/tests/components/ws_bridge/test.esp32-idf.yaml +++ b/tests/components/ws_bridge/test.esp32-idf.yaml @@ -19,6 +19,7 @@ wifi: logger: ws_bridge: + id: my_ws_bridge host: 192.168.0.10 token: "test_token" gateway_id: my_esp @@ -28,6 +29,15 @@ ws_bridge: - logger.log: "ws_bridge connected" on_disconnected: - logger.log: "ws_bridge disconnected" + # Hand-built declaration for an entity type ESPHome has no domain for. + on_declare: + - lambda: |- + id(my_ws_bridge)->send_entity_declare("car_location", "device_tracker", "Car Location", "", "", nullptr); + id(my_ws_bridge)->send_state_object("car_location", [](JsonObject v) { + v["latitude"] = 37.5665; + v["longitude"] = 126.9780; + v["gps_accuracy"] = 8; + }); sensor: - platform: ws_bridge