Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7167f8e
Add custom image/GIF support to icon state customizations
Dav1d-Fn May 13, 2026
61479df
Add custom image/GIF support to main action settings
Dav1d-Fn May 14, 2026
1a731e8
Merge remote-tracking branch 'origin/main' into main-image
gensyn May 14, 2026
1cb7148
Changed "custom_image" to "image", cleaned up code, fixed customizati…
gensyn May 14, 2026
ddf3725
Merge image and icon field into one
Dav1d-Fn May 14, 2026
efff30d
Cleaned up code for image support
gensyn May 17, 2026
423fbe7
Fixed MDI string handling
gensyn May 18, 2026
8f0b725
Fixed migration doc string.
gensyn May 18, 2026
eaa81f6
Updated README.md
gensyn May 18, 2026
d35c741
Merge pull request #23 from Dav1d-Fn/main
gensyn May 18, 2026
daf1934
test: update show_icon tests for browse button and icon gating
Copilot May 18, 2026
3db6824
test: align on_ready and icon window tests with merged behavior
Copilot May 18, 2026
e3cd90d
test: clarify icon window validation test name
Copilot May 18, 2026
5e41234
Merge pull request #25 from gensyn/copilot/fix-failing-test-workflow
gensyn May 18, 2026
1b503a2
Create FUNDING.yml
gensyn May 22, 2026
c9d0681
Remove support section and GitHub badge from README
gensyn May 22, 2026
0e08625
Cleaned up code for image support
gensyn Jul 3, 2026
4a03dfd
Make keep-alive logic defensive with try/except for TypeError
Copilot Jul 3, 2026
38c0836
Fix tests: configure entity_combo mock with get_n_items returning 0
Copilot Jul 3, 2026
98937b7
Merge pull request #27 from gensyn/fix
gensyn Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# These are supported funding model platforms

github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: gensyn
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,13 @@
- Disable `key_down` by mapping it to `None`

