diff --git a/README.md b/README.md index 8d49952..ef5d484 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,7 @@ Each button can take the following configuration | Variable name | Allow template | Description | Default | Type | |:------------------------|:-----------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------|:----------------------------------------------------------------------------------------------------------------| -| `entity_id` | ✅ | The `entity_id` that this button controls. This entitity will be passed to the `service` when the button is pressed. The button is re-rendered whenever the state of this entity changes. | | `Optional[str]` | +| `entity_id` | ✅ | The `entity_id` that this button controls. This entity will be passed to the `service` when the button is pressed. The button is re-rendered whenever the state of this entity changes. | | `Optional[str]` | | `linked_entity` | ✅ | A secondary entity_id that is used for updating images and states | | `Optional[str]` | | `service` | ✅ | The `service` that will be called when the button is pressed. | | `Optional[str]` | | `service_data` | ✅ | The `service_data` that will be passed to the `service` when the button is pressed. If empty, the `entity_id` will be passed. | | `Optional[Mapping[str, Any]]` | @@ -973,7 +973,7 @@ And shows a ring indicator and the numerical value of the brightness. {{state | int}} {%- endif -%} state_attribute: brightness - allow_touchscreen: true + allow_touchscreen_events: true delay: 0.5 dial_event_type: TURN attributes: @@ -984,16 +984,39 @@ And shows a ring indicator and the numerical value of the brightness. #### Types of Dial specific attributes -| Variable name | Description | Default | Type | -| :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------ | :----------- | -| `dial_event_type` | The type of event that the dial should trigger. | None | PUSH or TURN | -| `allow_touchscreen` | Sets whether events from the touchscreen should be allowed | False | bool | -| `attributes` | A dictionary of attributes that are specific to the dial | None | dict | -| `attributes.min` | The minimum value that the dial can have | None | int | -| `attributes.max` | The maximum value that the dial can have | None | int | -| `attributes.step` | The step size that the dial should have | None | int | -| `state_attribute` | An attribute of an HA entity that the dial should control e.g brightness for a light | None | str | -| `delay` | The delay (in seconds) before the `service` is called. This counts down from the specified time and collects the called turn events and sends the bundled value to home_assistant after the dial hasnt been turned for the specified time in delay | None | float | +The attributes until `delay` are the same as for the buttons, but there are some additional attributes that are specific to the dials. + + + + + + + +| Variable name | Allow template | Description | Default | Type | +|:---------------------------|:-----------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------|:--------------------------------| +| `entity_id` | ✅ | The `entity_id` that this dial controls. This entity will be passed to the `service` when the dial is rotated. The dial is re-rendered whenever the state of this entity changes. | | `Optional[str]` | +| `linked_entity` | ✅ | A secondary entity_id that is used for updating images and states | | `Optional[str]` | +| `service` | ✅ | The `service` that will be called when the dial is rotated. | | `Optional[str]` | +| `service_data` | ✅ | The `service_data` that will be passed to the `service` when the dial is rotated. If empty, the `entity_id` will be passed. | | `Optional[Mapping[str, Any]]` | +| `target` | ✅ | The `target` that will be passed to the `service` when the dial is rotated. | | `Optional[Mapping[str, Any]]` | +| `text` | ✅ | The text to display above the dial. If empty, no text is displayed. You might want to add `\n` characters to spread the text over several lines, or use the `\|` character in YAML to create a multi-line string. | | `str` | +| `text_color` | ✅ | Color of the text. If empty, the color is `white`, unless an `entity_id` is specified, in which case the color is `amber` when the state is `on`, and `white` when it is `off`. | | `Optional[str]` | +| `text_size` | ❌ | Integer size of the text. | `12` | `int` | +| `text_offset` | ❌ | The text's position can be moved up or down from the center of the dial, and this movement is measured in pixels. The value can be positive (for upward movement) or negative (for downward movement). | | `int` | +| `icon` | ✅ | The icon filename to display above the dial. Make the path absolute (e.g., `/config/streamdeck/my_icon.png`) or relative to the `assets` directory (e.g., `my_icon.png`). If empty, a icon with `icon_background_color` and `text` is displayed. The icon can be a URL to an image, like `'url:https://www.nijho.lt/authors/admin/avatar.jpg'`, or a `spotify:` icon, like `'spotify:album/6gnYcXVaffdG0vwVM34cr8'`. If the icon is a `spotify:` icon, the icon will be downloaded and cached. The icon can also display a partially complete ring, like a progress bar, or sensor value, like `ring:25` for a 25% complete ring. | | `Optional[str]` | +| `icon_mdi` | ✅ | The Material Design Icon to display above the dial. If empty, no icon is displayed. See https://mdi.bessarabov.com/ for a list of icons. The SVG icon will be downloaded and cached. | | `Optional[str]` | +| `icon_background_color` | ✅ | A color (in hex format, e.g., '#FF0000') for the background of the icon (if no `icon` is specified). | `#000000` | `str` | +| `icon_mdi_color` | ✅ | The color of the Material Design Icon (in hex format, e.g., '#FF0000'). If empty, the color is derived from `text_color` but is less saturated (gray is mixed in). | | `Optional[str]` | +| `icon_gray_when_off` | ❌ | When specifying `icon` and `entity_id`, if the state is `off`, the icon will be converted to grayscale. | | `bool` | +| `delay` | ✅ | The delay (in seconds) before the `service` is called. This counts down from the specified time and collects the called turn events and sends the bundled value to Home Assistant after the dial hasn't been turned for the specified time in delay. | | `Union[float, str]` | +| `dial_event_type` | ✅ | The event type of the dial that will trigger the service. Either `DialEventType.TURN` or `DialEventType.PUSH`. | | `Optional[str]` | +| `state_attribute` | ✅ | The attribute of the entity which gets used for the dial state. | | `Optional[str]` | +| `attributes` | ✅ | Sets the attributes of the dial. `min`: The minimal value of the dial. `max`: The maximal value of the dial. `step`: the step size by which the value of the dial is increased by on an event. | | `Optional[Mapping[str, float]]` | +| `allow_touchscreen_events` | ✅ | Whether events from the touchscreen are allowed, for example set the minimal value on `SHORT` and set maximal value on `LONG`. | | `bool` | + + + + ### Jinja variables @@ -1004,7 +1027,7 @@ And shows a ring indicator and the numerical value of the brightness. ### Touchscreen events - If your streamdeck has a touchscreen you can switch pages by swiping left or right on the screen. -- If you set the `allow_touchscreen` attribute you can also use the touchscreen to set the value of a dial to the max or min value of that dial by tapping or holding the area of the dial. +- If you set the `allow_touchscreen_events` attribute you can also use the touchscreen to set the value of a dial to the max or min value of that dial by tapping or holding the area of the dial. ### Include variables diff --git a/home_assistant_streamdeck_yaml.py b/home_assistant_streamdeck_yaml.py index 3ac8773..d63e899 100755 --- a/home_assistant_streamdeck_yaml.py +++ b/home_assistant_streamdeck_yaml.py @@ -76,265 +76,14 @@ StateDict: TypeAlias = dict[str, dict[str, Any]] -class Dial(BaseModel, extra="forbid"): # type: ignore[call-arg] - """Dial configuration.""" - - entity_id: str | None = Field( - default=None, - allow_template=True, - ) - linked_entity: str | None = Field( - default=None, - allow_template=True, - description="A secondary entity_id that is used for updating images and states", - ) - service: str | None = Field( - default=None, - allow_template=True, - ) - service_data: dict[str, Any] | None = Field( - default=None, - allow_template=True, - ) - target: dict[str, Any] | None = Field( - default=None, - allow_template=True, - ) - dial_event_type: str | None = Field( - default=None, - allow_template=True, - description="The event type of the dial that will trigger the service." - "Either DialEventType.TURN or DialEventType.PUSH", - ) - text: str = Field(default="", allow_template=True) - text_color: str | None = Field( - default=None, - allow_template=True, - ) - text_size: int = Field( - default=16, - allow_template=False, - ) - text_offset: int = Field( - default=0, - allow_template=False, - ) - icon: str | None = Field( - default=None, - allow_template=True, - ) - icon_mdi: str | None = Field( - default=None, - allow_template=True, - ) - icon_background_color: str = Field( - default="#000000", - allow_template=True, - ) - icon_mdi_color: str | None = Field( - default=None, - allow_template=True, - ) - icon_gray_when_off: bool = Field( - default=False, - allow_template=False, - description="When specifying `icon` and `entity_id`, if the state is `off`, the icon will be converted to grayscale.", - ) - delay: float | str = Field( - default=0.0, - allow_template=True, - description="The delay inbetween events for the service to be called" - " Dial changes are added to decrease traffic ", - ) - state_attribute: str | None = Field( - default=None, - allow_template=True, - description="The attribute of the entity which gets used for the dial state", - ) - attributes: dict[str, float] | None = Field( - default=None, - allow_template=True, - description="Sets the attributes of the dial" - "min: The minimal value of the dial" - "max: The maximal value of the dial" - "step: the step size by which the value of the dial is increased by on an event", - ) - allow_touchscreen_events: bool = Field( - default=False, - allow_template=True, - description="Whether events from the touchscreen such as setting minimal value on short and setting maximal value on LONG are allowed", - ) - - # vars for timer - _timer: AsyncDelayedCallback | None = PrivateAttr(None) - - # Internal attributes for Dial - _attributes: dict[str, float] = PrivateAttr( - {"state": 0, "min": 0, "max": 100, "step": 1}, - ) - - def update_attributes(self, data: dict[str, Any]) -> None: - """Updates all home assistant entity attributes.""" - if self.attributes is None: - self._attributes = data["attributes"] - else: - self._attributes = self.attributes - - if self.state_attribute is None: - self._attributes.update({"state": float(data["state"])}) - else: - try: - if data["attributes"][self.state_attribute] is None: - self._attributes["state"] = 0 - else: - self._attributes["state"] = float( - data["attributes"][self.state_attribute], - ) - except KeyError: - console.log(f"Could not find attribute {self.state_attribute}") - self._attributes["state"] = 0 - - def get_attributes(self) -> dict[str, float]: - """Returns all home assistant entity attributes.""" - return self._attributes - - def increment_state(self, value: float) -> None: - """Increments the value of the dial with checks for the minimal and maximal value.""" - num: float = self._attributes["state"] + value * self._attributes["step"] - num = min(self._attributes["max"], num) - num = max(self._attributes["min"], num) - self._attributes["state"] = num - - def set_state(self, value: float) -> None: - """Sets the value of the dial without checks for the minimal and maximal value.""" - self._attributes["state"] = value - - @classmethod - def templatable(cls: type[Dial]) -> set[str]: - """Return if an attribute is templatable, which is if the type-annotation is str.""" - schema = cls.schema() - properties = schema["properties"] - return {k for k, v in properties.items() if v["allow_template"]} - - def rendered_template_dial( - self, - complete_state: StateDict, - ) -> Dial: - """Return a dial with the rendered text.""" - dct = self.dict(exclude_unset=True) - for key in self.templatable(): - if key not in dct: - continue - val = dct[key] - if isinstance(val, dict): - for k, v in val.items(): - val[k] = _render_jinja(v, complete_state, self) - else: - dct[key] = _render_jinja(val, complete_state, self) - return Dial(**dct) - - # LCD/Touchscreen management - def render_lcd_image( - self, - complete_state: StateDict, - key: int, # Key needs to be from sorted dials - size: tuple[int, int], - icon_mdi_margin: int = 0, - font_filename: str = DEFAULT_FONT, - ) -> Image.Image: - """Render the image for the LCD.""" - try: - image = None - dial = self.rendered_template_dial(complete_state) - - if isinstance(dial.icon, str) and ":" in dial.icon: - which, id_ = dial.icon.split(":", 1) - if which == "spotify": - filename = _to_filename(dial.icon, ".jpeg") - image = _download_spotify_image(id_, filename).copy() - elif which == "url": - filename = _url_to_filename(id_) - image = _download_image(id_, filename, size).copy() - elif which == "ring": - pct = _maybe_number(id_) - assert isinstance( - pct, - (int, float), - ), f"Invalid ring percentage: {id_}" - image = _draw_percentage_ring( - percentage=pct, - size=size, - radius=40, - ) - - icon_convert_to_grayscale = False - text = dial.text - text_color = dial.text_color or "white" - - assert dial.entity_id is not None - if ( - complete_state[dial.entity_id]["state"] == "off" - and dial.icon_gray_when_off - ): - icon_convert_to_grayscale = True - - if image is None: - image = _init_icon( - icon_background_color=dial.icon_background_color, - icon_filename=dial.icon, - icon_mdi=dial.icon_mdi, - icon_mdi_margin=icon_mdi_margin, - icon_mdi_color=_named_to_hex(dial.icon_mdi_color or text_color), - size=size, - ).copy() - - if icon_convert_to_grayscale: - image = _convert_to_grayscale(image) - - _add_text( - image=image, - font_filename=font_filename, - text_size=self.text_size, - text=text, - text_color=text_color, - text_offset=self.text_offset, - ) - return image # noqa: TRY300 - - except ValueError as e: - console.log(e) - warnings.warn( - f"Failed to render icon for dial {key}", - IconWarning, - stacklevel=2, - ) - return _generate_failed_icon(size=size) - - def start_or_restart_timer( - self, - callback: Callable[[], None | Coroutine] | None = None, - ) -> bool: - """Starts or restarts AsyncDelayedCallback timer.""" - if not self.delay: - return False - if self._timer is None: - assert isinstance( - self.delay, - (int, float), - ), f"Invalid delay: {self.delay}" - self._timer = AsyncDelayedCallback(delay=self.delay, callback=callback) - self._timer.start() - return True - - -class Button(BaseModel, extra="forbid"): # type: ignore[call-arg] - """Button configuration.""" +class _ButtonDialBase(BaseModel, extra="forbid"): # type: ignore[call-arg] + """Parent of Button and Dial.""" entity_id: str | None = Field( default=None, allow_template=True, description="The `entity_id` that this button controls." - " This entitity will be passed to the `service` when the button is pressed." + " This entity will be passed to the `service` when the button is pressed." " The button is re-rendered whenever the state of this entity changes.", ) linked_entity: str | None = Field( @@ -432,6 +181,49 @@ class Button(BaseModel, extra="forbid"): # type: ignore[call-arg] " If while counting the button is pressed again, the timer is cancelled." " Should be a float or template string that evaluates to a float.", ) + + _timer: AsyncDelayedCallback | None = PrivateAttr(None) + + @classmethod + def templatable(cls: type[Button]) -> set[str]: + """Return if an attribute is templatable, which is if the type-annotation is str.""" + schema = cls.schema() + properties = schema["properties"] + return {k for k, v in properties.items() if v["allow_template"]} + + @classmethod + def to_pandas_table(cls: type[Button]) -> pd.DataFrame: + """Return a pandas table with the schema.""" + import pandas as pd + + rows = [] + for k, field in cls.__fields__.items(): + info = field.field_info + if info.description is None: + continue + + def code(text: str) -> str: + return f"`{text}`" + + row = { + "Variable name": code(k), + "Allow template": "✅" if info.extra["allow_template"] else "❌", + "Description": info.description, + "Default": code(info.default) if info.default else "", + "Type": code(field._type_display()), + } + rows.append(row) + return pd.DataFrame(rows) + + @classmethod + def to_markdown_table(cls: type[Button]) -> str: + """Return a markdown table with the schema.""" + return cls.to_pandas_table().to_markdown(index=False) + + +class Button(_ButtonDialBase, extra="forbid"): # type: ignore[call-arg] + """Button configuration.""" + special_type: ( Literal[ "next-page", @@ -472,43 +264,12 @@ class Button(BaseModel, extra="forbid"): # type: ignore[call-arg] " list of `colors` or `colormap` is specified, 10 equally spaced colors are used.", ) - _timer: AsyncDelayedCallback | None = PrivateAttr(None) - @classmethod def from_yaml(cls: type[Button], yaml_str: str) -> Button: """Set the attributes from a YAML string.""" data = safe_load_yaml(yaml_str) return cls(**data[0]) - @classmethod - def to_pandas_table(cls: type[Button]) -> pd.DataFrame: - """Return a pandas table with the schema.""" - import pandas as pd - - rows = [] - for k, field in cls.__fields__.items(): - info = field.field_info - if info.description is None: - continue - - def code(text: str) -> str: - return f"`{text}`" - - row = { - "Variable name": code(k), - "Allow template": "✅" if info.extra["allow_template"] else "❌", - "Description": info.description, - "Default": code(info.default) if info.default else "", - "Type": code(field._type_display()), - } - rows.append(row) - return pd.DataFrame(rows) - - @classmethod - def to_markdown_table(cls: type[Button]) -> str: - """Return a markdown table with the schema.""" - return cls.to_pandas_table().to_markdown(index=False) - @property def domain(self) -> str | None: """Return the domain of the entity.""" @@ -516,13 +277,6 @@ def domain(self) -> str | None: return None return self.service.split(".", 1)[0] - @classmethod - def templatable(cls: type[Button]) -> set[str]: - """Return if an attribute is templatable, which is if the type-annotation is str.""" - schema = cls.schema() - properties = schema["properties"] - return {k for k, v in properties.items() if v["allow_template"]} - def rendered_template_button( self, complete_state: StateDict, @@ -757,6 +511,210 @@ def sleep_button_and_image( return button, image +class Dial(_ButtonDialBase, extra="forbid"): # type: ignore[call-arg] + """Dial configuration.""" + + dial_event_type: str | None = Field( + default=None, + allow_template=True, + description="The event type of the dial that will trigger the service." + " Either `DialEventType.TURN` or `DialEventType.PUSH`.", + ) + + state_attribute: str | None = Field( + default=None, + allow_template=True, + description="The attribute of the entity which gets used for the dial state.", + # TODO: use this? + # An attribute of an HA entity that the dial should control e.g., brightness for a light. + ) + attributes: dict[str, float] | None = Field( + default=None, + allow_template=True, + description="Sets the attributes of the dial." + " `min`: The minimal value of the dial." + " `max`: The maximal value of the dial." + " `step`: the step size by which the value of the dial is increased by on an event.", + ) + allow_touchscreen_events: bool = Field( + default=False, + allow_template=True, + description="Whether events from the touchscreen are allowed, for example set the minimal value on `SHORT` and set maximal value on `LONG`.", + ) + + # vars for timer + _timer: AsyncDelayedCallback | None = PrivateAttr(None) + + # Internal attributes for Dial + _attributes: dict[str, float] = PrivateAttr( + {"state": 0, "min": 0, "max": 100, "step": 1}, + ) + + def update_attributes(self, data: dict[str, Any]) -> None: + """Updates all home assistant entity attributes.""" + if self.attributes is None: + self._attributes = data["attributes"] + else: + self._attributes = self.attributes + + if self.state_attribute is None: + self._attributes.update({"state": float(data["state"])}) + else: + try: + if data["attributes"][self.state_attribute] is None: + self._attributes["state"] = 0 + else: + self._attributes["state"] = float( + data["attributes"][self.state_attribute], + ) + except KeyError: + console.log(f"Could not find attribute {self.state_attribute}") + self._attributes["state"] = 0 + + def get_attributes(self) -> dict[str, float]: + """Returns all home assistant entity attributes.""" + return self._attributes + + def increment_state(self, value: float) -> None: + """Increments the value of the dial with checks for the minimal and maximal value.""" + num: float = self._attributes["state"] + value * self._attributes["step"] + num = min(self._attributes["max"], num) + num = max(self._attributes["min"], num) + self._attributes["state"] = num + + def set_state(self, value: float) -> None: + """Sets the value of the dial without checks for the minimal and maximal value.""" + self._attributes["state"] = value + + def rendered_template_dial( + self, + complete_state: StateDict, + ) -> Dial: + """Return a dial with the rendered text.""" + dct = self.dict(exclude_unset=True) + for key in self.templatable(): + if key not in dct: + continue + val = dct[key] + if isinstance(val, dict): + for k, v in val.items(): + val[k] = _render_jinja(v, complete_state, self) + else: + dct[key] = _render_jinja(val, complete_state, self) + return Dial(**dct) + + # LCD/Touchscreen management + def render_lcd_image( + self, + complete_state: StateDict, + key: int, # Key needs to be from sorted dials + size: tuple[int, int], + icon_mdi_margin: int = 0, + font_filename: str = DEFAULT_FONT, + ) -> Image.Image: + """Render the image for the LCD.""" + try: + image = None + dial = self.rendered_template_dial(complete_state) + + if isinstance(dial.icon, str) and ":" in dial.icon: + which, id_ = dial.icon.split(":", 1) + if which == "spotify": + filename = _to_filename(dial.icon, ".jpeg") + image = _download_spotify_image(id_, filename).copy() + elif which == "url": + filename = _url_to_filename(id_) + image = _download_image(id_, filename, size).copy() + elif which == "ring": + pct = _maybe_number(id_) + assert isinstance( + pct, + (int, float), + ), f"Invalid ring percentage: {id_}" + image = _draw_percentage_ring( + percentage=pct, + size=size, + radius=40, + ) + + icon_convert_to_grayscale = False + text = dial.text + text_color = dial.text_color or "white" + + assert dial.entity_id is not None + if ( + complete_state[dial.entity_id]["state"] == "off" + and dial.icon_gray_when_off + ): + icon_convert_to_grayscale = True + + if image is None: + image = _init_icon( + icon_background_color=dial.icon_background_color, + icon_filename=dial.icon, + icon_mdi=dial.icon_mdi, + icon_mdi_margin=icon_mdi_margin, + icon_mdi_color=_named_to_hex(dial.icon_mdi_color or text_color), + size=size, + ).copy() + + if icon_convert_to_grayscale: + image = _convert_to_grayscale(image) + + _add_text( + image=image, + font_filename=font_filename, + text_size=self.text_size, + text=text, + text_color=text_color, + text_offset=self.text_offset, + ) + return image # noqa: TRY300 + + except ValueError as e: + console.log(e) + warnings.warn( + f"Failed to render icon for dial {key}", + IconWarning, + stacklevel=2, + ) + return _generate_failed_icon(size=size) + + def start_or_restart_timer( + self, + callback: Callable[[], None | Coroutine] | None = None, + ) -> bool: + """Starts or restarts AsyncDelayedCallback timer.""" + if not self.delay: + return False + if self._timer is None: + assert isinstance( + self.delay, + (int, float), + ), f"Invalid delay: {self.delay}" + self._timer = AsyncDelayedCallback(delay=self.delay, callback=callback) + self._timer.start() + return True + + +def _update_dial_descriptions() -> None: + for _k, _v in Dial.__fields__.items(): + _v.field_info.description = ( + _v.field_info.description.replace("on the button", "above the dial") + .replace("button", "dial") + .replace("pressed", "rotated") + ) + if _k == "delay": + _v.field_info.description = ( + "The delay (in seconds) before the `service` is called." + " This counts down from the specified time and collects the called turn events and" + " sends the bundled value to Home Assistant after the dial hasn't been turned for the specified time in delay." + ) + + +_update_dial_descriptions() + + def _to_filename(id_: str, suffix: str = "") -> Path: """Converts an id with ":" and "_" to a filename with optional suffix.""" filename = ASSETS_PATH / id_.replace("/", "_").replace(":", "_")