diff --git a/examples/multi-device/README.md b/examples/multi-device/README.md index 93f9dcf..26035a4 100644 --- a/examples/multi-device/README.md +++ b/examples/multi-device/README.md @@ -16,9 +16,47 @@ transport profile. The API profile clears a Storage latch left by the MQTT profile and keeps the common low-battery latch. Keep credentials in the local `secrets.yaml`; remote package files contain no secrets. -The MQTT example preserves an explicit topic prefix. Treat that prefix as a -stable identity after Home Assistant discovery, because changing it creates new -entities. +## Identity and migration + +New installations use function-only entity names and MAC-based MQTT discovery +identifiers. `display_name` sets the human device label. It defaults to the +existing `friendly_name` substitution for compatibility. Changing that label +does not change the configured name or the entity-name hash. The configured +name defaults to `device_name`, with ESPHome's MAC suffix enabled. The default +MQTT topic prefix follows this effective node name. + +For duplicate plant species, set a unique `configured_name` before building. +To preserve an existing MAC-bearing name exactly, set that complete name and +`name_add_mac_suffix: "false"`. Never change a deployed technical identity as +part of a display-only rename. + +**Installed devices require an explicit migration.** These new defaults change +legacy entity names and MQTT discovery unique IDs. HA can create new entities; +this PR does not migrate its registry, automations, dashboards or history. +Keep the previous package revision until you have planned that migration. +Preserve your existing command topics, and remove old retained discovery only +as part of your own reviewed migration. Test one device first. + +To keep the previous package behavior while upgrading, override the named +entities in your device YAML (ESPHome merges them by `id`) and preserve the MQTT +settings explicitly. For example, for the battery entity: + +```yaml +sensor: + - id: batpercent + name: "${friendly_name} Battery" +mqtt: + topic_prefix: "your-existing-prefix" + discovery_unique_id_generator: legacy + discovery_object_id_generator: none +``` + +Repeat the entity override for every existing named sensor, text sensor and +switch. The old `mqtt_topic_prefix` substitution alone no longer changes +`mqtt.topic_prefix`; use the explicit block above. This preserves the original +strings; it does not rename or repair already migrated HA entities. With MAC +identifiers, changing an entity's own name still changes its name hash. Keep +those function names stable too. Both profiles use the released `waveshare_epaper` driver and wait for the panel's physical BUSY cycle before deep sleep. They deliberately keep the @@ -80,7 +118,7 @@ Publish `ON` or `OFF` with retain enabled and QoS 1: | Maintenance | `/cmd/maintenance` | `/status/maintenance` | | Storage | `/cmd/storage_mode` | `/status/storage_mode` | -`` is `mqtt_topic_prefix`. Command state expresses the requested mode. +`` is the effective MQTT topic prefix (the MAC-bearing node name by default). Command state expresses the requested mode. Result state expresses the accepted mode. Home Assistant discovers both controls and their diagnostic result entities. diff --git a/examples/multi-device/my-lemon-tree.yaml b/examples/multi-device/my-lemon-tree.yaml index b071d70..55771c7 100644 --- a/examples/multi-device/my-lemon-tree.yaml +++ b/examples/multi-device/my-lemon-tree.yaml @@ -17,9 +17,8 @@ substitutions: mqtt_password: !secret mqtt_password api_encryption_key: !secret lemon_tree_api_encryption_key - # MQTT topic prefix — set once and never change (renaming orphans HA entities). - # Format: -, visible in ESPHome logs on first boot. - mqtt_topic_prefix: "lemon-tree-abcdef" + # The default MQTT prefix is the effective node name, including its MAC suffix. + # Keep the node identity stable after discovery; see README migration guidance. # OTA password for this device ota_password: "replace-with-your-ota-password" diff --git a/examples/multi-device/packages/smart_plant_core.yaml b/examples/multi-device/packages/smart_plant_core.yaml index e18286c..af3dbbf 100644 --- a/examples/multi-device/packages/smart_plant_core.yaml +++ b/examples/multi-device/packages/smart_plant_core.yaml @@ -14,7 +14,12 @@ # Default values — override any of these in your device file. substitutions: device_name: "smart-plant" + # Preserve the technical identity when changing display text. + configured_name: "${device_name}" + name_add_mac_suffix: "true" + # Compatibility input: existing friendly_name overrides still supply the label. friendly_name: "Smart Plant" + display_name: "${friendly_name}" device_comment: "${friendly_name}" project_name: "smart.plant" project_version: "2.2" @@ -110,8 +115,9 @@ globals: initial_value: 'true' esphome: - name: "${device_name}" - name_add_mac_suffix: true + name: "${configured_name}" + name_add_mac_suffix: "${name_add_mac_suffix}" + friendly_name: "${display_name}" comment: "${device_comment}" project: name: "${project_name}" @@ -205,13 +211,13 @@ sensor: update_interval: never battery_voltage: id: batvolt - name: "${friendly_name} Battery voltage" + name: "Battery voltage" internal: true battery_level: id: batpercent device_class: "battery" unit_of_measurement: "%" - name: "${friendly_name} Battery" + name: "Battery" force_update: true - platform: aht10 @@ -219,13 +225,13 @@ sensor: variant: AHT20 i2c_id: bus_a temperature: - name: "${friendly_name} Temperature" + name: "Temperature" id: temp icon: "mdi:thermometer" device_class: temperature force_update: true humidity: - name: "${friendly_name} Air Humidity" + name: "Air Humidity" id: hum icon: "mdi:water-percent" device_class: humidity @@ -237,18 +243,18 @@ sensor: address: 0x10 update_interval: never ambient_light: - name: "${friendly_name} Ambient light" + name: "Ambient light" id: light icon: "mdi:white-balance-sunny" device_class: illuminance force_update: true actual_gain: - name: "${friendly_name} Actual gain" + name: "Actual gain" internal: true - platform: adc pin: GPIO1 - name: "${friendly_name} Soil Voltage" + name: "Soil Voltage" id: soil_voltage device_class: voltage state_class: measurement @@ -275,7 +281,7 @@ sensor: # sampling loop or an intermediate unfiltered percentage. - platform: copy source_id: soil_voltage - name: "${friendly_name} Soil Moisture" + name: "Soil Moisture" id: soil icon: "mdi:cup-water" device_class: moisture @@ -291,7 +297,7 @@ sensor: # Publish the running firmware version and native effective-configuration hash. text_sensor: - platform: version - name: "${friendly_name} ESPHome Version" + name: "ESPHome Version" hide_timestamp: true hide_hash: false entity_category: diagnostic @@ -374,7 +380,7 @@ display: - id: page_maintenance lambda: |- - it.printf(148, 8, id(font_subtitle), TextAlign::TOP_CENTER, "${friendly_name}"); + it.printf(148, 8, id(font_subtitle), TextAlign::TOP_CENTER, "${display_name}"); it.printf(148, 38, id(font_title), TextAlign::TOP_CENTER, "MAINTENANCE"); if (id(maintenance_end_hour) >= 0) { it.printf(148, 82, id(font_title), TextAlign::TOP_CENTER, "ENDS AT %02d:%02d", @@ -386,7 +392,7 @@ display: - id: page_storage lambda: |- - it.printf(148, 4, id(font_subtitle), TextAlign::TOP_CENTER, "${friendly_name}"); + it.printf(148, 4, id(font_subtitle), TextAlign::TOP_CENTER, "${display_name}"); it.printf(148, 31, id(font_title), TextAlign::TOP_CENTER, "STORAGE MODE"); it.printf(148, 68, id(font_subtitle), TextAlign::TOP_CENTER, "DAILY CHECK"); if (id(batpercent).has_state() && std::isfinite(id(batpercent).state)) { @@ -401,7 +407,7 @@ display: - id: page_low_batt lambda: |- it.fill(COLOR_ON); - it.printf(148, 4, id(font_subtitle), COLOR_OFF, TextAlign::TOP_CENTER, "${friendly_name}"); + it.printf(148, 4, id(font_subtitle), COLOR_OFF, TextAlign::TOP_CENTER, "${display_name}"); it.printf(148, 26, id(font_title), COLOR_OFF, TextAlign::TOP_CENTER, "LOW BATT !"); if (id(storage_active)) { it.printf(148, 60, id(font_subtitle), COLOR_OFF, TextAlign::TOP_CENTER, "STORAGE MODE"); diff --git a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml index 9cf64c3..9b5e965 100644 --- a/examples/multi-device/packages/smart_plant_profile_mqtt.yaml +++ b/examples/multi-device/packages/smart_plant_profile_mqtt.yaml @@ -12,7 +12,6 @@ # ============================================================================= substitutions: - mqtt_topic_prefix: "${device_name}" mqtt_broker: "" mqtt_username: "" mqtt_password: "" @@ -79,12 +78,14 @@ esphome: # or you get duplicate entities that go unavailable during sleep. Keep them on MQTT. mqtt: id: mqtt_client - topic_prefix: "${mqtt_topic_prefix}" broker: "${mqtt_broker}" username: "${mqtt_username}" password: "${mqtt_password}" discovery: true discovery_retain: true + # Per-device identity. Keep function-only entity names stable across renames. + discovery_unique_id_generator: mac + discovery_object_id_generator: device_name birth_message: # disabled — prevents HA marking device offline during sleep will_message: log_topic: # disabled — saves bandwidth on wake diff --git a/tests/test_identity_schema.py b/tests/test_identity_schema.py new file mode 100644 index 0000000..f1ec226 --- /dev/null +++ b/tests/test_identity_schema.py @@ -0,0 +1,41 @@ +"""Validate rename behavior through ESPHome's real package/substitution loader.""" +import importlib.util +from pathlib import Path +import tempfile +import unittest +import yaml +ROOT = Path(__file__).resolve().parents[1] +@unittest.skipUnless(importlib.util.find_spec("esphome"), "ESPHome is required") +class EntityIdentityTests(unittest.TestCase): + def test_display_rename_preserves_function_names_and_technical_identity(self): + from esphome.core import CORE + from esphome.config import read_config + results=[] + try: + with tempfile.TemporaryDirectory() as directory: + for label in ("First label", "Changed label"): + values={"device_name":"schema-plant", "configured_name":"schema-plant-abcdef", + "name_add_mac_suffix":"false", "friendly_name":label, + "wifi_ssid":"synthetic-wifi", "wifi_password":"synthetic-password", + "mqtt_broker":"192.0.2.1", "ota_password":"synthetic-ota", + "api_encryption_key":"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=", + "label_image":str(ROOT/"docs/source/images/programming/Lemon_tree_label_page_1.png")} + source=yaml.safe_dump({"substitutions":values},sort_keys=False) + source+="packages:\n core: !include "+str(ROOT/"examples/multi-device/packages/smart_plant_core.yaml")+"\n transport: !include "+str(ROOT/"examples/multi-device/packages/smart_plant_profile_mqtt.yaml")+"\n" + path=Path(directory)/"schema.yaml";path.write_text(source) + CORE.reset();CORE.config_path=path;config=read_config({}) + self.assertIsNotNone(config) + results.append({"name":config["esphome"]["name"], + "friendly":config["esphome"]["friendly_name"], + "sensor_names":{str(s["id"]):s.get("name") for s in config["sensor"] if "name" in s}, + "switch_names":{str(s["id"]):s.get("name") for s in config["switch"] if "name" in s}, + "prefix":config["mqtt"]["topic_prefix"]}) + self.assertEqual(str(config["mqtt"]["discovery_unique_id_generator"]),"mac") + self.assertEqual(str(config["mqtt"]["discovery_object_id_generator"]),"device_name") + self.assertNotEqual(results[0].pop("friendly"),results[1].pop("friendly")) + self.assertEqual(results[0],results[1]) + self.assertEqual(results[0]["name"],"schema-plant-abcdef") + self.assertEqual(results[0]["prefix"],"schema-plant-abcdef") + self.assertEqual(results[0]["sensor_names"], results[1]["sensor_names"]) + finally: + CORE.reset()