### 🎨 Show an Icon
- Display entity icons or custom icons from [Material Design Icons](https://pictogrammers.com/library/mdi/)
- Display entity icons or custom icons from [Material Design Icons](https://pictogrammers.com/library/mdi/) as well as images and GIFs
- Customize icon appearance:
- 🎨 Color
- 📏 Scale
- 🌫️ Opacity
- Customize image appearance:
- 📏 Scale
- **Dynamic customization** based on state or attribute values
- Automatic updates when entity state changes

Expand Down Expand Up @@ -396,12 +398,11 @@ Contributions are welcome! Feel free to:
- 📝 Improve documentation
- 🔧 Submit pull requests

---

<div align="center">
---

**Made with ❤️ for the Home Assistant and StreamController communities**
## ⭐ Support

[![GitHub](https://img.shields.io/badge/GitHub-Repository-181717?logo=github)](https://github.com/gensyn/HomeAssistantPlugin)
If you find HomeAssistantPlugin useful, please consider giving it a star on GitHub! It helps others discover the project.

</div>
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/M5G61ZZY8D)
1 change: 1 addition & 0 deletions actions/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
LABEL_NO_ENTITY = "actions.home_assistant.customization.no_entity.label"

SETTING_VERSION = "version"
SETTING_VERSION_NUMBER = 1

SETTING_ENTITY = "entity"
SETTING_DOMAIN = "domain"
Expand Down
9 changes: 8 additions & 1 deletion actions/cores/base_core/base_core.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""The module for the Home Assistant action that is loaded in StreamController."""

import gi
from GtkHelper.GenerativeUI.ComboRow import ComboRow
from HomeAssistantPlugin.actions import const
from HomeAssistantPlugin.actions.cores.base_core.migrate import migrate_settings

from GtkHelper.GenerativeUI.ComboRow import ComboRow
from src.backend.PluginManager.ActionCore import ActionCore

gi.require_version('Gtk', '4.0')
Expand Down Expand Up @@ -48,6 +50,7 @@ def __init__(self, settings_implementation, track_entity: bool, *args, **kwargs)

def on_ready(self) -> None:
"""Set up action when StreamController has finished loading."""
migrate_settings(self)
self.settings = self.settings_implementation(self)
self.initialized = True

Expand Down Expand Up @@ -114,7 +117,11 @@ def on_change_domain(self, _, domain, old_domain):
if entity and self.track_entity:
self.plugin_base.backend.remove_tracked_entity(entity, self.refresh)
self.settings.reset(domain)
# save entities from the combo to a temporary variable to keep them alive while we clear the combo
_temp_keep_alive = [self.entity_combo.get_item(i) for i in range(self.entity_combo.get_n_items())]
self.entity_combo.remove_all_items()
# now the entities can be removed
del _temp_keep_alive
self._last_loaded_entities = None

if domain:
Expand Down
1 change: 1 addition & 0 deletions actions/cores/base_core/base_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from HomeAssistantPlugin.actions import const

DEFAULT_SETTINGS = {
const.SETTING_VERSION: const.SETTING_VERSION_NUMBER,
const.SETTING_DOMAIN: const.EMPTY_STRING,
const.SETTING_ENTITY: const.EMPTY_STRING
}
Expand Down
27 changes: 27 additions & 0 deletions actions/cores/base_core/migrate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from HomeAssistantPlugin.actions import const
from HomeAssistantPlugin.actions.cores.customization_core import customization_const
from HomeAssistantPlugin.actions.show_icon import icon_const
from HomeAssistantPlugin.actions.show_icon.icon_settings import ShowIconSettings


def migrate_settings(action) -> None:
settings = action.get_settings()

if settings.get(const.SETTING_VERSION) is not None:
return

migrate_v0_v1(action)

def migrate_v0_v1(action) -> None:
Comment on lines +1 to +15
"""
Migrate from no version to v1.
There is actually nothing to do yet - this just introduces the version field.
"""
settings = action.get_settings()

if not settings:
# settings are empty - this is a brand-new action
return

settings[const.SETTING_VERSION] = const.SETTING_VERSION_NUMBER
action.set_settings(settings)
60 changes: 56 additions & 4 deletions actions/show_icon/icon_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
The module for the Home Assistant action that is loaded in StreamController.
"""

import os

import gi

gi.require_version("Gtk", "4.0")
from gi.repository import Gio
from gi.repository.Gtk import Align, Button, FileDialog, FileFilter

from loguru import logger as log

from HomeAssistantPlugin.actions.cores.base_core.base_core import requires_initialization
from HomeAssistantPlugin.actions.cores.customization_core.customization_core import CustomizationCore
from HomeAssistantPlugin.actions.show_icon import icon_const, icon_helper
Expand Down Expand Up @@ -43,6 +53,11 @@ def create_ui_elements(self) -> None:
complex_var_name=True
)

browse_button = Button(label=self.lm.get(icon_const.LABEL_ICON_BROWSE))
browse_button.set_valign(Align.CENTER)
browse_button.connect("clicked", self._on_browse_clicked)
self.icon.widget.add_suffix(browse_button)

self.color: ColorButtonRow = ColorButtonRow(
self, icon_const.SETTING_ICON_COLOR, icon_const.DEFAULT_ICON_COLOR,
title=icon_const.LABEL_ICON_COLOR, on_change=self._reload,
Expand All @@ -63,6 +78,7 @@ def create_ui_elements(self) -> None:
can_reset=False, complex_var_name=True
)


@requires_initialization
def set_enabled_disabled(self) -> None:
"""
Expand All @@ -87,17 +103,22 @@ def set_enabled_disabled(self) -> None:

self.opacity.widget.set_sensitive(False)
self.opacity.widget.set_subtitle(self.lm.get(icon_const.LABEL_ICON_NO_ENTITY))

else:
icon_value = self.settings.get_icon()
has_icon = bool(icon_value) and icon_value in icon_helper.MDI_ICONS
not_supported = self.lm.get(icon_const.LABEL_ICON_ONLY_SUPPORTED_FOR_ICONS) if not has_icon else icon_const.EMPTY_STRING

Comment on lines +108 to +111
self.icon.widget.set_sensitive(True)

self.color.widget.set_sensitive(True)
self.color.widget.set_subtitle(icon_const.EMPTY_STRING)
self.color.widget.set_sensitive(has_icon)
self.color.widget.set_subtitle(not_supported)

self.scale.widget.set_sensitive(True)
self.scale.widget.set_subtitle(icon_const.EMPTY_STRING)

self.opacity.widget.set_sensitive(True)
self.opacity.widget.set_subtitle(icon_const.EMPTY_STRING)
self.opacity.widget.set_sensitive(has_icon)
self.opacity.widget.set_subtitle(not_supported)

def refresh(self, state: dict = None) -> None:
"""
Expand All @@ -123,6 +144,37 @@ def refresh(self, state: dict = None) -> None:
self._load_customizations()
self.set_enabled_disabled()

def _on_browse_clicked(self, *_) -> None:
dialog = FileDialog()
dialog.set_title(self.lm.get(icon_const.LABEL_ICON_IMAGE))

filter_images = FileFilter()
filter_images.set_name("Images")
for mime in ("image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"):
filter_images.add_mime_type(mime)

filters = Gio.ListStore.new(FileFilter)
filters.append(filter_images)
dialog.set_filters(filters)
dialog.set_default_filter(filter_images)

current_path = self.icon.widget.get_text()
if current_path:
current_dir = os.path.dirname(current_path)
if os.path.isdir(current_dir):
dialog.set_initial_folder(Gio.File.new_for_path(current_dir))

dialog.open(None, None, self._on_file_chosen)

def _on_file_chosen(self, dialog, result) -> None:
try:
file = dialog.open_finish(result)
if file:
self.icon.widget.set_text(file.get_path())
self._reload()
except Exception as e:
log.error(f"Error choosing file: {e}")

def _get_domains(self) -> list[str]:
"""This class needs all domains that provide actions in Home Assistant."""
return self.plugin_base.backend.get_domains_for_entities()
7 changes: 6 additions & 1 deletion actions/show_icon/icon_const.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
CUSTOM_COLOR = "color"
CUSTOM_SCALE = "scale"
CUSTOM_OPACITY = "opacity"
CUSTOM_IMAGE = "image"

LABEL_ICON_IMAGE = "actions.home_assistant.icon.image.label"
LABEL_ICON_BROWSE = "actions.home_assistant.icon.browse.label"
LABEL_ICON_ONLY_SUPPORTED_FOR_ICONS = "actions.home_assistant.icon.only_supported_for_icons.label"

MDI_SVG_JSON = "assets/mdi-svg.json"
ICON_COLOR_RED = "#ff0000"
Expand All @@ -39,4 +44,4 @@
ATTRIBUTES = "attributes"
ATTRIBUTE_ICON = "icon"
STATE = "state"
ERROR = "error"
ERROR = "error"
2 changes: 1 addition & 1 deletion actions/show_icon/icon_customization.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,4 @@ def export(self) -> dict[str, Any]:
icon_const.CUSTOM_COLOR: self.color,
icon_const.CUSTOM_SCALE: self.scale,
icon_const.CUSTOM_OPACITY: self.opacity
}
}
14 changes: 7 additions & 7 deletions actions/show_icon/icon_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def get_icon(state: dict, settings: ShowIconSettings, is_connected: bool) -> tup

