Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@

---

## [ws_bridge](/components/ws_bridge)
**WebSocket Bridge to Home Assistant (no MQTT needed)**
- Connects directly to Home Assistant's `/api/websocket` and speaks the [`hass-ws-bridge`](https://github.com/eigger/hass-ws-bridge) protocol to declare `sensor`/`binary_sensor`/`switch`/`number`/`select`/`button` entities, push state, and receive commands — no MQTT broker required.
- ESP32 (ESP-IDF) only; uses ESP-IDF's `esp_websocket_client` for a secure `wss://` connection.

---

## [jaalee_jht](/components/jaalee_jht)
**Jaalee JHT Bluetooth Temperature and Humidity Sensor Component**
- Supports Jaalee JHT Bluetooth Temperature and Humidity Sensor.
Expand Down
137 changes: 137 additions & 0 deletions components/ws_bridge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# ws_bridge

An ESPHome external component for ESP32 (ESP-IDF only). It connects over a
secure WebSocket directly to Home Assistant's standard `/api/websocket`
endpoint and speaks the protocol of the
[`hass-ws-bridge`](https://github.com/eigger/hass-ws-bridge) custom
integration — declaring entities, pushing state, and receiving commands —
**without needing an MQTT broker**. Combined with a way to reach Home
Assistant securely from outside your LAN (e.g. Nabu Casa remote UI, or your
own reverse proxy with a valid certificate), it can also remove the need for
a VPN just to get a remote device's data into Home Assistant.

> Requires the `ws_bridge` custom component installed on the Home Assistant
> side: https://github.com/eigger/hass-ws-bridge (see its
> [PROTOCOL.md](https://github.com/eigger/hass-ws-bridge/blob/main/docs/PROTOCOL.md)
> for the full wire protocol).

## Installation

```yaml
external_components:
- source: github://eigger/espcomponents@latest
components: [ ws_bridge ]
refresh: always
```

`ws_bridge` requires the **ESP-IDF** framework (it pulls in ESP-IDF's
`esp_websocket_client` managed component):

```yaml
esp32:
board: esp32dev
framework:
type: esp-idf
```

## Configuration

```yaml
ws_bridge:
host: 192.168.0.10 # or your Nabu Casa / reverse-proxy hostname
port: 8123
ssl: true # wss:// (default). Set false only for LAN testing.
token: !secret ha_token # Home Assistant long-lived access token
gateway_id: my_esp # (default: this device's name)
name: "My ESP" # (default: this device's friendly_name)
keep_last_state_on_disconnect: false

on_connected:
- logger.log: "ws_bridge connected"
on_disconnected:
- logger.log: "ws_bridge disconnected"

sensor:
- platform: ws_bridge
unique_id: temp1
name: "Temperature"
device_class: temperature
unit_of_measurement: "°C"
state_class: measurement

binary_sensor:
- platform: ws_bridge
unique_id: motion1
name: "Motion"
device_class: motion

switch:
- platform: ws_bridge
unique_id: relay1
name: "Relay"

number:
- platform: ws_bridge
unique_id: setpoint1
name: "Setpoint"
min_value: 0
max_value: 100
step: 0.5

select:
- platform: ws_bridge
unique_id: mode1
name: "Mode"
options:
- "Auto"
- "Manual"

button:
- platform: ws_bridge
unique_id: restart1
name: "Restart"
```

### `ws_bridge` (hub) options

| Option | Required | Default | Description |
|--------|:--------:|---------|-------------|
| `host` | ✓ | - | Home Assistant address (IP, `.local` hostname, or a remote hostname such as Nabu Casa's) |
| `port` | | 8123 | Port |
| `ssl` | | `true` | Use `wss://`. Only disable for LAN-only testing — the access token is sent in plain text over `ws://` |
| `token` | ✓ | - | Home Assistant long-lived access token |
| `gateway_id` | | device name | Unique client identifier (becomes the HA gateway device) |
| `name` | | device friendly name | Display name for the gateway device |
| `keep_last_state_on_disconnect` | | `false` | If `true`, this gateway's entities keep their last state in HA instead of going `unavailable` when the connection drops (including an ungraceful disconnect) |

### Platform options (all of `sensor`/`binary_sensor`/`switch`/`number`/`select`/`button`)

| Option | Required | Description |
|--------|:--------:|-------------|
| `unique_id` | ✓ | Identifier for this entity, unique within the gateway |
| `ws_device_id` | | Groups this entity under a sub-device in HA (e.g. multiple sensors on one physical board) |
| `ws_device_name` | | Display name for that sub-device |

Plus each platform's normal ESPHome options (`name`, `device_class`, `icon`,
`entity_category`, `unit_of_measurement`/`state_class` for `sensor`,
`min_value`/`max_value`/`step` for `number`, `options` for `select`).

## Triggers

- `on_connected` — the WebSocket connected and Home Assistant accepted the connection
- `on_disconnected` — the connection was lost

## Behavior / Limitations

- **Read-only platforms** (`sensor`, `binary_sensor`) push their state to Home
Assistant automatically whenever it changes.
- **Controllable platforms** (`switch`, `number`, `select`, `button`) receive
commands from Home Assistant and update their own state optimistically
(`publish_state`/`this->state`) — hook `on_turn_on`/`lambda:`/etc. in your
own YAML if you need to drive real hardware from the state.
- On every (re)connect, all declared entities and their current state are
re-sent, per the protocol's reconnection guidance.
- TLS uses ESP-IDF's built-in public CA bundle — this works out of the box
with Nabu Casa or any certificate from a public CA. A custom CA
certificate (for self-signed setups) is not supported yet.
- Only one gateway connection per device is supported.
106 changes: 106 additions & 0 deletions components/ws_bridge/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome import automation
from esphome.components import esp32
from esphome.const import CONF_ID, CONF_PORT, CONF_NAME, CONF_TRIGGER_ID
from esphome.core import CORE

from .const import (
CONF_WS_BRIDGE_ID,
CONF_HOST,
CONF_SSL,
CONF_TOKEN,
CONF_GATEWAY_ID,
CONF_KEEP_LAST_STATE_ON_DISCONNECT,
CONF_UNIQUE_ID,
CONF_WS_DEVICE_ID,
CONF_WS_DEVICE_NAME,
CONF_ON_CONNECTED,
CONF_ON_DISCONNECTED,
)

CODEOWNERS = ["@eigger"]
DEPENDENCIES = ["network"]
AUTO_LOAD = ["json"]

ESP_WEBSOCKET_CLIENT_VERSION = "1.7.0"

ws_bridge_ns = cg.esphome_ns.namespace("ws_bridge")
WsBridgeComponent = ws_bridge_ns.class_("WsBridgeComponent", cg.Component)
WsBridgeDevice = ws_bridge_ns.class_("WsBridgeDevice")

ConnectedTrigger = ws_bridge_ns.class_("ConnectedTrigger", automation.Trigger.template())
DisconnectedTrigger = ws_bridge_ns.class_("DisconnectedTrigger", automation.Trigger.template())


def _validate_esp_idf(config):
if CORE.target_framework != "esp-idf":
raise cv.Invalid("ws_bridge requires the ESP-IDF framework (esp32: framework: type: esp-idf)")
return config


CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(WsBridgeComponent),
cv.Required(CONF_HOST): cv.string_strict,
cv.Optional(CONF_PORT, default=8123): cv.port,
cv.Optional(CONF_SSL, default=True): cv.boolean,
cv.Required(CONF_TOKEN): cv.string_strict,
cv.Optional(CONF_GATEWAY_ID, default=lambda: CORE.name): cv.string_strict,
cv.Optional(CONF_NAME, default=lambda: CORE.friendly_name or CORE.name): cv.string_strict,
cv.Optional(CONF_KEEP_LAST_STATE_ON_DISCONNECT, default=False): cv.boolean,
cv.Optional(CONF_ON_CONNECTED): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ConnectedTrigger)}
),
cv.Optional(CONF_ON_DISCONNECTED): automation.validate_automation(
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DisconnectedTrigger)}
),
}
).extend(cv.COMPONENT_SCHEMA),
_validate_esp_idf,
)

