# Inovelli Blue (ZHA) configuration sync # # Copies the ZHA configuration entities (number / select / switch) of one # Inovelli Blue switch onto the other Inovelli Blue switches of the same model. # # Entities are matched across devices by their *name suffix*: ZHA sets # has_entity_name, so friendly_name is always " ". # Stripping the device name yields a key such as "Remote dimming up speed" # that is identical on every switch of the same model. # # Scale note: Home Assistant caps the rendered output of a single template at # 262144 characters. Nothing here may build a structure whose size is # (switches x settings); at 40 syncable settings a hundred switches is already # close to four thousand potential writes, and a flat list of those overflows # the cap several times over. The plan is therefore held as per-switch counts, # and the per-switch write list is derived inside the write loop where only one # switch's worth of it exists at a time. script: inovelli_blue_sync_config: alias: Inovelli Blue - Sync Config Between Switches description: >- Reads every ZHA config entity from a source Inovelli Blue switch and writes the differing values to the other Inovelli Blue switches. Runs as a test run by default and reports the result as a persistent notification. icon: mdi:content-duplicate mode: single fields: source_device: name: Source device description: >- The switch to copy settings FROM. Its current values become the values every target is set to; nothing is ever written to it. Pick the one that is already configured the way you want the rest to be. required: true selector: device: integration: zha manufacturer: Inovelli target_devices: name: Target devices (Optional) description: >- The switches to write TO. Leave this empty and every other ZHA device reporting the same model as the source is used, which is the normal way to run it. Naming switches here instead skips that model check, so only do it deliberately - pointing a dimmer's settings at a fan switch is not prevented. required: false selector: device: multiple: true integration: zha manufacturer: Inovelli exclude_devices: name: Excluded devices (Optional) description: >- Switches to leave alone. Anything listed here is dropped from the target list whether the targets were discovered automatically or named above. Use it for the one switch you keep configured differently on purpose. required: false selector: device: multiple: true integration: zha write_delay: name: Write delay (Defaults to 0.5 - The Default usually works) description: >- Seconds to pause after each individual write. Every setting is a separate Zigbee command, so a full sync across many switches is hundreds of them; the pause keeps that from saturating the mesh and getting commands dropped. Raise it to 1 or 2 if writes start failing on large runs. Lowering it below the default is not recommended. required: true default: 0.5 selector: number: min: 0 max: 10 step: 0.1 mode: box unit_of_measurement: seconds # ---------------------------------------------------------------------- # What to sync. Every configuration setting a VZM31-SN exposes appears in # exactly one of the five groups below, so the checkboxes are the whole # picture rather than a filter over it: a setting that is not ticked is # not written. # # `multiple: true` with `mode: list` is what makes the frontend render # these as checkbox lists rather than a dropdown or radio buttons. Do not # add `custom_value` or `reorder` to any of them - either one makes the # frontend fall back to a combobox and the checkboxes disappear. # # `options` must be homogeneous within a field: Home Assistant's # SelectSelector accepts a list of strings OR a list of {value, label} # mappings, never a mix. Every group here uses the mapping form so each # checkbox can say what the setting does. `value` is the part that # matters functionally - it has to equal the entity's friendly-name # suffix exactly - while `label` is only what the form displays. # # A label exists to answer one question: should this setting be copied? # So it names the setting, says what it controls in terms someone can # observe on the wall, and gives the meaning of any value that changes # that decision - 0 for off, 255 for the previous level. Reference # material - parameter numbers, attribute ids, ranges, gating # conditions - is deliberately absent, and lives in README.md instead. # # Every group spells `default` and `options` out in full rather than # sharing one list through a YAML anchor, so that the form does not # depend on two separate layers handling aliases the same way. # # tools/validate_package.py enforces both of those and explains them at # more length than a form is worth. # ---------------------------------------------------------------------- sync_led: name: "Sync: LED appearance" description: >- The color, brightness and behavior of the LED bar down the side of the paddle. required: false default: - Default all LED off color - Default all LED off intensity - Default all LED on color - Default all LED on intensity - Firmware progress LED - LED scaling mode - Load level indicator timeout - Only 1 LED mode selector: select: multiple: true mode: list options: - value: Default all LED off color label: >- 𝗗𝗲𝗳𝗮𝘂𝗹𝘁 𝗮𝗹𝗹 𝗟𝗘𝗗 𝗼𝗳𝗳 𝗰𝗼𝗹𝗼𝗿: the color of the LED bar while the light is off. 0 red, 85 green, 170 blue, 255 white - value: Default all LED off intensity label: >- 𝗗𝗲𝗳𝗮𝘂𝗹𝘁 𝗮𝗹𝗹 𝗟𝗘𝗗 𝗼𝗳𝗳 𝗶𝗻𝘁𝗲𝗻𝘀𝗶𝘁𝘆: how bright the LED bar is while the light is off. 0 turns the bar off - value: Default all LED on color label: >- 𝗗𝗲𝗳𝗮𝘂𝗹𝘁 𝗮𝗹𝗹 𝗟𝗘𝗗 𝗼𝗻 𝗰𝗼𝗹𝗼𝗿: the color of the LED bar while the light is on. 0 red, 85 green, 170 blue, 255 white - value: Default all LED on intensity label: >- 𝗗𝗲𝗳𝗮𝘂𝗹𝘁 𝗮𝗹𝗹 𝗟𝗘𝗗 𝗼𝗻 𝗶𝗻𝘁𝗲𝗻𝘀𝗶𝘁𝘆: how bright the LED bar is while the light is on - value: Firmware progress LED label: >- 𝗙𝗶𝗿𝗺𝘄𝗮𝗿𝗲 𝗽𝗿𝗼𝗴𝗿𝗲𝘀𝘀 𝗟𝗘𝗗: whether the LED bar shows progress while the switch is updating its firmware - value: LED scaling mode label: >- 𝗟𝗘𝗗 𝘀𝗰𝗮𝗹𝗶𝗻𝗴 𝗺𝗼𝗱𝗲: how much of the LED bar lights up for a given brightness - value: Load level indicator timeout label: >- 𝗟𝗼𝗮𝗱 𝗹𝗲𝘃𝗲𝗹 𝗶𝗻𝗱𝗶𝗰𝗮𝘁𝗼𝗿 𝘁𝗶𝗺𝗲𝗼𝘂𝘁: how long the LED bar shows the brightness level after the paddle is released. 0 never shows it, 11 shows it always - value: Only 1 LED mode label: >- 𝗢𝗻𝗹𝘆 𝟭 𝗟𝗘𝗗 𝗺𝗼𝗱𝗲: light only the bottom segment of the LED bar instead of the whole bar sync_ramp: name: "Sync: ramp rates and dimming speeds" description: >- How fast the light fades on and off, and how fast it dims while the paddle is held. A "ramp rate" is the fade between off and on; a "dimming speed" is the rate of change while the light is already on. Local means at the paddle, remote means from Home Assistant. Every value is in tenths of a second, so 0 is instant and 126 is 12.6 seconds. 127 means the setting follows another one instead of holding a rate of its own, which is why these are best copied as a set. - These settings have no effect unless Output mode is set to dimmer. required: false default: - Local dimming down speed - Local dimming up speed - Local ramp rate off to on - Local ramp rate on to off - Remote dimming down speed - Remote dimming up speed - Remote ramp rate off to on - Remote ramp rate on to off selector: select: multiple: true mode: list options: - value: Local dimming down speed label: >- 𝗟𝗼𝗰𝗮𝗹 𝗱𝗶𝗺𝗺𝗶𝗻𝗴 𝗱𝗼𝘄𝗻 𝘀𝗽𝗲𝗲𝗱: how fast the light dims down while the paddle is held down - value: Local dimming up speed label: >- 𝗟𝗼𝗰𝗮𝗹 𝗱𝗶𝗺𝗺𝗶𝗻𝗴 𝘂𝗽 𝘀𝗽𝗲𝗲𝗱: how fast the light dims up while the paddle is held up - value: Local ramp rate off to on label: >- 𝗟𝗼𝗰𝗮𝗹 𝗿𝗮𝗺𝗽 𝗿𝗮𝘁𝗲 𝗼𝗳𝗳 𝘁𝗼 𝗼𝗻: how fast the light fades on when the paddle is tapped - value: Local ramp rate on to off label: >- 𝗟𝗼𝗰𝗮𝗹 𝗿𝗮𝗺𝗽 𝗿𝗮𝘁𝗲 𝗼𝗻 𝘁𝗼 𝗼𝗳𝗳: how fast the light fades off when the paddle is tapped - value: Remote dimming down speed label: >- 𝗥𝗲𝗺𝗼𝘁𝗲 𝗱𝗶𝗺𝗺𝗶𝗻𝗴 𝗱𝗼𝘄𝗻 𝘀𝗽𝗲𝗲𝗱: how fast the light dims down when Home Assistant lowers the level - value: Remote dimming up speed label: >- 𝗥𝗲𝗺𝗼𝘁𝗲 𝗱𝗶𝗺𝗺𝗶𝗻𝗴 𝘂𝗽 𝘀𝗽𝗲𝗲𝗱: how fast the light dims up when Home Assistant raises the level - value: Remote ramp rate off to on label: >- 𝗥𝗲𝗺𝗼𝘁𝗲 𝗿𝗮𝗺𝗽 𝗿𝗮𝘁𝗲 𝗼𝗳𝗳 𝘁𝗼 𝗼𝗻: how fast the light fades on when Home Assistant turns it on - value: Remote ramp rate on to off label: >- 𝗥𝗲𝗺𝗼𝘁𝗲 𝗿𝗮𝗺𝗽 𝗿𝗮𝘁𝗲 𝗼𝗻 𝘁𝗼 𝗼𝗳𝗳: how fast the light fades off when Home Assistant turns it off sync_buttons: name: "Sync: buttons, scenes and behavior" description: >- What the paddle and the config button do: which way up the paddle is, what a double tap does, how long it waits for a second tap, the auto-off timer, and the relay click. required: false default: - Aux switch scenes - Automatic switch shutoff timer - Button delay - Disable config 2x tap to clear notifications - Disable relay click in on off mode - Double tap down enabled - Double tap down level - Double tap up enabled - Double tap up level - Invert switch - Local protection selector: select: multiple: true mode: list options: - value: Aux switch scenes label: >- 𝗔𝘂𝘅 𝘀𝘄𝗶𝘁𝗰𝗵 𝘀𝗰𝗲𝗻𝗲𝘀: let an attached aux switch fire its own multi-tap scenes instead of copying the main paddle's - value: Automatic switch shutoff timer label: >- 𝗔𝘂𝘁𝗼𝗺𝗮𝘁𝗶𝗰 𝘀𝘄𝗶𝘁𝗰𝗵 𝘀𝗵𝘂𝘁𝗼𝗳𝗳 𝘁𝗶𝗺𝗲𝗿: how long the light stays on before turning itself off. 0 leaves it on indefinitely - value: Button delay label: >- 𝗕𝘂𝘁𝘁𝗼𝗻 𝗱𝗲𝗹𝗮𝘆: how long the paddle waits before acting, so it can catch a second tap. 0 responds instantly but gives up multi-tap scenes - value: Disable config 2x tap to clear notifications label: >- 𝗗𝗶𝘀𝗮𝗯𝗹𝗲 𝗰𝗼𝗻𝗳𝗶𝗴 𝟮𝘅 𝘁𝗮𝗽 𝘁𝗼 𝗰𝗹𝗲𝗮𝗿 𝗻𝗼𝘁𝗶𝗳𝗶𝗰𝗮𝘁𝗶𝗼𝗻𝘀: stop a double tap of the config button from clearing an LED notification - value: Disable relay click in on off mode label: >- 𝗗𝗶𝘀𝗮𝗯𝗹𝗲 𝗿𝗲𝗹𝗮𝘆 𝗰𝗹𝗶𝗰𝗸 𝗶𝗻 𝗼𝗻 𝗼𝗳𝗳 𝗺𝗼𝗱𝗲: silence the click the internal relay makes when the light switches - value: Double tap down enabled label: >- 𝗗𝗼𝘂𝗯𝗹𝗲 𝘁𝗮𝗽 𝗱𝗼𝘄𝗻 𝗲𝗻𝗮𝗯𝗹𝗲𝗱: allow a double tap down to jump straight to a set brightness - value: Double tap down level label: >- 𝗗𝗼𝘂𝗯𝗹𝗲 𝘁𝗮𝗽 𝗱𝗼𝘄𝗻 𝗹𝗲𝘃𝗲𝗹: the brightness a double tap down jumps to. 0 turns the light off - value: Double tap up enabled label: >- 𝗗𝗼𝘂𝗯𝗹𝗲 𝘁𝗮𝗽 𝘂𝗽 𝗲𝗻𝗮𝗯𝗹𝗲𝗱: allow a double tap up to jump straight to a set brightness - value: Double tap up level label: >- 𝗗𝗼𝘂𝗯𝗹𝗲 𝘁𝗮𝗽 𝘂𝗽 𝗹𝗲𝘃𝗲𝗹: the brightness a double tap up jumps to. 254 is full brightness - value: Invert switch label: >- 𝗜𝗻𝘃𝗲𝗿𝘁 𝘀𝘄𝗶𝘁𝗰𝗵: swap the paddle over, so the top turns the light off and the bottom turns it on - value: Local protection label: >- 𝗟𝗼𝗰𝗮𝗹 𝗽𝗿𝗼𝘁𝗲𝗰𝘁𝗶𝗼𝗻: stop the paddle from controlling the light, so it responds only to Home Assistant sync_levels: name: "Sync: default and start-up levels" description: >- What brightness the light comes on to when no particular level is asked for, and what it does when mains power comes back after an outage. required: false default: - Binding off to on sync level - Local default dimming level - Remote default dimming level - Start-up behavior - Start-up default dimming level selector: select: multiple: true mode: list options: - value: Binding off to on sync level label: >- 𝗕𝗶𝗻𝗱𝗶𝗻𝗴 𝗼𝗳𝗳 𝘁𝗼 𝗼𝗻 𝘀𝘆𝗻𝗰 𝗹𝗲𝘃𝗲𝗹: apply the default brightness to a Zigbee-bound bulb as well as to the switch's own load - value: Local default dimming level label: >- 𝗟𝗼𝗰𝗮𝗹 𝗱𝗲𝗳𝗮𝘂𝗹𝘁 𝗱𝗶𝗺𝗺𝗶𝗻𝗴 𝗹𝗲𝘃𝗲𝗹: the brightness the light comes on to when the paddle is tapped. 0 means off, 255 means the level it was at last - value: On level label: >- 𝗢𝗻 𝗹𝗲𝘃𝗲𝗹: the brightness the light comes on to when it is switched on without a level being given. 255 means the level it was at last - value: Remote default dimming level label: >- 𝗥𝗲𝗺𝗼𝘁𝗲 𝗱𝗲𝗳𝗮𝘂𝗹𝘁 𝗱𝗶𝗺𝗺𝗶𝗻𝗴 𝗹𝗲𝘃𝗲𝗹: the brightness the light comes on to when Home Assistant turns it on. 0 means off, 255 means the level it was at last - value: Start-up behavior label: >- 𝗦𝘁𝗮𝗿𝘁-𝘂𝗽 𝗯𝗲𝗵𝗮𝘃𝗶𝗼𝗿: what the light does when mains power comes back - stay off, turn on, toggle, or return to how it was - value: Start-up default dimming level label: >- 𝗦𝘁𝗮𝗿𝘁-𝘂𝗽 𝗱𝗲𝗳𝗮𝘂𝗹𝘁 𝗱𝗶𝗺𝗺𝗶𝗻𝗴 𝗹𝗲𝘃𝗲𝗹: the brightness the light returns to when mains power comes back. 0 means off, 255 means the level it was at last sync_load: name: "Sync: wiring and load" description: >- How the switch drives what is attached to it: whether it dims or only switches, the wiring arrangement it is installed in, the brightness limits for the fixture, and how it handles smart bulbs. - These settings are dependent upon how the device is physically wired. required: false default: - Dimming mode - Increased non-neutral output - Maximum load dimming level - Minimum load dimming level - Output mode - Smart bulb mode - Switch type selector: select: multiple: true mode: list options: - value: Dimming mode label: >- 𝗗𝗶𝗺𝗺𝗶𝗻𝗴 𝗺𝗼𝗱𝗲: which dimming method the switch uses, chosen to suit the bulb or LED driver it is running - value: Increased non-neutral output label: >- 𝗜𝗻𝗰𝗿𝗲𝗮𝘀𝗲𝗱 𝗻𝗼𝗻-𝗻𝗲𝘂𝘁𝗿𝗮𝗹 𝗼𝘂𝘁𝗽𝘂𝘁: allow a brighter maximum on wiring with no neutral, at the cost of possible flicker or the switch dropping off the network - value: Maximum load dimming level label: >- 𝗠𝗮𝘅𝗶𝗺𝘂𝗺 𝗹𝗼𝗮𝗱 𝗱𝗶𝗺𝗺𝗶𝗻𝗴 𝗹𝗲𝘃𝗲𝗹: the brightest the light is allowed to go - value: Minimum load dimming level label: >- 𝗠𝗶𝗻𝗶𝗺𝘂𝗺 𝗹𝗼𝗮𝗱 𝗱𝗶𝗺𝗺𝗶𝗻𝗴 𝗹𝗲𝘃𝗲𝗹: the dimmest the light is allowed to go before it turns off - value: Output mode label: >- 𝗢𝘂𝘁𝗽𝘂𝘁 𝗺𝗼𝗱𝗲: whether the switch dims the light or only turns it fully on and off - value: Smart bulb mode label: >- 𝗦𝗺𝗮𝗿𝘁 𝗯𝘂𝗹𝗯 𝗺𝗼𝗱𝗲: keep power on to the fixture at all times and control it over Zigbee instead, so smart bulbs stay reachable - value: Switch type label: >- 𝗦𝘄𝗶𝘁𝗰𝗵 𝘁𝘆𝗽𝗲: which wiring arrangement this switch is installed in, such as single pole or one end of a 3-way full_detail: name: Full detail description: >- Report the per-setting breakdown for every switch rather than a brief summary and a list of the ones that did not sync. required: true default: false selector: boolean: # Last, so it sits directly above the button that starts the run: it is # the one field to check before pressing it, and the decision it settles # is whether that press writes anything. test_run: name: Test run description: >- Leave this on to see what would change without changing anything. The run reports every difference it found and writes nothing. Turn it off to actually write. It is on by default, so a run started without setting it is always safe. required: true default: true selector: boolean: sequence: - alias: Resolve inputs and plan the sync variables: # Epoch seconds, captured before any work, so each exit path can # report how long the run took. now() is evaluated once here: script # actions render templates without setting up time listeners, so # this does not make the script time-tracked. run_started_at: "{{ now().timestamp() }}" # The same five groups as the checkbox fields above, and they have to # stay identical to them. A name here that no entity actually has is # not an error, it is a setting that silently never syncs, so these # are the exact friendly-name suffixes ZHA produces for a VZM31-SN. catalog: |- {{ { 'led': ['Default all LED off color', 'Default all LED off intensity', 'Default all LED on color', 'Default all LED on intensity', 'Firmware progress LED', 'LED scaling mode', 'Load level indicator timeout', 'Only 1 LED mode'], 'ramp': ['Local dimming down speed', 'Local dimming up speed', 'Local ramp rate off to on', 'Local ramp rate on to off', 'Remote dimming down speed', 'Remote dimming up speed', 'Remote ramp rate off to on', 'Remote ramp rate on to off'], 'buttons': ['Aux switch scenes', 'Automatic switch shutoff timer', 'Button delay', 'Disable config 2x tap to clear notifications', 'Disable relay click in on off mode', 'Double tap down enabled', 'Double tap down level', 'Double tap up enabled', 'Double tap up level', 'Invert switch', 'Local protection'], 'levels': ['Binding off to on sync level', 'Local default dimming level', 'On level', 'Remote default dimming level', 'Start-up behavior', 'Start-up default dimming level'], 'load': ['Dimming mode', 'Increased non-neutral output', 'Maximum load dimming level', 'Minimum load dimming level', 'Output mode', 'Smart bulb mode', 'Switch type'] } }} # Offered, but not ticked unless the caller asks for it. default_off: "{{ ['On level'] }}" # Blocked outright, whatever the caller passes. Not offered as # checkboxes either, because there is no version of "yes please" that # ends well. # # On/Off transition time and Start-up current level are the generic # Zigbee view of two settings this script already syncs under their # Inovelli names, so writing one would set that setting a second # time from a value nothing has confirmed is current, after the # write that was actually planned. Remote protection would make a # switch refuse all further remote writes, including this script's, # with no way back over Zigbee; the quirk does not create it for the # VZM31-SN today but does for the VZM32-SN, so an update could # surface it here. README.md, under "Three settings that are never # written", sets out the evidence for both. # # Home Assistant reads the first two only when a switch is added, so # what it displays for them can be years old and can differ between # two switches holding identical values. The run re-reads them from # the source and from every target that disagrees with it, and again # from every switch it writes, which is the only way to correct a # display without touching the setting underneath it. never_write: |- {{ ['on/off transition time', 'start-up current level', 'remote protection'] }} # Field defaults are only applied by the UI, so a direct action call # that omits a group gets that group's default selection here rather # than an empty one. selected: |- {%- set given = { 'led': sync_led if sync_led is defined and sync_led is not none else none, 'ramp': sync_ramp if sync_ramp is defined and sync_ramp is not none else none, 'buttons': sync_buttons if sync_buttons is defined and sync_buttons is not none else none, 'levels': sync_levels if sync_levels is defined and sync_levels is not none else none, 'load': sync_load if sync_load is defined and sync_load is not none else none} -%} {%- set ns = namespace(sel=[]) -%} {%- for group, options in catalog.items() -%} {%- set v = given[group] -%} {%- if v is none -%} {%- set ns.sel = ns.sel + (options | reject('in', default_off) | list) -%} {%- else -%} {%- set v = [v] if v is string else v -%} {%- set ns.sel = ns.sel + (v | map('string') | list) -%} {%- endif -%} {%- endfor -%} {{ ns.sel | map('trim') | map('lower') | reject('eq', '') | reject('in', never_write) | unique | list }} is_test_run: "{{ (test_run | default(true)) | bool(true) }}" want_full_detail: "{{ (full_detail | default(false)) | bool(false) }}" # How much per-setting detail the response and notification carry. # These bounds are what keep every render well clear of Home # Assistant's 262144-character template output limit; the counts # alongside the detail are always complete and unbounded. # # detail_entry_limit is above the 40 settings a VZM31-SN can offer, # so a switch's change list is never cut short even on a default run. detail_entry_limit: "{{ 100000 if want_full_detail else 45 }}" # A write failure is the most important thing this script reports, so # it is never subject to the per-switch detail budget above: # the per-switch cap is high enough to name every planned write, and # the fleet-wide budget below still guarantees at least one named # failure per failing switch once it runs out. detail_fail_limit: "{{ 100000 if want_full_detail else 40 }}" detail_fail_budget: "{{ 1000000 if want_full_detail else 400 }}" write_delay_s: "{{ [(write_delay | default(0.5)) | float(0.5), 0] | max }}" src_device: |- {%- set s = source_device | default('', true) -%} {{ (s | first | default('', true)) if (s is iterable and s is not string) else s }} src_name: >- {{ device_attr(src_device, 'name_by_user') or device_attr(src_device, 'name') or src_device }} src_sw: >- {{ device_attr(src_device, 'sw_version') | default('unknown', true) | string }} # Write order is not cosmetic. The firmware validates several # settings against others, so a value written too early is accepted, # silently reverted, and then reported as a failed write: a Trailing # Edge write to Dimming mode, for one, is only honored once Switch # type says the wiring allows it. Everything with a dependency goes # here, in dependency order; everything else follows alphabetically. # README.md, under "Write order", says what depends on what. priority: |- {{ ['switch type', 'output mode', 'smart bulb mode', 'increased non-neutral output', 'dimming mode', 'maximum load dimming level', 'minimum load dimming level', 'remote dimming up speed', 'local dimming up speed', 'remote dimming down speed', 'remote ramp rate off to on', 'local ramp rate off to on', 'local dimming down speed', 'remote ramp rate on to off', 'local ramp rate on to off'] }} # Every config entity the source exposes, as [{key, entity_id, # domain}]: the name-suffix key ZHA's naming yields, and where to # read it. The five templates below work from this rather than # walking the device again each time, so the rule for turning a # friendly name into a key - and the fall back to the whole name for # an entity that was renamed by hand - lives in one place. # # States are deliberately not carried here. The blocked settings are # re-read part way through the run, so a value captured now would be # stale by the time it mattered; whatever needs one reads it live at # the point it compares. The primary light entity is dropped, having # no name suffix and so an empty key. source_scan: |- {%- set dname = (device_attr(src_device, 'name_by_user') or device_attr(src_device, 'name') or '') | string -%} {%- set nlen = dname | length -%} {%- set ns = namespace(rows=[]) -%} {%- for e in device_entities(src_device) -%} {%- set dom = e.split('.')[0] -%} {%- if dom in ['number', 'select', 'switch'] -%} {%- set fn = state_attr(e, 'friendly_name') | default('', true) | string -%} {%- set skey = (fn[nlen:] if fn[:nlen] == dname else fn) | trim -%} {%- if skey != '' -%} {%- set ns.rows = ns.rows + [{ 'key': skey, 'entity_id': e, 'domain': dom}] -%} {%- endif -%} {%- endif -%} {%- endfor -%} {{ ns.rows }} # [{key, entity_id, domain, state, rank}] for the source, minus # unavailable entities, anything left unticked and anything # hard-blocked. source_settings: |- {%- set ns = namespace(rows=[]) -%} {%- for s in source_scan -%} {%- set st = states(s.entity_id) -%} {%- set lk = s.key | lower -%} {%- if st not in ['unknown', 'unavailable'] and lk in selected and lk not in never_write -%} {%- set ns.rows = ns.rows + [{ 'key': s.key, 'entity_id': s.entity_id, 'domain': s.domain, 'state': st, 'rank': priority.index(lk) if lk in priority else 999}] -%} {%- endif -%} {%- endfor -%} {#- sort is stable, so sorting by name and then by rank leaves the priority settings in dependency order and the rest alphabetical -#} {{ ns.rows | sort(attribute='key') | sort(attribute='rank') }} # Config entities on the source that no checkbox covers. Always # empty on a stock VZM31-SN; anything appearing here means Home # Assistant or the quirk renamed or added a setting, and it is # silently not being synced until the groups above are updated. unrecognized: |- {%- set known = (catalog.values() | list | sum(start=[])) | map('lower') | list -%} {%- set ns = namespace(rows=[]) -%} {%- for s in source_scan -%} {%- if (s.key | lower) not in known and (s.key | lower) not in never_write -%} {%- set ns.rows = ns.rows + [s.key] -%} {%- endif -%} {%- endfor -%} {{ ns.rows | sort }} # Non-fatal things the caller should know, carried on every exit path # so they surface whether or not anything was written. Anything that # only makes sense once a particular exit path is reached - a clamped # breakdown, drift, an unlisted switch - is added to `problems` there # instead, so the report never describes a limit that did not bite. advisories: |- {%- set ns = namespace(p=[]) -%} {%- if unrecognized | count > 0 -%} {%- set ns.p = ns.p + ['These settings exist on the source but are not covered by any of the **Sync:** groups, so they are not being synced: ' ~ unrecognized | join(', ') ~ '. Home Assistant or the ZHA quirk has most likely renamed or added a setting. Update the groups in the script to pick it up.'] -%} {%- endif -%} {{ ns.p }} target_devs: |- {%- set given = target_devices | default([], true) -%} {%- set given = [given] if given is string else given -%} {%- set skip = exclude_devices | default([], true) -%} {%- set skip = [skip] if skip is string else skip -%} {%- set ns = namespace(d=[]) -%} {%- if given | count > 0 -%} {%- for d in given -%} {%- if d != src_device and d not in ns.d -%} {%- set ns.d = ns.d + [d] -%} {%- endif -%} {%- endfor -%} {%- else -%} {%- set model = device_attr(src_device, 'model') -%} {%- for e in integration_entities('zha') -%} {%- set d = device_id(e) -%} {%- if d and d != src_device and d not in ns.d and model is not none and device_attr(d, 'model') == model -%} {%- set ns.d = ns.d + [d] -%} {%- endif -%} {%- endfor -%} {%- endif -%} {{ ns.d | reject('in', skip) | list }} # Identity and counts for every target, and nothing else. This is # deliberately free of per-setting payload: the size of this render # grows with the number of switches only, never with the number of # settings that differ. plan_counts: |- {%- set ns = namespace(out=[]) -%} {%- for dev in target_devs -%} {%- set dname = (device_attr(dev, 'name_by_user') or device_attr(dev, 'name') or dev) | string -%} {%- set nlen = dname | length -%} {%- set tns = namespace(rows=[]) -%} {%- for e in device_entities(dev) -%} {%- set dom = e.split('.')[0] -%} {%- if dom in ['number', 'select', 'switch'] -%} {%- set st = states(e) -%} {%- set fn = state_attr(e, 'friendly_name') | default('', true) | string -%} {%- set tkey = (fn[nlen:] if fn[:nlen] == dname else fn) | trim -%} {%- if tkey != '' and st not in ['unknown', 'unavailable'] -%} {%- set tns.rows = tns.rows + [{ 'k': tkey ~ '|' ~ dom, 'state': st}] -%} {%- endif -%} {%- endif -%} {%- endfor -%} {%- set keys = tns.rows | map(attribute='k') | list -%} {%- set cns = namespace(diff=0, miss=0, mask='') -%} {%- for s in source_settings -%} {%- set lk = s.key ~ '|' ~ s.domain -%} {%- if lk not in keys -%} {%- set cns.miss = cns.miss + 1 -%} {%- set cns.mask = cns.mask ~ '0' -%} {%- else -%} {%- set t = (tns.rows | selectattr('k', 'eq', lk) | first).state -%} {%- set differs = (s.state | float(0) != t | float(0)) if s.domain == 'number' else (s.state | string != t | string) -%} {%- if differs -%} {%- set cns.diff = cns.diff + 1 -%} {%- set cns.mask = cns.mask ~ '1' -%} {%- else -%} {%- set cns.mask = cns.mask ~ '0' -%} {%- endif -%} {%- endif -%} {%- endfor -%} {%- set dsw = device_attr(dev, 'sw_version') | default('unknown', true) | string -%} {#- `planned` is one character per source setting, '1' where this switch is due a write. Verification has to know exactly what was planned to tell a failed write from a setting that moved on its own, and a mask costs a fixed ~30 characters per switch where a list of setting names would cost hundreds. -#} {%- set ns.out = ns.out + [{ 'device_id': dev, 'name': dname, 'sw_version': dsw, 'firmware_differs': dsw != src_sw | string, 'change_count': cns.diff, 'missing_count': cns.miss, 'planned': cns.mask}] -%} {%- endfor -%} {{ ns.out }} change_count: "{{ plan_counts | map(attribute='change_count') | sum }}" # Only the switches that actually need a write are visited by the # write loop, which is also the only place a per-setting write list # is ever materialized. plan_targets: >- {{ plan_counts | selectattr('change_count', 'gt', 0) | map(attribute='device_id') | list }} # Counted from the write list itself, so the number the report gives # and the number of switches written cannot come apart. out_of_sync_count: "{{ plan_targets | count }}" # full_detail cannot simply be unbounded. Every switch getting every # setting is (switches x settings) entries in a single render, which # with 40 settings ticked passes the cap on its own - the # run would not return a truncated answer, it would fail outright. So # the switch budget for a full-detail run is derived from how many # settings are actually in play, keeping the largest render inside a # safe share of the limit. When that bites, `problems` says so rather # than leaving the caller to wonder why the list stops. detail_device_limit: |- {%- if not want_full_detail -%} 20 {%- else -%} {#- every switch costs ~360 characters just to be listed, whether it earns detail or not, and that floor has to come out of the budget before the per-setting entries get their share -#} {%- set floor = (target_devs | count) * 420 -%} {%- set per_switch = ((source_settings | count) * 62) + 60 -%} {{ [((140000 - floor) // per_switch), 20] | max }} {%- endif -%} # Every switch with something to show in a per-setting breakdown, # busiest first: those due a write, then those that match but are # short of entities. A reordering of plan_counts, so it carries no # per-setting payload either. detail_ids takes as many as the budget # allows and detail_clamped says whether that cut anything off; # deriving both from one list stops the two disagreeing. detail_candidates: |- {%- set busy = plan_counts | selectattr('change_count', 'gt', 0) | sort(attribute='change_count', reverse=true) | list -%} {%- set short = plan_counts | selectattr('change_count', 'eq', 0) | selectattr('missing_count', 'gt', 0) | sort(attribute='missing_count', reverse=true) | list -%} {{ busy + short }} detail_clamped: >- {{ want_full_detail and detail_candidates | count > detail_device_limit | int }} # Which switches earn a per-setting breakdown: as many of the # candidates as the budget allows, busiest first. detail_ids: >- {{ detail_candidates[:detail_device_limit | int] | map(attribute='device_id') | list }} # The source's own copy of the hard-blocked settings. Home Assistant # asked the source for these once, when it was added, exactly as it # did every target, so the value it displays for one of them is as # capable of being years old as any target's. The step below re-reads # these before anything is compared against them. blocked_source: |- {%- set ns = namespace(e=[]) -%} {%- for s in source_scan -%} {%- if (s.key | lower) in never_write -%} {%- set ns.e = ns.e + [s.entity_id] -%} {%- endif -%} {%- endfor -%} {{ ns.e }} # plan_counts plus a bounded per-setting breakdown for the switches # in detail_ids. change_count and missing_count stay exact even when # the lists beside them are truncated. plan_devices: |- {%- set lim = detail_entry_limit | int -%} {%- set ns = namespace(out=[]) -%} {%- for p in plan_counts -%} {%- set want = p.device_id in detail_ids and (p.change_count > 0 or p.missing_count > 0) -%} {%- set dns = namespace(changes=[], miss=[]) -%} {%- if want -%} {%- set nlen = p.name | length -%} {%- set tns = namespace(rows=[]) -%} {%- for e in device_entities(p.device_id) -%} {%- set dom = e.split('.')[0] -%} {%- if dom in ['number', 'select', 'switch'] -%} {%- set st = states(e) -%} {%- set fn = state_attr(e, 'friendly_name') | default('', true) | string -%} {%- set tkey = (fn[nlen:] if fn[:nlen] == p.name else fn) | trim -%} {%- if tkey != '' and st not in ['unknown', 'unavailable'] -%} {%- set tns.rows = tns.rows + [{ 'k': tkey ~ '|' ~ dom, 'state': st}] -%} {%- endif -%} {%- endif -%} {%- endfor -%} {%- set keys = tns.rows | map(attribute='k') | list -%} {%- for s in source_settings -%} {%- set lk = s.key ~ '|' ~ s.domain -%} {%- if lk not in keys -%} {%- if dns.miss | count < lim -%} {%- set dns.miss = dns.miss + [s.key] -%} {%- endif -%} {%- elif dns.changes | count < lim -%} {%- set t = (tns.rows | selectattr('k', 'eq', lk) | first).state -%} {%- set differs = (s.state | float(0) != t | float(0)) if s.domain == 'number' else (s.state | string != t | string) -%} {%- if differs -%} {#- no domain here: nothing reads it, and at 40 settings across a large fleet it is a third of this render -#} {%- set dns.changes = dns.changes + [{ 'key': s.key, 'from': t, 'to': s.state}] -%} {%- endif -%} {%- endif -%} {%- endfor -%} {%- endif -%} {%- set ns.out = ns.out + [{ 'device_id': p.device_id, 'name': p.name, 'sw_version': p.sw_version, 'firmware_differs': p.firmware_differs, 'status': 'out_of_sync' if p.change_count > 0 else 'in_sync', 'change_count': p.change_count, 'changes': dns.changes, 'missing': dns.miss, 'missing_count': p.missing_count, 'failed': [], 'failed_count': 0, 'drifted': [], 'drifted_count': 0, 'detail': want}] -%} {%- endfor -%} {{ ns.out }} # What the response's `switches` list holds on the paths that stop # before writing. Nothing has failed and nothing is pending on those # paths, so there is no switch worth naming and the list is empty # unless the caller asked to see every one of them. idle_switches: |- {%- set ns = namespace(out=[]) -%} {%- if want_full_detail -%} {%- for d in plan_devices -%} {%- set ns.out = ns.out + [{ 'name': d.name, 'device_id': d.device_id, 'status': 'in_sync', 'synced_count': 0, 'failed_count': 0, 'settings_that_failed': []}] -%} {%- endfor -%} {%- endif -%} {{ ns.out }} # The test run's equivalent: the switches that do not match the # source, each naming what would change on it. The count is # `pending_count` rather than a failure count because a test run has # not attempted a single write, and calling it a failure would send # someone looking for a problem that does not exist yet. pending_switches: |- {%- set total = source_settings | count -%} {%- set ns = namespace(out=[]) -%} {%- for d in plan_devices -%} {%- if d.change_count > 0 or want_full_detail -%} {%- set pns = namespace(t=[]) -%} {%- for c in d.changes -%} {%- set pns.t = pns.t + [c.key ~ ': currently ' ~ c['from'] ~ ', would become ' ~ c.to] -%} {%- endfor -%} {%- set ns.out = ns.out + [{ 'name': d.name, 'device_id': d.device_id, 'status': d.status, 'synced_count': total - d.missing_count - d.change_count, 'pending_count': d.change_count, 'settings_out_of_sync': pns.t}] -%} {%- endif -%} {%- endfor -%} {{ ns.out }} # "Switches in scope" always counts the source itself plus every # target, so the totals in the summary line add up to a whole network. switch_count: "{{ (target_devs | count) + 1 }}" switch_word: "{{ 'switch' if switch_count == 1 else 'switches' }}" source_info: |- {{ {'device_id': src_device, 'name': src_name, 'sw_version': src_sw | string, 'syncable_settings': source_settings | count} }} # Unconditional, and first, because everything downstream is measured # against what the source says. Working out which of these to read would # mean deciding from the very values in doubt, and there are at most # three of them on one switch, so reading all of them costs less than # the decision would. - alias: Re-read the source's blocked settings repeat: for_each: "{{ blocked_source }}" sequence: - action: homeassistant.update_entity continue_on_error: true target: entity_id: "{{ repeat.item }}" # A `variables:` block is rendered when its step is reached rather than # when the run starts, so this reads the source as the step above left # it. Held back from the planning block for that reason alone: computed # up there it would compare every target against the display the source # happened to be carrying, and correct the targets to a value the source # never agrees with, run after run. # # What comes out of here is a read list and nothing else, so none of it # reaches the response. - alias: Compare the blocked settings across the fleet variables: # The hard-blocked settings, compared across the fleet to find the # target entities whose display is behind. A setting the caller # unticked is left alone because that was their decision, but these # are the script's decision, and a switch showing a different value # while the report says everything matches is the one thing nobody # can explain from the report alone. Only the entities that disagree # are listed, because reading the rest is Zigbee traffic spent on # every run for nothing. blocked_refresh: |- {%- set sns = namespace(rows=[]) -%} {%- for s in source_scan -%} {%- set st = states(s.entity_id) -%} {%- if (s.key | lower) in never_write and st not in ['unknown', 'unavailable'] -%} {%- set sns.rows = sns.rows + [{ 'k': s.key ~ '|' ~ s.domain, 'domain': s.domain, 'state': st}] -%} {%- endif -%} {%- endfor -%} {%- set ns = namespace(refresh=[]) -%} {%- for dev in target_devs -%} {%- set tname = (device_attr(dev, 'name_by_user') or device_attr(dev, 'name') or dev) | string -%} {%- set tlen = tname | length -%} {%- set tns = namespace(rows=[]) -%} {%- for e in device_entities(dev) -%} {%- set dom = e.split('.')[0] -%} {%- if dom in ['number', 'select', 'switch'] -%} {%- set fn = state_attr(e, 'friendly_name') | default('', true) | string -%} {%- set tkey = (fn[tlen:] if fn[:tlen] == tname else fn) | trim -%} {%- if (tkey | lower) in never_write -%} {%- set tns.rows = tns.rows + [{ 'k': tkey ~ '|' ~ dom, 'entity_id': e, 'state': states(e)}] -%} {%- endif -%} {%- endif -%} {%- endfor -%} {%- set dns = namespace(e=[]) -%} {%- for s in sns.rows -%} {%- set m = tns.rows | selectattr('k', 'eq', s.k) | list -%} {%- if m | count > 0 -%} {%- set t = m | first -%} {%- set differs = (s.state | float(0) != t.state | float(0)) if s.domain == 'number' else (s.state | string != t.state | string) -%} {%- if differs -%} {%- set dns.e = dns.e + [t.entity_id] -%} {%- endif -%} {%- endif -%} {%- endfor -%} {%- if dns.e | count > 0 -%} {%- set ns.refresh = ns.refresh + [dns.e] -%} {%- endif -%} {%- endfor -%} {{ ns.refresh }} # Ahead of every exit path, because the run that most needs this is the # one that finds nothing to write and stops early. One switch's entities # per call, so the read is paced across the mesh rather than arriving as # one burst the coordinator has to queue. - alias: Re-read the blocked settings that disagree repeat: for_each: "{{ blocked_refresh }}" sequence: - action: homeassistant.update_entity continue_on_error: true target: entity_id: "{{ repeat.item }}" - alias: Bail out when there is nothing to work with if: - condition: template value_template: "{{ target_devs | count == 0 or source_settings | count == 0 }}" then: - variables: problems: |- {%- set model = device_attr(src_device, 'model') | default('unknown', true) -%} {%- set ns = namespace(problems=advisories) -%} {%- if target_devs | count == 0 -%} {%- set ns.problems = ns.problems + ['No target devices were found. Either name them explicitly in `target_devices`, or check that other ZHA devices report the same model as **' ~ src_name ~ '** (`' ~ model ~ '`).'] -%} {%- endif -%} {%- if source_settings | count == 0 -%} {%- set ns.problems = ns.problems + ['**' ~ src_name ~ '** exposes no syncable settings. Either the device is wrong, all of its number/select/switch entities are `unknown`/`unavailable`, or every checkbox in the five "Sync:" groups was cleared.'] -%} {%- endif -%} {{ ns.problems }} summary: |- {%- if target_devs | count == 0 and source_settings | count == 0 -%} Nothing to do: no target switches were found, and the source switch exposes no syncable settings. {%- elif target_devs | count == 0 -%} Nothing to do: no target switches were found to compare against. {%- else -%} Nothing to do: the source switch exposes no syncable settings. {%- endif -%} next_step: >- Nothing was written and nothing was compared. Resolve what `problems` describes, then run again. # The switch list under the name all four paths use for it, so # the response assembly and the notification below are the same # on every path rather than four variations on one idea. switches: "{{ idle_switches }}" generated_at: "{{ now().strftime('%Y-%m-%d %I:%M:%S %p %Z') }}" run_duration_seconds: >- {{ (now().timestamp() - (run_started_at | float(0))) | round(1) }} # Everything except the three conditional keys is rendered here, # one key at a time, so the single template that assembles the # response below carries nothing large beyond the switch list - # and that list is a projection of an already-bounded render, # smaller than the render it comes from. # # `switches` and `problems` are attached to the body below # rather than being keys of this mapping, and `next_step` is a # key here that is dropped again, because a key cannot be # conditionally absent from a YAML mapping. All three are # things a reader has to open and dismiss # when they are empty, and a next step that says there is no next # step is the emptiest of the three. report_head: status: nothing_to_do summary: "{{ summary }}" next_step: "{{ next_step }}" generated_at: "{{ generated_at | string }}" run_duration_seconds: "{{ run_duration_seconds | float(0) }}" test_run: "{{ is_test_run }}" total_switches: "{{ switch_count | int }}" switches_to_sync: 0 switches_updated: 0 switches_failed: 0 settings_to_sync: 0 settings_synced: 0 settings_failed: 0 full_detail: "{{ want_full_detail }}" source: "{{ source_info }}" result: >- {%- set body = report_head if next_step else dict( report_head.items() | rejectattr('0', 'eq', 'next_step')) -%} {%- if switches | count > 0 -%} {%- set body = dict(body, switches=switches) -%} {%- endif -%} {%- if problems | count > 0 -%} {%- set body = dict(body, problems=problems) -%} {%- endif -%} {{ body }} - action: persistent_notification.create data: notification_id: inovelli_blue_sync title: Inovelli Blue sync - nothing to do message: |- {%- set ns = namespace(out=['status: nothing_to_do', 'summary: ' ~ summary]) -%} {%- if next_step -%} {%- set ns.out = ns.out + ['next_step: ' ~ next_step] -%} {%- endif -%} {%- set ns.out = ns.out + ['', '```', 'generated_at: ' ~ generated_at, 'run_duration_seconds: ' ~ run_duration_seconds, 'test_run: ' ~ (is_test_run | lower), '', 'total_switches: ' ~ (switch_count | int), 'switches_to_sync: 0', 'switches_updated: 0', 'switches_failed: 0', 'settings_to_sync: 0', 'settings_synced: 0', 'settings_failed: 0', '', 'source:', ' device_id: ' ~ src_device, ' name: ' ~ src_name, ' sw_version: ' ~ src_sw, ' syncable_settings: ' ~ (source_settings | count)] -%} {%- if switches | count > 0 -%} {%- set ns.out = ns.out + ['', 'switches:'] -%} {%- for s in switches -%} {%- set ns.out = ns.out + ['', ' ' ~ s.name, ' synced_count: ' ~ s.synced_count, ' failed_count: ' ~ s.failed_count] -%} {%- for line in s.settings_that_failed -%} {%- if loop.first -%} {%- set ns.out = ns.out + [' settings_that_failed:'] -%} {%- endif -%} {%- set ns.out = ns.out + [' - ' ~ line] -%} {%- endfor -%} {%- endfor -%} {%- endif -%} {%- set ns.out = ns.out + ['```'] -%} {%- if problems | count > 0 -%} {%- set ns.out = ns.out + ['', 'problems:', ''] + (problems | map('regex_replace', '^', '- ') | list) -%} {%- endif -%} {{ ns.out | join('\n') }} - stop: Nothing to sync response_variable: result - alias: Bail out when every target already matches if: - condition: template value_template: "{{ change_count | int == 0 }}" then: - variables: summary: |- {%- if switch_count == 1 -%} The only switch in scope is the source, so there is nothing to sync. {%- else -%} All {{ switch_count }} switches are already in sync; nothing needs to be updated. {%- endif -%} # Every switch already matches the source, so there is nothing to # write and nothing for anyone to do about it. An empty next step # is dropped from the response rather than returned as a sentence # saying so. next_step: "" problems: "{{ advisories }}" switches: "{{ idle_switches }}" generated_at: "{{ now().strftime('%Y-%m-%d %I:%M:%S %p %Z') }}" run_duration_seconds: >- {{ (now().timestamp() - (run_started_at | float(0))) | round(1) }} report_head: status: already_in_sync summary: "{{ summary }}" next_step: "{{ next_step }}" generated_at: "{{ generated_at | string }}" run_duration_seconds: "{{ run_duration_seconds | float(0) }}" test_run: "{{ is_test_run }}" total_switches: "{{ switch_count | int }}" switches_to_sync: 0 switches_updated: 0 switches_failed: 0 settings_to_sync: 0 settings_synced: 0 settings_failed: 0 full_detail: "{{ want_full_detail }}" source: "{{ source_info }}" result: >- {%- set body = report_head if next_step else dict( report_head.items() | rejectattr('0', 'eq', 'next_step')) -%} {%- if switches | count > 0 -%} {%- set body = dict(body, switches=switches) -%} {%- endif -%} {%- if problems | count > 0 -%} {%- set body = dict(body, problems=problems) -%} {%- endif -%} {{ body }} - action: persistent_notification.create data: notification_id: inovelli_blue_sync title: Inovelli Blue sync - already in sync message: |- {%- set ns = namespace(out=['status: already_in_sync', 'summary: ' ~ summary]) -%} {%- if next_step -%} {%- set ns.out = ns.out + ['next_step: ' ~ next_step] -%} {%- endif -%} {%- set ns.out = ns.out + ['', '```', 'generated_at: ' ~ generated_at, 'run_duration_seconds: ' ~ run_duration_seconds, 'test_run: ' ~ (is_test_run | lower), '', 'total_switches: ' ~ (switch_count | int), 'switches_to_sync: 0', 'switches_updated: 0', 'switches_failed: 0', 'settings_to_sync: 0', 'settings_synced: 0', 'settings_failed: 0', '', 'source:', ' device_id: ' ~ src_device, ' name: ' ~ src_name, ' sw_version: ' ~ src_sw, ' syncable_settings: ' ~ (source_settings | count)] -%} {%- if switches | count > 0 -%} {%- set ns.out = ns.out + ['', 'switches:'] -%} {%- for s in switches -%} {%- set ns.out = ns.out + ['', ' ' ~ s.name, ' synced_count: ' ~ s.synced_count, ' failed_count: ' ~ s.failed_count] -%} {%- for line in s.settings_that_failed -%} {%- if loop.first -%} {%- set ns.out = ns.out + [' settings_that_failed:'] -%} {%- endif -%} {%- set ns.out = ns.out + [' - ' ~ line] -%} {%- endfor -%} {%- endfor -%} {%- endif -%} {%- set ns.out = ns.out + ['```'] -%} {%- if problems | count > 0 -%} {%- set ns.out = ns.out + ['', 'problems:', ''] + (problems | map('regex_replace', '^', '- ') | list) -%} {%- endif -%} {{ ns.out | join('\n') }} - stop: Targets already match the source response_variable: result - alias: Test run - report the proposed changes and stop if: - condition: template value_template: "{{ is_test_run }}" then: - variables: summary: |- {%- if out_of_sync_count == 0 and switch_count == 1 -%} The only switch in scope is the source, so there is nothing to sync. {%- elif out_of_sync_count == 0 -%} All {{ switch_count }} switches are already in sync; nothing needs to be updated. {%- else -%} Out of the {{ switch_count }} {{ switch_word }}, {{ out_of_sync_count }} will be updated since {{ 'it is' if out_of_sync_count == 1 else 'they are' }} out of sync. {%- endif -%} # A test run that found nothing to change leaves nothing to # re-run for, so the next step is empty and dropped. next_step: |- {%- if out_of_sync_count > 0 -%} Nothing has been written. Re-run with `test_run: false` to apply the {{ change_count }} setting{{ ' that is' if change_count | int == 1 else 's that are' }} out of sync. {%- endif -%} switches: "{{ pending_switches }}" problems: |- {%- set ns = namespace(p=advisories) -%} {#- A switch can be listed with an exact count beside an empty list, because naming every setting on every switch is the render that does not fit. Saying which case it is beats leaving someone to work out why the names stop. -#} {%- set unnamed = (switches | sum(attribute='pending_count')) - (switches | map(attribute='settings_out_of_sync') | map('count') | sum) -%} {%- if unnamed > 0 and detail_clamped -%} {%- set ns.p = ns.p + [unnamed ~ ' setting' ~ (' is' if unnamed == 1 else 's are') ~ ' out of sync but not named, because naming every setting on every switch at this many switches and settings would exceed the Home Assistant template output limit and the run would fail rather than return anything. Every count is exact regardless.'] -%} {%- elif unnamed > 0 -%} {%- set ns.p = ns.p + [unnamed ~ ' setting' ~ (' is' if unnamed == 1 else 's are') ~ ' out of sync but not named. Re-run with `full_detail: true` to name as many of them as fit inside the Home Assistant template output limit. Every count is exact regardless.'] -%} {%- endif -%} {%- set omitted = (plan_devices | count) - (switches | count) -%} {%- if omitted > 0 and switches | count > 0 -%} {%- set ns.p = ns.p + ['Only the switches that need attention are listed. Re-run with `full_detail: true` to list the other ' ~ omitted ~ (' switch as well.' if omitted == 1 else ' switches as well.')] -%} {%- endif -%} {{ ns.p }} generated_at: "{{ now().strftime('%Y-%m-%d %I:%M:%S %p %Z') }}" run_duration_seconds: >- {{ (now().timestamp() - (run_started_at | float(0))) | round(1) }} report_head: status: test_run summary: "{{ summary }}" next_step: "{{ next_step }}" generated_at: "{{ generated_at | string }}" run_duration_seconds: "{{ run_duration_seconds | float(0) }}" test_run: true total_switches: "{{ switch_count | int }}" switches_to_sync: "{{ out_of_sync_count | int }}" switches_updated: 0 switches_failed: 0 settings_to_sync: "{{ change_count | int }}" settings_synced: 0 settings_failed: 0 full_detail: "{{ want_full_detail }}" source: "{{ source_info }}" result: >- {%- set body = report_head if next_step else dict( report_head.items() | rejectattr('0', 'eq', 'next_step')) -%} {%- if switches | count > 0 -%} {%- set body = dict(body, switches=switches) -%} {%- endif -%} {%- if problems | count > 0 -%} {%- set body = dict(body, problems=problems) -%} {%- endif -%} {{ body }} - action: persistent_notification.create data: notification_id: inovelli_blue_sync title: Inovelli Blue sync - test run message: |- {%- set ns = namespace(out=['status: test_run', 'summary: ' ~ summary]) -%} {%- if next_step -%} {%- set ns.out = ns.out + ['next_step: ' ~ next_step] -%} {%- endif -%} {%- set ns.out = ns.out + ['', '```', 'generated_at: ' ~ generated_at, 'run_duration_seconds: ' ~ run_duration_seconds, 'test_run: true', '', 'total_switches: ' ~ (switch_count | int), 'switches_to_sync: ' ~ (out_of_sync_count | int), 'switches_updated: 0', 'switches_failed: 0', 'settings_to_sync: ' ~ (change_count | int), 'settings_synced: 0', 'settings_failed: 0', '', 'source:', ' device_id: ' ~ src_device, ' name: ' ~ src_name, ' sw_version: ' ~ src_sw, ' syncable_settings: ' ~ (source_settings | count)] -%} {%- if switches | count > 0 -%} {%- set ns.out = ns.out + ['', 'switches:'] -%} {%- for s in switches -%} {%- set ns.out = ns.out + ['', ' ' ~ s.name, ' synced_count: ' ~ s.synced_count, ' pending_count: ' ~ s.pending_count] -%} {%- for line in s.settings_out_of_sync -%} {%- if loop.first -%} {%- set ns.out = ns.out + [' settings_out_of_sync:'] -%} {%- endif -%} {%- set ns.out = ns.out + [' - ' ~ line] -%} {%- endfor -%} {%- endfor -%} {%- endif -%} {%- set ns.out = ns.out + ['```'] -%} {%- if problems | count > 0 -%} {%- set ns.out = ns.out + ['', 'problems:', ''] + (problems | map('regex_replace', '^', '- ') | list) -%} {%- endif -%} {{ ns.out | join('\n') }} - stop: Test run complete response_variable: result - alias: Write the plan repeat: for_each: "{{ plan_targets }}" sequence: # A full run can take an hour, and the response data only arrives # once the script has finished, so this is the only signal that it # is still working. Every progress update and the final report share # one notification_id, so each one replaces the last rather than # stacking up. Reported per switch rather than per write: the inner # loop shadows `repeat`, and per write would be thousands of # updates for no extra information. - alias: Show write progress action: persistent_notification.create data: notification_id: inovelli_blue_sync title: Inovelli Blue sync - writing message: >- {%- set n = plan_targets | count -%} {%- set dname = device_attr(repeat.item, 'name_by_user') or device_attr(repeat.item, 'name') or repeat.item -%} Writing switch {{ repeat.index }} of {{ n }}, {{ n - repeat.index }} still to go. Currently on **{{ dname }}**. Nothing is verified yet - what actually stuck is checked once every switch has been written. # Derived here rather than up front: this is one switch's worth of # writes, so it stays small no matter how large the fleet is. - alias: Work out what this switch needs variables: dev_writes: |- {%- set dev = repeat.item -%} {%- set dname = (device_attr(dev, 'name_by_user') or device_attr(dev, 'name') or dev) | string -%} {%- set nlen = dname | length -%} {%- set tns = namespace(rows=[]) -%} {%- for e in device_entities(dev) -%} {%- set dom = e.split('.')[0] -%} {%- if dom in ['number', 'select', 'switch'] -%} {%- set st = states(e) -%} {%- set fn = state_attr(e, 'friendly_name') | default('', true) | string -%} {%- set tkey = (fn[nlen:] if fn[:nlen] == dname else fn) | trim -%} {%- if tkey != '' and st not in ['unknown', 'unavailable'] -%} {%- set tns.rows = tns.rows + [{ 'k': tkey ~ '|' ~ dom, 'entity_id': e, 'state': st}] -%} {%- endif -%} {%- endif -%} {%- endfor -%} {%- set ns = namespace(w=[]) -%} {%- for s in source_settings -%} {%- set m = tns.rows | selectattr('k', 'eq', s.key ~ '|' ~ s.domain) | list -%} {%- if m | count > 0 -%} {%- set t = (m | first) -%} {%- set differs = (s.state | float(0) != t.state | float(0)) if s.domain == 'number' else (s.state | string != t.state | string) -%} {%- if differs -%} {%- set ns.w = ns.w + [{ 'key': s.key, 'entity_id': t.entity_id, 'domain': s.domain, 'to': s.state}] -%} {%- endif -%} {%- endif -%} {%- endfor -%} {#- The load limits are the one pair with no safe fixed order, because either one can be rejected for crossing the value the switch currently holds for the other. Writing max, then min, then max again always lands, because the source's own pair is by definition valid, and it costs one extra write only on the switches where both actually change. README.md, under "Write order", works the case through. -#} {%- set mx = ns.w | selectattr('key', 'eq', 'Maximum load dimming level') | list -%} {%- set mn = ns.w | selectattr('key', 'eq', 'Minimum load dimming level') | list -%} {%- if mx | count > 0 and mn | count > 0 -%} {%- set ns2 = namespace(w=[]) -%} {%- for w in ns.w -%} {%- set ns2.w = ns2.w + [w] -%} {%- if w.key == 'Minimum load dimming level' -%} {%- set ns2.w = ns2.w + [mx | first] -%} {%- endif -%} {%- endfor -%} {%- set ns.w = ns2.w -%} {%- endif -%} {{ ns.w }} - alias: Write this switch's settings repeat: for_each: "{{ dev_writes }}" sequence: - choose: - alias: Number setting conditions: - condition: template value_template: "{{ repeat.item.domain == 'number' }}" sequence: - action: number.set_value continue_on_error: true target: entity_id: "{{ repeat.item.entity_id }}" data: value: "{{ repeat.item.to | float }}" - alias: Select setting conditions: - condition: template value_template: "{{ repeat.item.domain == 'select' }}" sequence: - action: select.select_option continue_on_error: true target: entity_id: "{{ repeat.item.entity_id }}" data: option: "{{ repeat.item.to }}" - alias: Switch setting on conditions: - condition: template value_template: >- {{ repeat.item.domain == 'switch' and repeat.item.to == 'on' }} sequence: - action: switch.turn_on continue_on_error: true target: entity_id: "{{ repeat.item.entity_id }}" - alias: Switch setting off conditions: - condition: template value_template: >- {{ repeat.item.domain == 'switch' and repeat.item.to != 'on' }} sequence: - action: switch.turn_off continue_on_error: true target: entity_id: "{{ repeat.item.entity_id }}" - delay: seconds: "{{ write_delay_s }}" - alias: Let the mesh settle delay: seconds: "{{ [write_delay_s * 4, 2] | max }}" - alias: Re-read the switches that were written repeat: for_each: "{{ plan_targets }}" sequence: - alias: Show verification progress action: persistent_notification.create data: notification_id: inovelli_blue_sync title: Inovelli Blue sync - checking results message: >- {%- set n = plan_targets | count -%} {%- set dname = device_attr(repeat.item, 'name_by_user') or device_attr(repeat.item, 'name') or repeat.item -%} All {{ n }} switches have been written. Re-reading switch {{ repeat.index }} of {{ n }}, {{ n - repeat.index }} still to go. Currently on **{{ dname }}**. - action: homeassistant.update_entity continue_on_error: true target: # One switch's syncable entities per call, so this target list # never grows with the size of the fleet. # # The never-written settings are read here as well. Writing the # Inovelli setting one of them is a view of changes the value it # answers with, and Home Assistant never asks again on its own, # so a switch left out here goes on displaying what it held # before the write - which reads as two switches disagreeing # when they hold the same value. entity_id: |- {%- set dev = repeat.item -%} {%- set dname = (device_attr(dev, 'name_by_user') or device_attr(dev, 'name') or dev) | string -%} {%- set nlen = dname | length -%} {%- set want = (source_settings | map(attribute='key') | map('lower') | list) + never_write -%} {%- set ns = namespace(e=[]) -%} {%- for e in device_entities(dev) -%} {%- if e.split('.')[0] in ['number', 'select', 'switch'] -%} {%- set fn = state_attr(e, 'friendly_name') | default('', true) | string -%} {%- set tkey = (fn[nlen:] if fn[:nlen] == dname else fn) | trim -%} {%- if tkey != '' and tkey | lower in want -%} {%- set ns.e = ns.e + [e] -%} {%- endif -%} {%- endif -%} {%- endfor -%} {{ ns.e }} - alias: Verify the writes and build the response variables: # Verification compares every syncable setting the target exposes # against the source, then splits the mismatches in two using the # planned-write mask: # # planned and still wrong -> a write failure. The switch was told to # change this and did not. # never planned and now wrong -> drift. The switch already matched, # was never written, and moved anyway as a side effect of the # other writes. This happens because two entities can be views of # one device register, so writing one moves the other. # # Only write failures decide a switch's status or feed the failure # counts. Counting drift as failure reports a healthy network as # almost entirely failed, since a coupled register moves everywhere at # once while genuine write failures are isolated. verified: |- {%- set lim = detail_fail_limit | int -%} {%- set dlim = detail_entry_limit | int -%} {%- set devlim = detail_device_limit | int -%} {%- set ns = namespace(out=[], written=0, bad=0, upd=0, fail=0, drifted=0, drift_total=0, fdev=0, budget=detail_fail_budget | int, dcounts=[0] * (source_settings | count)) -%} {%- for d in plan_devices -%} {%- set mask = plan_counts[loop.index0].planned -%} {%- set nlen = d.name | length -%} {%- set tns = namespace(rows=[]) -%} {%- for e in device_entities(d.device_id) -%} {%- set dom = e.split('.')[0] -%} {%- if dom in ['number', 'select', 'switch'] -%} {%- set fn = state_attr(e, 'friendly_name') | default('', true) | string -%} {%- set tkey = (fn[nlen:] if fn[:nlen] == d.name else fn) | trim -%} {%- if tkey != '' -%} {%- set tns.rows = tns.rows + [{ 'k': tkey ~ '|' ~ dom, 'entity_id': e, 'state': states(e)}] -%} {%- endif -%} {%- endif -%} {%- endfor -%} {%- set keys = tns.rows | map(attribute='k') | list -%} {%- set fns = namespace(bad=[], count=0, drift=[], dcount=0) -%} {%- for s in source_settings -%} {%- set lk = s.key ~ '|' ~ s.domain -%} {%- if lk in keys -%} {%- set t = (tns.rows | selectattr('k', 'eq', lk) | first) -%} {%- set same = (s.state | float(0) == t.state | float(0)) if s.domain == 'number' else (s.state | string == t.state | string) -%} {%- if not same or t.state in ['unknown', 'unavailable'] -%} {%- if mask[loop.index0] == '1' -%} {%- set fns.count = fns.count + 1 -%} {%- if fns.bad | count < lim -%} {%- set fns.bad = fns.bad + [{ 'key': s.key, 'want': s.state, 'got': t.state}] -%} {%- endif -%} {%- else -%} {%- set fns.dcount = fns.dcount + 1 -%} {%- set i = loop.index0 -%} {%- set ns.dcounts = ns.dcounts[:i] + [ns.dcounts[i] + 1] + ns.dcounts[i + 1:] -%} {%- if fns.drift | count < dlim and d.detail -%} {%- set fns.drift = fns.drift + [{ 'key': s.key, 'want': s.state, 'got': t.state}] -%} {%- endif -%} {%- endif -%} {%- endif -%} {%- endif -%} {%- endfor -%} {#- Failures are never dropped entirely. Once the fleet-wide budget is spent a failing switch still names one of them, so failed_count > 0 always comes with something to look at. -#} {%- set keep_n = 0 -%} {%- if fns.count > 0 -%} {%- set keep_n = fns.bad | count if ns.budget >= fns.bad | count else 1 -%} {%- set ns.budget = ns.budget - keep_n -%} {%- endif -%} {%- set st = 'in_sync' if d.change_count == 0 and fns.count == 0 else ('updated' if fns.count == 0 else 'failed') -%} {%- set ok = [d.change_count - fns.count, 0] | max -%} {%- set ns.written = ns.written + ok -%} {%- set ns.bad = ns.bad + fns.count -%} {%- set ns.drift_total = ns.drift_total + fns.dcount -%} {%- if fns.dcount > 0 -%}{%- set ns.drifted = ns.drifted + 1 -%}{%- endif -%} {%- if st == 'updated' -%}{%- set ns.upd = ns.upd + 1 -%}{%- endif -%} {%- if st == 'failed' -%}{%- set ns.fail = ns.fail + 1 -%}{%- endif -%} {%- set ns.out = ns.out + [{ 'device_id': d.device_id, 'name': d.name, 'sw_version': d.sw_version, 'firmware_differs': d.firmware_differs, 'status': st, 'change_count': d.change_count, 'changes': d.changes, 'missing': d.missing, 'missing_count': d.missing_count, 'failed': fns.bad[:keep_n], 'failed_count': fns.count, 'drifted': fns.drift, 'drifted_count': fns.dcount, 'detail': d.detail or keep_n > 0}] -%} {%- endfor -%} {#- Which settings drifted, fleet-wide. Drift is usually one firmware coupling hitting the same setting on every switch, so this names the culprit in a couple of lines instead of hundreds. -#} {%- set dns = namespace(rows=[]) -%} {%- for i in range(source_settings | count) -%} {%- if ns.dcounts[i] > 0 -%} {%- set dns.rows = dns.rows + [{ 'key': source_settings[i].key, 'switch_count': ns.dcounts[i]}] -%} {%- endif -%} {%- endfor -%} {{ {'devices': ns.out, 'written': ns.written, 'failed_writes': ns.bad, 'updated': ns.upd, 'failed': ns.fail, 'drifted': ns.drifted, 'drift_total': ns.drift_total, 'drift_settings': dns.rows | sort(attribute='switch_count', reverse=true)} }} summary: |- {%- set u = verified.updated -%} {%- set f = verified.failed -%} {%- set dd = verified.drifted -%} {%- set dt = verified.drift_total -%} Out of the {{ switch_count }} {{ switch_word }}, {{ u }} {{ 'was' if u == 1 else 'were' }} updated{{ ' and ' ~ f ~ ' failed to update' if f > 0 else '' }}.{{ ' Separately, ' ~ dt ~ (' setting on ' if dt == 1 else ' settings on ') ~ dd ~ (' switch' if dd == 1 else ' switches') ~ ' drifted as a side effect of other writes.' if dd > 0 else '' }} # A run where every planned setting was written and read back clean # leaves nothing to act on, so the next step is empty and dropped # rather than returned as a sentence confirming there is none. next_step: |- {%- if verified.failed_writes > 0 -%} Re-run the sync to retry the {{ verified.failed_writes }} setting{{ '' if verified.failed_writes == 1 else 's' }} that did not stick. Anything that keeps failing run after run will not take over Zigbee, so set it by hand on the switch it belongs to. {%- endif -%} # The failed settings a switch names as one readable line each, rather # than as a want/got mapping. Every switch is listed when the caller # asked for the full picture; otherwise only the ones that failed, # because a list of switches that worked is a list nobody reads. switches: |- {%- set ns = namespace(out=[]) -%} {%- for d in verified.devices -%} {%- if want_full_detail or d.status == 'failed' -%} {%- set fns = namespace(t=[]) -%} {%- for x in d.failed -%} {%- set fns.t = fns.t + [x.key ~ ': wanted ' ~ x.want ~ ', still ' ~ x.got] -%} {%- endfor -%} {%- set ns.out = ns.out + [{ 'name': d.name, 'device_id': d.device_id, 'status': d.status, 'synced_count': [d.change_count - d.failed_count, 0] | max, 'failed_count': d.failed_count, 'settings_that_failed': fns.t}] -%} {%- endif -%} {%- endfor -%} {{ ns.out }} # Drift and an unnamed failure are the two things worth saying in # prose rather than as a count: one is not a failure at all and the # other is a limit of the report rather than of the run. problems: |- {%- set ns = namespace(p=advisories) -%} {%- if verified.drift_settings | count > 0 -%} {%- set dt = verified.drift_total -%} {%- set dd = verified.drifted -%} {%- set ns.p = ns.p + [dt ~ (' setting on ' if dt == 1 else ' settings on ') ~ dd ~ (' switch' if dd == 1 else ' switches') ~ ' changed without ever being written, as a side effect of the other writes: ' ~ (verified.drift_settings | map(attribute='key') | join(', ')) ~ '. These are not write failures: two settings can be views of one register in the firmware, so writing one moves the other. Untick the derived setting if a run keeps fighting the firmware over it.'] -%} {%- endif -%} {%- set unnamed = verified.devices | sum(attribute='failed_count') - (verified.devices | map(attribute='failed') | map('count') | sum) -%} {%- if unnamed > 0 -%} {%- set ns.p = ns.p + [unnamed ~ (' failed setting is' if unnamed == 1 else ' failed settings are') ~ ' counted but not named. Naming every one of them would exceed the Home Assistant template output limit, and the run would fail rather than return anything. Every count is exact regardless.'] -%} {%- endif -%} {#- Worth saying only when the list it explains has something in it: on a run where nothing failed there is nothing being held back that anyone needs to go looking for. -#} {%- set omitted = (verified.devices | count) - (switches | count) -%} {%- if omitted > 0 and switches | count > 0 -%} {%- set ns.p = ns.p + ['Only the switches that need attention are listed. Re-run with `full_detail: true` to list the other ' ~ omitted ~ (' switch as well.' if omitted == 1 else ' switches as well.')] -%} {%- endif -%} {{ ns.p }} generated_at: "{{ now().strftime('%Y-%m-%d %I:%M:%S %p %Z') }}" run_duration_seconds: >- {{ (now().timestamp() - (run_started_at | float(0))) | round(1) }} report_head: status: completed summary: "{{ summary }}" next_step: "{{ next_step }}" generated_at: "{{ generated_at | string }}" run_duration_seconds: "{{ run_duration_seconds | float(0) }}" test_run: false total_switches: "{{ switch_count | int }}" switches_to_sync: "{{ out_of_sync_count | int }}" switches_updated: "{{ verified.updated }}" switches_failed: "{{ verified.failed }}" settings_to_sync: "{{ change_count | int }}" settings_synced: "{{ verified.written }}" settings_failed: "{{ verified.failed_writes }}" full_detail: "{{ want_full_detail }}" source: "{{ source_info }}" result: >- {%- set body = report_head if next_step else dict( report_head.items() | rejectattr('0', 'eq', 'next_step')) -%} {%- if switches | count > 0 -%} {%- set body = dict(body, switches=switches) -%} {%- endif -%} {%- if problems | count > 0 -%} {%- set body = dict(body, problems=problems) -%} {%- endif -%} {{ body }} - alias: Report the results action: persistent_notification.create data: notification_id: inovelli_blue_sync title: Inovelli Blue sync - results message: |- {%- set ns = namespace(out=['status: completed', 'summary: ' ~ summary]) -%} {%- if next_step -%} {%- set ns.out = ns.out + ['next_step: ' ~ next_step] -%} {%- endif -%} {%- set ns.out = ns.out + ['', '```', 'generated_at: ' ~ generated_at, 'run_duration_seconds: ' ~ run_duration_seconds, 'test_run: false', '', 'total_switches: ' ~ (switch_count | int), 'switches_to_sync: ' ~ (out_of_sync_count | int), 'switches_updated: ' ~ verified.updated, 'switches_failed: ' ~ verified.failed, 'settings_to_sync: ' ~ (change_count | int), 'settings_synced: ' ~ verified.written, 'settings_failed: ' ~ verified.failed_writes, '', 'source:', ' device_id: ' ~ src_device, ' name: ' ~ src_name, ' sw_version: ' ~ src_sw, ' syncable_settings: ' ~ (source_settings | count)] -%} {%- if switches | count > 0 -%} {%- set ns.out = ns.out + ['', 'switches:'] -%} {%- for s in switches -%} {%- set ns.out = ns.out + ['', ' ' ~ s.name, ' synced_count: ' ~ s.synced_count, ' failed_count: ' ~ s.failed_count] -%} {%- for line in s.settings_that_failed -%} {%- if loop.first -%} {%- set ns.out = ns.out + [' settings_that_failed:'] -%} {%- endif -%} {%- set ns.out = ns.out + [' - ' ~ line] -%} {%- endfor -%} {%- endfor -%} {%- endif -%} {%- set ns.out = ns.out + ['```'] -%} {%- if problems | count > 0 -%} {%- set ns.out = ns.out + ['', 'problems:', ''] + (problems | map('regex_replace', '^', '- ') | list) -%} {%- endif -%} {{ ns.out | join('\n') }} - alias: Return the response data stop: Sync complete response_variable: result