# Stream Dock N4 Pro — the Home Assistant half of the four-dial lighting control. # # Drop this file in HA's config as `packages/stream_dock.yaml` and make sure # configuration.yaml has: # # homeassistant: # packages: !include_dir_named packages # # `stream-dock/install-ha-package.sh` does both of those for you. # # WHY THIS EXISTS AT ALL, instead of the dock calling light.turn_on directly: # a rotary encoder produces a *relative* movement ("three ticks clockwise"), and # Home Assistant has no relative-colour service. There is `brightness_step_pct`, # but nothing equivalent for a colour channel — turning the red dial up means # reading the lamp's current rgb_color, adding to one element, and writing all # three back. That read has to happen where the state lives, which is here. The # Stream Deck plugin only ever sends "which channel, how many ticks". # # Every script takes `entity_id` explicitly rather than baking a room in, so one # copy of this package serves every room and a second dock needs no new HA config. # The per-room part lives in the dock's own button bindings — see # `stream-dock/generated/bindings.md`, which is generated from CoreSystemConfig.json. # # NOTHING HERE HAS RUN AGAINST A REAL HOME ASSISTANT. It is written from the # template documentation and reviewed by hand; the first thing to do once HA is up # is run each script from Developer Tools -> Actions with a real light before # binding a single dial. See stream-dock/README.md §"What is unverified". script: stream_dock_channel_adjust: alias: "Stream Dock: nudge one RGB channel" description: >- Relative colour change from an encoder. Reads the target's current rgb_color, moves one channel by ticks*step, clamps to 0-255 and writes all three back. mode: queued max: 30 fields: entity_id: name: Lights description: The light(s) this dock controls. Required — never defaulted. required: true selector: entity: multiple: true filter: domain: light channel: name: Channel required: true selector: select: options: [r, g, b] ticks: name: Ticks description: Encoder movement. Negative is counter-clockwise. required: true selector: number: { min: -64, max: 64, mode: box } step: name: Step description: 0-255 units per tick. default: 8 selector: number: { min: 1, max: 64, mode: box } variables: # entity_id arrives as a list from the dock, but a hand-run from Developer # Tools is just as likely to pass a bare string. Normalise both. targets: >- {{ entity_id if entity_id is not string else [entity_id] }} # Which lamp's colour counts as "the current colour" when the dial drives # several. A lit one, if there is one — an off lamp reports rgb_color: none # in most integrations, and starting from white every time you touch the dial # while one lamp of three is off is the bug this line exists to avoid. reference: >- {{ (expand(targets) | selectattr('state', 'eq', 'on') | map(attribute='entity_id') | list + (targets | list)) | first }} base: >- {{ (state_attr(reference, 'rgb_color') or [255, 255, 255]) | list }} index: "{{ {'r': 0, 'g': 1, 'b': 2}.get(channel, '0') | int }}" delta: "{{ ((ticks | float(0)) * (step | float(8))) | round | int }}" value: "{{ [[(base[index] | int) + delta, 255] | min, 0] | max }}" target_rgb: "{{ base[:index] + [value] + base[index + 1:] }}" sequence: - action: light.turn_on target: entity_id: "{{ targets }}" data: rgb_color: "{{ target_rgb }}" stream_dock_channel_set: alias: "Stream Dock: set one RGB channel absolutely" description: >- The absolute-position variant, for a dial bound to {{ rotationPercent }} instead of {{ ticks }}. Same colour maths, but the dial's accumulated position IS the channel value rather than a nudge to it. mode: queued max: 30 fields: entity_id: name: Lights required: true selector: entity: multiple: true filter: domain: light channel: name: Channel required: true selector: select: options: [r, g, b] percent: name: Percent description: 0-100, mapped onto 0-255. required: true selector: number: { min: 0, max: 100, mode: box } variables: targets: >- {{ entity_id if entity_id is not string else [entity_id] }} reference: >- {{ (expand(targets) | selectattr('state', 'eq', 'on') | map(attribute='entity_id') | list + (targets | list)) | first }} base: >- {{ (state_attr(reference, 'rgb_color') or [255, 255, 255]) | list }} index: "{{ {'r': 0, 'g': 1, 'b': 2}.get(channel, '0') | int }}" value: "{{ [[((percent | float(0)) * 2.55) | round | int, 255] | min, 0] | max }}" target_rgb: "{{ base[:index] + [value] + base[index + 1:] }}" sequence: - action: light.turn_on target: entity_id: "{{ targets }}" data: rgb_color: "{{ target_rgb }}" stream_dock_channel_extreme: alias: "Stream Dock: slam one RGB channel to an end stop" description: >- The dial's push action. A channel that is anywhere above zero goes to zero; a channel already at zero goes to full. Two presses always get you back to where you were, which is what makes it safe to press by accident. mode: queued max: 10 fields: entity_id: name: Lights required: true selector: entity: multiple: true filter: domain: light channel: name: Channel required: true selector: select: options: [r, g, b] variables: targets: >- {{ entity_id if entity_id is not string else [entity_id] }} reference: >- {{ (expand(targets) | selectattr('state', 'eq', 'on') | map(attribute='entity_id') | list + (targets | list)) | first }} base: >- {{ (state_attr(reference, 'rgb_color') or [255, 255, 255]) | list }} index: "{{ {'r': 0, 'g': 1, 'b': 2}.get(channel, '0') | int }}" value: "{{ 0 if (base[index] | int) > 0 else 255 }}" target_rgb: "{{ base[:index] + [value] + base[index + 1:] }}" sequence: - action: light.turn_on target: entity_id: "{{ targets }}" data: rgb_color: "{{ target_rgb }}" stream_dock_brightness_adjust: alias: "Stream Dock: nudge brightness" description: >- The fourth dial. Unlike colour, HA has a native relative service parameter for this (brightness_step_pct), so there is no read-modify-write here — and it is the integration, not this script, that decides what 0% means (most turn the lamp off). mode: queued max: 30 fields: entity_id: name: Lights required: true selector: entity: multiple: true filter: domain: light ticks: name: Ticks required: true selector: number: { min: -64, max: 64, mode: box } step_pct: name: Step description: Percentage points per tick. default: 5 selector: number: { min: 1, max: 50, mode: box } variables: targets: >- {{ entity_id if entity_id is not string else [entity_id] }} delta: "{{ ((ticks | float(0)) * (step_pct | float(5))) | round | int }}" sequence: - action: light.turn_on target: entity_id: "{{ targets }}" data: brightness_step_pct: "{{ delta }}" stream_dock_toggle: alias: "Stream Dock: toggle the room" mode: single fields: entity_id: name: Lights required: true selector: entity: multiple: true filter: domain: light sequence: - action: light.toggle target: entity_id: >- {{ entity_id if entity_id is not string else [entity_id] }} stream_dock_set_color: alias: "Stream Dock: set a preset colour" description: >- The key presets. brightness_pct is optional: left out, the lamp keeps whatever brightness it already had, which is what you want from a key that only means "make it green". mode: queued max: 10 fields: entity_id: name: Lights required: true selector: entity: multiple: true filter: domain: light rgb: name: RGB description: "[r, g, b], each 0-255." required: true selector: object: brightness_pct: name: Brightness description: Omit to leave brightness alone. selector: number: { min: 1, max: 100, mode: box } variables: targets: >- {{ entity_id if entity_id is not string else [entity_id] }} sequence: # Two branches rather than one templated `data:` block. A template that returns # a whole service-data mapping is a trick that may or may not be supported # depending on the HA version, and this script is the one thing on the dock that # eight keys depend on — so it uses only the boring construct. - choose: - conditions: - condition: template value_template: "{{ brightness_pct is defined and brightness_pct not in [none, ''] }}" sequence: - action: light.turn_on target: entity_id: "{{ targets }}" data: rgb_color: "{{ rgb }}" brightness_pct: "{{ brightness_pct | int }}" default: - action: light.turn_on target: entity_id: "{{ targets }}" data: rgb_color: "{{ rgb }}"