# Shared schema every ws_bridge platform (sensor/binary_sensor/switch/number/
# select/button) must extend, mirroring uartex's UARTEX_DEVICE_SCHEMA pattern.
WS_BRIDGE_DEVICE_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_WS_BRIDGE_ID): cv.use_id(WsBridgeComponent),
cv.Required(CONF_UNIQUE_ID): cv.string_strict,
cv.Optional(CONF_WS_DEVICE_ID): cv.string_strict,
cv.Optional(CONF_WS_DEVICE_NAME): cv.string_strict,
}
)


async def register_ws_bridge_device(var, config):
parent = await cg.get_variable(config[CONF_WS_BRIDGE_ID])
cg.add(var.set_ws_bridge_parent(parent))
cg.add(var.set_unique_id(config[CONF_UNIQUE_ID]))
if CONF_WS_DEVICE_ID in config:
cg.add(var.set_device_id(config[CONF_WS_DEVICE_ID]))
if CONF_WS_DEVICE_NAME in config:
cg.add(var.set_device_name(config[CONF_WS_DEVICE_NAME]))
cg.add(parent.register_device(var))


async def to_code(config):
esp32.add_idf_component(name="espressif/esp_websocket_client", ref=ESP_WEBSOCKET_CLIENT_VERSION)

var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)

