[core] Add relation option to sub-devices (#21232)

This commit is contained in:
Jesse Hills
2026-10-22 00:27:38 +00:00
committed by GitHub
parent c26967f623
commit 07f24708b4
32 changed files with 269 additions and 0 deletions
+6
View File
@@ -227,11 +227,17 @@ message AreaInfo {
string name = 2 [(max_data_length) = 120, (force) = true];
}
enum DeviceRelation {
DEVICE_RELATION_VIA = 0;
DEVICE_RELATION_CHILD = 1;
}
message DeviceInfo {
uint32 device_id = 1;
// max_data_length matches core/config.FRIENDLY_NAME_MAX_LEN via DEVICE_SCHEMA
string name = 2 [(max_data_length) = 120, (force) = true];
uint32 area_id = 3;
DeviceRelation relation = 4;
}
enum SerialProxyPortType {
@@ -24,6 +24,7 @@
#endif
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
#include "esphome/core/device.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
@@ -2055,6 +2056,7 @@ bool APIConnection::send_device_info_response_() {
device_info.device_id = device->get_device_id();
device_info.name = StringRef(device->get_name());
device_info.area_id = device->get_area_id();
device_info.relation = static_cast<enums::DeviceRelation>(device->get_relation());
}
#endif
#ifdef USE_AREAS
+2
View File
@@ -85,6 +85,7 @@ uint8_t *DeviceInfo::encode_msg(const void *self, uint8_t *__restrict__ pos PROT
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, msg.device_id);
pos = ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, msg.name);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.area_id);
pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, static_cast<uint32_t>(msg.relation));
return pos;
}
uint32_t DeviceInfo::calc_size_msg(const void *self) {
@@ -93,6 +94,7 @@ uint32_t DeviceInfo::calc_size_msg(const void *self) {
size += ProtoSize::calc_uint32(1, msg.device_id);
size += 2 + msg.name.size();
size += ProtoSize::calc_uint32(1, msg.area_id);
size += msg.relation ? 2 : 0;
return size;
}
#endif
+5
View File
@@ -19,6 +19,10 @@ enum DisconnectReason : uint32_t {
DISCONNECT_REASON_UNSPECIFIED = 0,
DISCONNECT_REASON_PROVISIONING_CLOSED = 1,
};
enum DeviceRelation : uint32_t {
DEVICE_RELATION_VIA = 0,
DEVICE_RELATION_CHILD = 1,
};
enum SerialProxyPortType : uint32_t {
SERIAL_PROXY_PORT_TYPE_TTL = 0,
SERIAL_PROXY_PORT_TYPE_RS232 = 1,
@@ -567,6 +571,7 @@ class DeviceInfo final : public ProtoMessage {
uint32_t device_id{0};
StringRef name{};
uint32_t area_id{0};
enums::DeviceRelation relation{};
static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM);
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG);
+11
View File
@@ -154,6 +154,16 @@ template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::Disc
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::DeviceRelation>(enums::DeviceRelation value) {
switch (value) {
case enums::DEVICE_RELATION_VIA:
return ESPHOME_PSTR("DEVICE_RELATION_VIA");
case enums::DEVICE_RELATION_CHILD:
return ESPHOME_PSTR("DEVICE_RELATION_CHILD");
default:
return ESPHOME_PSTR("UNKNOWN");
}
}
template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::SerialProxyPortType value) {
switch (value) {
case enums::SERIAL_PROXY_PORT_TYPE_TTL:
@@ -1005,6 +1015,7 @@ const char *DeviceInfo::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
dump_field(out, ESPHOME_PSTR("name"), this->name);
dump_field(out, ESPHOME_PSTR("area_id"), this->area_id);
dump_field(out, ESPHOME_PSTR("relation"), static_cast<enums::DeviceRelation>(this->relation));
return out.c_str();
}
#endif
+29
View File
@@ -69,6 +69,7 @@ from esphome.helpers import (
from esphome.types import ConfigType
CONF_LOOP_INTERVAL = "loop_interval"
CONF_RELATION = "relation"
CONF_SUSPEND_LOOP = "suspend_loop"
_LOGGER = logging.getLogger(__name__)
@@ -118,6 +119,17 @@ ProjectUpdateTrigger = cg.esphome_ns.class_(
"ProjectUpdateTrigger", cg.Component, automation.Trigger.template(cg.std_string)
)
Device = cg.esphome_ns.class_("Device")
DeviceRelation = cg.esphome_ns.enum("DeviceRelation", is_class=True)
DEVICE_RELATIONS: dict[str, cg.MockObj] = DeviceRelation.options(
"via", "child", prefix="DEVICE_RELATION_"
)
# Relation the C++ getter and field use when nothing sets one, and the API's default
DEFAULT_DEVICE_RELATION: str = "via"
# Define for a relation shared by every device
SHARED_DEVICE_RELATION_DEFINES: dict[str, str | None] = {
DEFAULT_DEVICE_RELATION: None,
"child": "USE_DEVICE_RELATION_ALL_CHILD",
}
Area = cg.esphome_ns.class_("Area")
VALID_INCLUDE_EXTS = {".h", ".hpp", ".tcc", ".ino", ".cpp", ".c"}
@@ -295,6 +307,7 @@ DEVICE_SCHEMA = cv.Schema(
cv.string_no_slash, cv.ByteLength(max=FRIENDLY_NAME_MAX_LEN)
),
cv.Optional(CONF_AREA_ID): cv.use_id(Area),
cv.Required(CONF_RELATION): cv.enum(DEVICE_RELATIONS, lower=True),
}
)
@@ -943,6 +956,15 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_DEVICES")
cg.add_define("ESPHOME_DEVICE_COUNT", len(devices))
# When every device shares one relation the getter returns it as a constant,
# so only a mix of relations needs a per-device field
relations: set[str] = {dev_conf[CONF_RELATION] for dev_conf in devices}
mixed_relations: bool = len(relations) > 1
if mixed_relations:
cg.add_define("USE_DEVICE_RELATION")
elif (shared_define := SHARED_DEVICE_RELATION_DEFINES[relations.pop()]) is not None:
cg.add_define(shared_define)
# Process each device
for dev_conf in devices:
device_id: core.ID = dev_conf[CONF_ID]
@@ -959,6 +981,13 @@ async def to_code(config: ConfigType) -> None:
area_id_hash = fnv1a_32bit_hash(area_id.id)
cg.add(dev.set_area_id(area_id_hash))
# The field only exists for a mix of relations, and starts as VIA
if (
mixed_relations
and (relation := dev_conf[CONF_RELATION]) != DEFAULT_DEVICE_RELATION
):
cg.add(dev.set_relation(relation))
cg.add(cg.App.register_device(dev))
+1
View File
@@ -70,6 +70,7 @@
#define USE_DEEP_SLEEP
#define USE_DEEP_SLEEP_ON_WAKE
#define USE_DEVICES
#define USE_DEVICE_RELATION
#define USE_DFROBOT_SEN0465_TEMPERATURE_COMPENSATION
#define DFROBOT_SEN0465_COMPENSATION_RANGES 3
#define USE_DISPLAY
+24
View File
@@ -1,7 +1,19 @@
#pragma once
#include <cstdint>
#include "esphome/core/defines.h"
namespace esphome {
/// How a sub-device relates to the node that exposes it. Values match the API's DeviceRelation enum.
enum class DeviceRelation : uint8_t {
/// A separate physical device reached through this node (for example over RF, UART or ESP-NOW).
DEVICE_RELATION_VIA = 0,
/// A logical part of this node (for example one zone of a multi-zone controller).
DEVICE_RELATION_CHILD = 1,
};
class Device {
public:
void set_device_id(uint32_t device_id) { this->device_id_ = device_id; }
@@ -10,11 +22,23 @@ class Device {
const char *get_name() { return this->name_; }
void set_area_id(uint32_t area_id) { this->area_id_ = area_id; }
uint32_t get_area_id() { return this->area_id_; }
#if defined(USE_DEVICE_RELATION)
// Only emitted when devices have different relations; otherwise the getter is a constant and costs no RAM.
void set_relation(DeviceRelation relation) { this->relation_ = relation; }
DeviceRelation get_relation() const { return this->relation_; }
#elif defined(USE_DEVICE_RELATION_ALL_CHILD)
DeviceRelation get_relation() const { return DeviceRelation::DEVICE_RELATION_CHILD; }
#else
DeviceRelation get_relation() const { return DeviceRelation::DEVICE_RELATION_VIA; }
#endif
protected:
uint32_t device_id_{};
uint32_t area_id_{};
const char *name_ = "";
#ifdef USE_DEVICE_RELATION
DeviceRelation relation_{DeviceRelation::DEVICE_RELATION_VIA};
#endif
};
} // namespace esphome
@@ -49,63 +49,83 @@ esphome:
- id: device_1
name: "Device 1"
area_id: area_1
relation: via
- id: device_2
name: "Device 2"
area_id: area_2
relation: via
- id: device_3
name: "Device 3"
area_id: area_3
relation: via
- id: device_4
name: "Device 4"
area_id: area_4
relation: via
- id: device_5
name: "Device 5"
area_id: area_5
relation: via
- id: device_6
name: "Device 6"
area_id: area_6
relation: via
- id: device_7
name: "Device 7"
area_id: area_7
relation: via
- id: device_8
name: "Device 8"
area_id: area_8
relation: via
- id: device_9
name: "Device 9"
area_id: area_9
relation: via
- id: device_10
name: "Device 10"
area_id: area_10
relation: via
- id: device_11
name: "Device 11"
area_id: area_11
relation: via
- id: device_12
name: "Device 12"
area_id: area_12
relation: via
- id: device_13
name: "Device 13"
area_id: area_13
relation: via
- id: device_14
name: "Device 14"
area_id: area_14
relation: via
- id: device_15
name: "Device 15"
area_id: area_15
relation: via
- id: device_16
name: "Device 16"
area_id: area_16
relation: via
- id: device_17
name: "Device 17"
area_id: area_17
relation: via
- id: device_18
name: "Device 18"
area_id: area_18
relation: via
- id: device_19
name: "Device 19"
area_id: area_19
relation: via
- id: device_20
name: "Device 20"
area_id: area_20
relation: via
api:
encryption:
+1
View File
@@ -686,6 +686,7 @@ DEVICES_HEADER = """
devices:
- id: kitchen_dev
name: Kitchen
relation: via
esp32:
variant: esp32
+3
View File
@@ -37,11 +37,14 @@ esphome:
- id: other_device
name: Another device
area_id: another_area
relation: child
- id: test_device
name: Test device in main area
area_id: testing_area # Reference the main area (not in areas)
relation: child
- id: no_area_device
name: Device without area # This device has no area_id
relation: child
binary_sensor:
- platform: template
+1
View File
@@ -351,6 +351,7 @@ esphome:
- id: my_device
name: "Test Device"
area_id: living_room
relation: via
# API services
api:
@@ -15,15 +15,19 @@ esphome:
- id: light_controller_device
name: Light Controller
area_id: living_room_area # Uses top-level area
relation: via
- id: temp_sensor_device
name: Temperature Sensor
area_id: bedroom_area
relation: via
- id: motion_detector_device
name: Motion Detector
area_id: living_room_area # Reuses top-level area
relation: child
- id: smart_switch_device
name: Smart Switch
area_id: kitchen_area
relation: child
host:
api:
@@ -11,12 +11,15 @@ esphome:
- id: temperature_monitor
name: Temperature Monitor
area_id: living_room
relation: via
- id: humidity_monitor
name: Humidity Monitor
area_id: bedroom
relation: via
- id: motion_sensor
name: Motion Sensor
area_id: living_room
relation: via
host:
api:
@@ -4,10 +4,13 @@ esphome:
devices:
- id: controller_1
name: Controller 1
relation: via
- id: controller_2
name: Controller 2
relation: via
- id: controller_3
name: Controller 3
relation: via
host:
api: # Port will be automatically injected
@@ -4,8 +4,10 @@ esphome:
devices:
- id: device_a
name: Device A
relation: via
- id: device_b
name: Device B
relation: via
host:
api: # Port will be automatically injected
@@ -8,8 +8,10 @@ esphome:
devices:
- id: sub_device_1
name: Sub Device One
relation: via
- id: sub_device_2
name: Sub Device Two
relation: via
host:
@@ -9,9 +9,11 @@ esphome:
- id: sub_device_one
name: Sub Device One
area_id: test_area
relation: via
- id: sub_device_two
name: Sub Device Two
area_id: test_area
relation: via
host:
api:
@@ -69,6 +69,11 @@ async def test_areas_and_devices(
"Smart Switch should be in Kitchen"
)
# TODO: once aioesphomeapi with SubDeviceInfo.relation is pinned
# (esphome/aioesphomeapi#1955), assert the relations: Light Controller
# and Temperature Sensor VIA, Motion Detector and Smart Switch
# CHILD. The fixture mixes them so the per-device field path is built.
# Verify suggested_area is set to the top-level area name
assert device_info.suggested_area == "Living Room", (
f"Expected suggested_area to be 'Living Room', got '{device_info.suggested_area}'"
+80
View File
@@ -28,7 +28,10 @@ from esphome.const import (
)
from esphome.core import CORE, KEY_CONTROLLER_REGISTRY_CONTROLLERS, config
from esphome.core.config import (
CONF_RELATION,
CONF_SUSPEND_LOOP,
DEVICE_RELATIONS,
SHARED_DEVICE_RELATION_DEFINES,
Area,
make_app_name_cpp,
preload_core_config,
@@ -364,6 +367,83 @@ def test_device_without_area(yaml_file: Callable[[str], str]) -> None:
assert "area_id" not in device
def test_device_relation(yaml_file: Callable[[str], str]) -> None:
"""Test that device relation accepts via and child in any case."""
result = load_config_from_fixture(yaml_file, "device_relation.yaml", FIXTURES_DIR)
assert result is not None
devices = result[CONF_ESPHOME][CONF_DEVICES]
assert [device[CONF_RELATION] for device in devices] == ["via", "via", "child"]
def test_device_without_relation(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that a device without a relation fails validation."""
result = load_config_from_fixture(
yaml_file, "device_missing_relation.yaml", FIXTURES_DIR
)
assert result is None
captured = capsys.readouterr()
assert "'relation' is a required option" in captured.out
def test_device_with_invalid_relation(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Test that an unknown device relation fails validation."""
result = load_config_from_fixture(
yaml_file, "device_invalid_relation.yaml", FIXTURES_DIR
)
assert result is None
captured = capsys.readouterr()
assert "Unknown value 'parent', valid options are 'via', 'child'" in captured.out
def test_shared_device_relation_defines_cover_all_relations() -> None:
"""Every relation needs an entry, so a new one cannot silently report VIA."""
assert SHARED_DEVICE_RELATION_DEFINES.keys() == DEVICE_RELATIONS.keys()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("fixture", "expected_relations", "expected_define"),
[
("device_relation.yaml", ["DEVICE_RELATION_CHILD"], "USE_DEVICE_RELATION"),
("device_child_only.yaml", [], "USE_DEVICE_RELATION_ALL_CHILD"),
("device_via_only.yaml", [], None),
],
)
async def test_device_relation_to_code(
yaml_file: Callable[[str], Path],
fixture: str,
expected_relations: list[str],
expected_define: str | None,
) -> None:
"""A shared relation becomes a define; only a mix needs per-device setters."""
result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR)
assert result is not None
with patch("esphome.core.config.cg") as mock_cg:
mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock()
mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock()
await config.to_code(result[CONF_ESPHOME])
set_relation = mock_cg.new_Pvariable.return_value.set_relation
assert [
str(c.args[0].enum_value).rsplit("::", 1)[-1]
for c in set_relation.call_args_list
] == expected_relations
relation_defines = [
c.args[0]
for c in mock_cg.add_define.call_args_list
if c.args[0].startswith("USE_DEVICE_RELATION")
]
assert relation_defines == ([expected_define] if expected_define else [])
def test_device_with_invalid_area_id(
yaml_file: Callable[[str], str], capsys: pytest.CaptureFixture[str]
) -> None:
@@ -0,0 +1,11 @@
esphome:
name: test-device-child-only
devices:
- id: zone_1_device
name: Zone 1
relation: child
- id: zone_2_device
name: Zone 2
relation: child
host:
@@ -3,8 +3,10 @@ esphome:
devices:
- id: duplicate_device
name: "Device 1"
relation: via
- id: duplicate_device
name: "Device 2"
relation: via
esp32:
board: esp32dev
@@ -3,8 +3,10 @@ esphome:
devices:
- id: test_2258
name: "Device 1"
relation: via
- id: d6ka
name: "Device 2"
relation: via
esp32:
board: esp32dev
@@ -7,6 +7,7 @@ esphome:
- id: test_device
name: "Test Device"
area_id: nonexistent_area
relation: via
esp32:
board: esp32dev
@@ -0,0 +1,8 @@
esphome:
name: test-device-invalid-relation
devices:
- id: test_device
name: Test Device
relation: parent
host:
@@ -0,0 +1,7 @@
esphome:
name: test-device-missing-relation
devices:
- id: test_device
name: Test Device
host:
@@ -0,0 +1,14 @@
esphome:
name: test-device-relation
devices:
- id: modbus_meter_device
name: Modbus Meter
relation: via
- id: rf_sensor_device
name: RF Sensor
relation: via
- id: zone_device
name: Zone 1
relation: CHILD
host:
@@ -0,0 +1,11 @@
esphome:
name: test-device-via-only
devices:
- id: modbus_meter_device
name: Modbus Meter
relation: via
- id: rf_sensor_device
name: RF Sensor
relation: via
host:
@@ -3,5 +3,6 @@ esphome:
devices:
- id: test_device
name: Test Device
relation: via
host:
@@ -12,11 +12,14 @@ esphome:
- id: device1
name: Device 1
area_id: main_area
relation: via
- id: device2
name: Device 2
area_id: area1
relation: via
- id: device3
name: Device 3
area_id: area2
relation: via
host:
@@ -7,5 +7,6 @@ esphome:
- id: test_device
name: Test Device
area_id: bedroom_area
relation: via
host:
@@ -3,8 +3,10 @@ esphome:
devices:
- id: device1
name: "Device 1"
relation: via
- id: device2
name: "Device 2"
relation: via
esp32:
board: esp32dev