name, color, scale, opacity = _get_icon_settings(state, settings)

if name not in MDI_ICONS:
# color and opacity only have an effect on icons
return name, scale

Comment on lines 30 to +35
# convert RGB color to hex
color = customization_helper.convert_color_list_to_hex(color)

Expand All @@ -39,14 +43,11 @@ def get_icon(state: dict, settings: ShowIconSettings, is_connected: bool) -> tup

def _get_icon_settings(state: dict, settings: ShowIconSettings) -> tuple[str, str, float, float]:
# default value for the icon is the icon set in HA
name = state.get(icon_const.ATTRIBUTES, {}).get(icon_const.ATTRIBUTE_ICON, icon_const.EMPTY_STRING)
name = settings.get_icon() or state.get(icon_const.ATTRIBUTES, {}).get(icon_const.ATTRIBUTE_ICON, icon_const.EMPTY_STRING).replace("mdi:", icon_const.EMPTY_STRING)
color = settings.get_color()
scale = settings.get_scale()
opacity = settings.get_opacity()

if settings.get_icon() in MDI_ICONS.keys():
name = settings.get_icon()

#
# Begin custom icon
#
Expand Down Expand Up @@ -88,8 +89,7 @@ def _get_icon_settings(state: dict, settings: ShowIconSettings) -> tuple[str, st
or (operator == "<=" and value <= custom_icon_value)
or (operator == ">" and value > custom_icon_value)
or (operator == ">=" and value >= custom_icon_value)):
name, color, scale, opacity = _replace_values(name, color, scale, opacity,
customization)
name, color, scale, opacity = _replace_values(name, color, scale, opacity, customization)

#
# End custom icon
Expand Down Expand Up @@ -154,4 +154,4 @@ def _get_icon_svg(name: str) -> str:

return (f'<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 24 '
f'24"><title>{name}</title><path d="{path}" fill="<color>" opacity="<opacity>" '
f'/></svg>')
f'/></svg>')
2 changes: 1 addition & 1 deletion actions/show_icon/icon_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,4 @@ def get_opacity(self) -> int:
Get the opacity.
:return: the opacity
"""
return int(self._action.get_settings()[icon_const.SETTING_ICON][icon_const.SETTING_OPACITY])
return int(self._action.get_settings()[icon_const.SETTING_ICON][icon_const.SETTING_OPACITY])
Loading
Loading