cg.add(var.set_host(config[CONF_HOST]))
cg.add(var.set_port(config[CONF_PORT]))
cg.add(var.set_ssl(config[CONF_SSL]))
cg.add(var.set_token(config[CONF_TOKEN]))
cg.add(var.set_gateway_id(config[CONF_GATEWAY_ID]))
cg.add(var.set_gateway_name(config[CONF_NAME]))
cg.add(var.set_keep_last_state_on_disconnect(config[CONF_KEEP_LAST_STATE_ON_DISCONNECT]))

for conf in config.get(CONF_ON_CONNECTED, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_DISCONNECTED, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
23 changes: 23 additions & 0 deletions components/ws_bridge/automation.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#pragma once
#include "esphome/core/automation.h"
#include "ws_bridge.h"

namespace esphome {
namespace ws_bridge {

class ConnectedTrigger : public Trigger<> {
public:
explicit ConnectedTrigger(WsBridgeComponent *parent) {
parent->add_on_connected_callback([this]() { this->trigger(); });
}
};

class DisconnectedTrigger : public Trigger<> {
public:
explicit DisconnectedTrigger(WsBridgeComponent *parent) {
parent->add_on_disconnected_callback([this]() { this->trigger(); });
}
};

} // namespace ws_bridge
} // namespace esphome
22 changes: 22 additions & 0 deletions components/ws_bridge/binary_sensor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import binary_sensor, ws_bridge

from .. import ws_bridge_ns

DEPENDENCIES = ["ws_bridge"]
WsBridgeBinarySensor = ws_bridge_ns.class_(
"WsBridgeBinarySensor", binary_sensor.BinarySensor, cg.Component, ws_bridge.WsBridgeDevice
)

CONFIG_SCHEMA = (
binary_sensor.binary_sensor_schema(WsBridgeBinarySensor)
.extend(ws_bridge.WS_BRIDGE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
)


async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
await ws_bridge.register_ws_bridge_device(var, config)
24 changes: 24 additions & 0 deletions components/ws_bridge/binary_sensor/ws_bridge_binary_sensor.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#include "ws_bridge_binary_sensor.h"
#include "../ws_bridge.h"
#include "../ws_bridge_entity_json.h"

namespace esphome {
namespace ws_bridge {

static const char *const TAG = "ws_bridge.binary_sensor";

void WsBridgeBinarySensor::setup() {
this->add_on_state_callback([this](bool state) { this->parent_->send_state_bool(this->unique_id_, state); });
}

void WsBridgeBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "WS Bridge Binary Sensor", this); }

void WsBridgeBinarySensor::ws_bridge_declare() {
this->parent_->send_entity_declare(this->unique_id_, "binary_sensor", this->get_name().str(), this->device_id_,
this->device_name_,
[this](JsonObject root) { add_common_entity_fields(root, *this); });
if (this->has_state()) this->parent_->send_state_bool(this->unique_id_, this->state);
}

} // namespace ws_bridge
} // namespace esphome
17 changes: 17 additions & 0 deletions components/ws_bridge/binary_sensor/ws_bridge_binary_sensor.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#pragma once
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/core/component.h"
#include "../ws_bridge_device.h"

namespace esphome {
namespace ws_bridge {

class WsBridgeBinarySensor : public binary_sensor::BinarySensor, public Component, public WsBridgeDevice {
public:
void setup() override;
void dump_config() override;
void ws_bridge_declare() override;
};

} // namespace ws_bridge
} // namespace esphome
20 changes: 20 additions & 0 deletions components/ws_bridge/button/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import button, ws_bridge

from .. import ws_bridge_ns

DEPENDENCIES = ["ws_bridge"]
WsBridgeButton = ws_bridge_ns.class_("WsBridgeButton", button.Button, cg.Component, ws_bridge.WsBridgeDevice)

CONFIG_SCHEMA = (
button.button_schema(WsBridgeButton)
.extend(ws_bridge.WS_BRIDGE_DEVICE_SCHEMA)
.extend(cv.COMPONENT_SCHEMA)
)


async def to_code(config):
var = await button.new_button(config)
await cg.register_component(var, config)
await ws_bridge.register_ws_bridge_device(var, config)
23 changes: 23 additions & 0 deletions components/ws_bridge/button/ws_bridge_button.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#include "ws_bridge_button.h"
#include "../ws_bridge.h"
#include "../ws_bridge_entity_json.h"

namespace esphome {
namespace ws_bridge {

static const char *const TAG = "ws_bridge.button";

void WsBridgeButton::dump_config() { LOG_BUTTON("", "WS Bridge Button", this); }

void WsBridgeButton::ws_bridge_handle_command(const WsCommand &command) {
if (command.action == "press") this->press();
}

void WsBridgeButton::ws_bridge_declare() {
this->parent_->send_entity_declare(this->unique_id_, "button", this->get_name().str(), this->device_id_,
this->device_name_,
[this](JsonObject root) { add_common_entity_fields(root, *this); });
}

} // namespace ws_bridge
} // namespace esphome
Loading