Compare commits

...
18 Commits
Author SHA1 Message Date
francop 0ed1687503 App Updates 2026-07-21 11:19:17 -04:00
francop 87ffe648a5 Updated Expose to true 2026-07-21 11:19:07 -04:00
francop 0c420e445f Added 4th sensor to clean temp 2026-07-20 23:00:48 -04:00
francop 26dcabbf8a Added Clean Blink sensors to remove negative temp values 2026-07-20 22:53:36 -04:00
francop a0c3271743 Updated apps 2026-07-20 22:52:35 -04:00
francop 28a8cb98f6 Added Alexa Music 2026-07-17 10:12:15 -04:00
francop 92c5268dc8 updated apps 2026-07-15 13:29:49 -04:00
francop 010e828e9c updated apps 2026-07-14 23:57:03 -04:00
francop 6cc7212cef Updated apps 2026-07-09 13:30:12 -04:00
francop 0f7585754d Updated Scheduler and Maintainance Apps 2026-07-08 12:00:20 -04:00
francop fefc2c8b5c New apps Added 2026-07-08 10:43:39 -04:00
francop 3b1f4bbd75 Added VT Thermometer to HACS 2026-06-16 10:33:21 -04:00
francop 887feaa50a Initil after Upgrade 2026-06-15 10:53:52 -04:00
francop 2fe9bf0dd6 Changed consumption tigger for Microwave to 100 2026-06-15 10:53:38 -04:00
francop 9b6eaa084a Added New Coffee maker detection logic 2026-06-14 10:26:35 -04:00
francop dcc2524785 Initial 2026-06-14 02:01:49 -04:00
francop 16c1da4289 Added AUtomation for Washer 2026-06-14 02:01:39 -04:00
francop ee8f221d39 Added Weather AI 2026-06-14 02:01:06 -04:00
2525 changed files with 798546 additions and 3349 deletions
+1 -1
View File
@@ -1 +1 @@
2026.6.2 2026.7.2
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+9
View File
@@ -0,0 +1,9 @@
{"timestamp": "2026-07-16T01:51:24.833520+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_boolean", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 265.3963565826416, "success": true, "error_message": null, "response_size_bytes": 2297, "user_context": null}
{"timestamp": "2026-07-16T01:51:24.844401+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_select", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 234.4343662261963, "success": true, "error_message": null, "response_size_bytes": 472, "user_context": null}
{"timestamp": "2026-07-16T01:51:24.849852+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_number", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 281.5418243408203, "success": true, "error_message": null, "response_size_bytes": 640, "user_context": null}
{"timestamp": "2026-07-16T01:51:25.011117+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "timer", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 184.76414680480957, "success": true, "error_message": null, "response_size_bytes": 465, "user_context": null}
{"timestamp": "2026-07-16T01:51:25.012519+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "counter", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 186.4025592803955, "success": true, "error_message": null, "response_size_bytes": 826, "user_context": null}
{"timestamp": "2026-07-16T01:51:27.603591+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "input_boolean", "area_filter": null, "search_types": null, "limit": 10, "offset": 10, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 78.72748374938965, "success": true, "error_message": null, "response_size_bytes": 818, "user_context": null}
{"timestamp": "2026-07-16T14:58:34.806520+00:00", "tool_name": "ha_list_floors_areas", "parameters": {"fields": null, "area_fields": null}, "execution_time_ms": 34.44170951843262, "success": true, "error_message": null, "response_size_bytes": 3757, "user_context": null}
{"timestamp": "2026-07-16T15:17:46.049602+00:00", "tool_name": "ha_get_overview", "parameters": {"detail_level": "minimal", "domains": null, "limit": null, "offset": 0, "max_entities_per_domain": null, "include_state": null, "include_entity_id": null, "include_notifications": true, "include_dismissed_repairs": false, "fields": null}, "execution_time_ms": 1456.7010402679443, "success": true, "error_message": null, "response_size_bytes": 14815, "user_context": null}
{"timestamp": "2026-07-16T15:20:28.060870+00:00", "tool_name": "ha_search", "parameters": {"query": null, "domain_filter": "automation", "area_filter": null, "search_types": null, "limit": 10, "offset": 0, "exact_match": true, "include_hidden": true, "include_config": false, "group_by_domain": false, "per_domain_limit": null, "state_filter": null, "result_fields": null, "fields": null, "config_time_budget": null, "ctx": {"_STATE_TTL_SECONDS": 86400}}, "execution_time_ms": 113.4181022644043, "success": true, "error_message": null, "response_size_bytes": 2476, "user_context": null}
+1 -1
View File
@@ -1 +1 @@
{"pid": 71, "version": 1, "ha_version": "2026.6.2", "start_ts": 1781274194.4665778} {"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784644362.8406641}
+43
View File
@@ -0,0 +1,43 @@
# =============================================================================
# Prettier Configuration for Home Assistant YAML
# Reference: https://developers.home-assistant.io/docs/documenting/yaml-style-guide/
# =============================================================================
#
# Prettier auto-formats YAML files written by OpenCode. This config aligns
# with the official Home Assistant YAML Style Guide where possible.
#
# WHAT PRETTIER ENFORCES:
# - 2-space indentation (no tabs)
# - Double quotes for strings (HA convention)
# - Trailing newline at end of file
# - Consistent spacing and formatting
# - Preserves HA custom tags (!include, !secret, !input, !env_var)
# - Preserves block style sequences and mappings
# - Preserves multiline strings (literal | and folded > styles)
# - Preserves comments
#
# WHAT PRETTIER DOES NOT ENFORCE (must be handled manually or by the AI):
# - Converting flow style [1, 2, 3] to block style sequences
# - Converting flow style { key: val } to block style mappings
# - Normalizing null/~ to implicit null (just "key:" with no value)
# - Normalizing truthy booleans (Yes/On/TRUE) to lowercase true/false
# - Wrapping long lines (templates, etc.)
#
# Users can customize this file. See: https://prettier.io/docs/en/options
# =============================================================================
# HA YAML Style Guide: "An indentation of 2 spaces must be used"
tabWidth: 2
useTabs: false
# HA YAML Style Guide: "Strings are preferably quoted with double quotes"
singleQuote: false
# Keep printWidth generous to avoid prettier breaking long YAML keys into
# explicit key notation (?). HA templates can be long but should be manually
# split using literal (|) or folded (>) block scalars per the style guide.
printWidth: 120
# Preserve multiline string formatting (literal |, folded >, etc.)
# HA YAML Style Guide: "make use of the literal style and folded style strings"
proseWrap: "preserve"
+807
View File
@@ -0,0 +1,807 @@
# Home Assistant OpenCode Rules
You are working directly within a Home Assistant installation. Your working directory is `/homeassistant`, which is the live Home Assistant configuration directory.
## CRITICAL: User Consent and Scope Rules
You MUST follow these rules strictly:
1. **Never exceed the user's request** - Do exactly what the user asks, nothing more. Do not "improve" or "enhance" beyond the stated scope.
2. **Never make changes without explicit approval** - Before modifying ANY file:
- Show the user exactly what you plan to change
- Wait for their explicit confirmation ("yes", "go ahead", "do it", etc.)
- If they haven't approved, DO NOT proceed
3. **Ask, don't assume** - If the user's request is ambiguous:
- Ask clarifying questions first
- Present options and let them choose
- Never guess at their intent
4. **Read-only by default** - When investigating or troubleshooting:
- Only read files and gather information
- Present findings and recommendations
- Wait for user instruction before making any changes
5. **One change at a time** - When making approved changes:
- Make the minimum change needed
- Show what was changed
- Let the user verify before proceeding to any next step
6. **No unsolicited modifications** - Never:
- "Clean up" code the user didn't ask about
- Add features they didn't request
- Refactor working configurations
- Fix issues they haven't mentioned
7. **Respect "no"** - If a user declines a suggestion, do not:
- Repeat the suggestion
- Make the change anyway
- Try to convince them otherwise
## Environment Context
- You are running inside the OpenCode app
- The current directory (`/homeassistant`) contains the live Home Assistant configuration
- Changes to YAML files here directly affect the Home Assistant instance
- If add-on folder access is enabled, `/addons` and `/addon_configs` are available for Home Assistant add-on development. Treat `/addon_configs` as sensitive and only inspect or modify these folders when the user explicitly asks.
- You may have access to MCP tools for interacting with Home Assistant (check with the user)
## Home Assistant Interaction Model
There are three primary, safe ways to interact with Home Assistant:
### 1. Configuration Files (YAML)
The standard way to define and customize Home Assistant behavior:
- Automations, scripts, scenes, and blueprints
- Integration and sensor configurations
- Templates, packages, and customizations
- Dashboard (Lovelace) definitions
These files are designed for user editing and are the source of truth for your Home Assistant setup.
### 2. MCP Tools (Runtime API)
Real-time interaction with the running Home Assistant instance:
- Query current entity states and history
- Control devices and call services
- Validate configurations
- Diagnose issues and detect anomalies
- Report OpenCode/HA agent capability status with `get_agent_capabilities`
### Native Home Assistant LLM Platform
Home Assistant is developing a native `llm` integration where Core integrations and custom integrations can expose curated tools through `<integration>/llm.py` and registered LLM APIs. New Home Assistant builds may also expose those APIs over native MCP endpoints such as `/api/mcp/<API ID>`; the built-in Assist API uses `/api/mcp/assist`. This is complementary to OpenCode MCP, not a replacement.
- If the optional `homeassistant_native` MCP server is available, prefer it for requests that fit the configured native Home Assistant LLM API because those tools are curated by Home Assistant.
- Use OpenCode MCP for configuration editing, safe writes, validation, admin/dev workflows, screenshots, updates, ESPHome, `hab`, Zigbee tasks, add-on development, and Home Assistant documentation lookup.
- Use `get_agent_capabilities` or `ha://agent/capabilities` to check whether the running HA instance reports the native `llm` component and native MCP endpoints.
- Use `get_home_context` for compact area/domain/entity understanding before broad state dumps.
- Use `get_ha_llm_development_guide` when helping develop or review a custom integration's native `<integration>/llm.py` provider.
- Do not assume this add-on can register tools directly with HA's native `llm` platform; native tool registration is internal to HA integrations/custom integrations. The add-on can consume configured native LLM APIs through native MCP when Home Assistant exposes them.
### 3. hab CLI (Home Assistant Builder)
A CLI tool designed for AI agents to manage Home Assistant. Run `hab` commands via the terminal:
- **Entity management**: `hab entity list`, `hab entity get light.living_room`, `hab entity logbook sensor.power --start 2h`
- **Service calls**: `hab action call light.turn_on --entity light.living_room --data '{"brightness": 200}'`
- **Automation CRUD**: `hab automation list`, `hab automation create`, `hab automation delete`
- **Dashboard management**: `hab dashboard list`, `hab dashboard view create`
- **Area/floor/zone/label**: `hab area list`, `hab area create "Kitchen"`
- **Helpers**: `hab helper list`, `hab helper create`
- **Scripts**: `hab script list`, `hab script create`
- **Scenes**: `hab scene list`, `hab scene create`, `hab scene activate "Movie Time"`
- **Blueprints**: `hab blueprint list`
- **Backups**: `hab backup list`, `hab backup create`
- **System**: `hab system info`, `hab system health`, `hab overview`
- **Devices**: `hab device list`
- **People**: `hab person list`, `hab person create`, `hab person update`
- **Categories**: `hab category list`, `hab category assign --entity light.kitchen`
- **To-do lists**: `hab todo list`, `hab todo item list todo.shopping`, `hab todo item add todo.shopping "Milk"`
- **Notifications**: `hab notification list`, `hab notification create --message "Hello" --title "Alert"`
- **Integrations**: `hab integration list`, `hab integration reload hue`, `hab integration disable mqtt`
- **Repairs**: `hab repairs list`, `hab repairs ignore <issue_id>`
- **Events**: `hab event list`, `hab event fire my_custom_event --data '{"key": "value"}'`
- **Templates**: `hab template render --expression "{{ states('sensor.temperature') }}"`
- **Search**: `hab search related`
`hab` outputs human-readable text by default. Use `--json` for structured JSON output (ideal for parsing).
`hab` is pre-authenticated via the Supervisor token - no login required.
Run `hab --help` or `hab <command> --help` for full usage details.
<!-- HAB_LIVE_HELP_START -->
```
Home Assistant Builder (hab) is a CLI utility designed for LLMs
to build and manage Home Assistant configurations.
Interactive sessions default to human-readable text. Non-interactive sessions default to JSON.
Start with 'hab guide' for workflow-level guidance optimized for LLM and agent usage.
Usage:
hab [command]
Getting Started:
auth Manage authentication
capability Inspect runtime capabilities
guide Display built-in usage guides
overview Show an overview of the Home Assistant instance
schema Show machine-readable command schema
Registry:
area Manage areas
device Manage devices
entity Manage entities
floor Manage floors
label Manage labels
person Manage persons
search Search for items and relationships
zone Manage zones
Automation:
action Call actions (services)
automation Manage automations
blueprint Manage blueprints
category Manage categories
helper Manage groups, templates, and other helpers
scene Manage scenes
script Manage scripts
Dashboard:
dashboard Manage dashboards
Other:
backup Manage backups
calendar Manage calendar events
diagnostics Manage diagnostics handlers
energy Manage energy dashboard settings
esphome Manage ESPHome devices
event Manage Home Assistant events
integration Manage integrations
network Manage network settings
notification Manage persistent notifications
repairs Manage Home Assistant repairs
system Manage system
template Work with Home Assistant templates
thread Manage Thread credentials
todo Manage to-do list items
update Update hab to the latest version
version Show version information
Additional Commands:
help Help about any command
Flags:
--config string Path to config directory (default: ~/.config/home-assistant-builder)
-h, --help help for hab
--json Use JSON output instead of human-readable text
--skip-update-check Skip automatic update check on startup
--text Use human-readable text output
--verbose Show verbose output
Use "hab [command] --help" for more information about a command.
```
<!-- HAB_LIVE_HELP_END -->
**Use configuration files when:** defining behavior, creating automations, setting up integrations
**Use MCP tools when:** checking current state, safe config writing, anomaly detection, entity diagnostics
**Use hab CLI when:** managing dashboards, areas, helpers, backups, blueprints, and bulk admin operations
### 4. zigporter CLI (Zigbee Toolkit)
A CLI for Zigbee device management in Home Assistant. Handles cascade renames (updating entity IDs across automations, scripts, scenes, and all Lovelace dashboards atomically), device inspection, stale device cleanup, and Zigbee mesh visualization.
**Cascade rename** — zigporter's unique value: when you rename an entity or device, it automatically patches every reference in automations, scripts, scenes, and Lovelace dashboards. `hab` can rename a single entity/device but does NOT cascade to references.
Key commands:
- **Cascade rename**: `zigporter rename-entity light.old_id light.new_id --apply`, `zigporter rename-device "Old Name" "New Name" --apply`
- **Device inventory**: `zigporter list-devices --json`, `zigporter list-z2m --json` (requires Z2M config)
- **Device inspection**: `zigporter inspect "Device Name" --json`, `zigporter inspect sensor.entity_id --json`
- **Stale device management**: `zigporter stale "Device" --action remove`, `zigporter stale "Device" --action ignore`
- **Post-migration cleanup**: `zigporter fix-device "Device" --apply`
- **Connectivity check**: `zigporter check`
- **ZHA export**: `zigporter export --output devices.json`
- **Mesh visualization**: `zigporter network-map --format table` (terminal), `zigporter network-map --output mesh.svg` (SVG file)
**Output format**: Use `--json` on listing/inspect commands for structured output (ideal for AI parsing). Rename commands output diffs and confirmation text.
zigporter is pre-authenticated via the Supervisor token. Z2M commands (`list-z2m`, `network-map --backend z2m`) require Z2M URL configuration in the add-on settings.
**Important limitations**:
- `rename-entity` / `rename-device` do NOT patch Jinja2 template expressions (e.g. `{{ states('old.id') }}`). A warning is printed listing affected files — inform the user these need manual review after renaming.
- The `migrate` command is inherently interactive (requires physical device actions) and must NOT be used by AI agents.
- Dry-run is the default for renames — always preview before using `--apply`.
<!-- ZIGPORTER_LIVE_HELP_START -->
```
Usage: zigporter [OPTIONS] COMMAND [ARGS]...
Migrate Zigbee devices between ZHA and Zigbee2MQTT. Supports both ZHA → Z2M
(default) and Z2M → ZHA (--direction z2m-to-zha).
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --version -v Show version and exit. │
│ --install-completion Install completion for the current shell. │
│ --show-completion Show completion for the current shell, to │
│ copy it or customize the installation. │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ setup Create or update the configuration file in the zigporter │
│ config directory. │
│ check Verify that all requirements are in place before migrating. │
│ export Export current ZHA devices, entities, areas, and automation │
│ references to JSON. │
│ export-z2m Export current Z2M devices, entities, areas, and automation │
│ references to JSON. │
│ list-z2m List all devices currently paired with Zigbee2MQTT. │
│ list-devices List all Home Assistant devices across all integrations. │
│ migrate Interactive wizard to migrate devices between ZHA and │
│ Zigbee2MQTT. │
│ inspect Show all automations, scripts, scenes, and dashboard cards │
│ that depend on a device. │
│ rename-entity Rename an entity ID and update all references in automations, │
│ scripts, scenes, and dashboards. │
│ rename-device Rename a device and cascade the change to all its entities, │
│ automations, scripts, scenes, and dashboards. │
│ stale Identify and manage offline/stale devices across all │
│ integrations. │
│ fix-device Remove stale ZHA device entries left behind after migration │
│ to Zigbee2MQTT. │
│ network-map Show Zigbee mesh topology with signal strength (LQI) for each │
│ device. │
╰──────────────────────────────────────────────────────────────────────────────╯
```
<!-- ZIGPORTER_LIVE_HELP_END -->
**Use zigporter CLI when:** renaming entities/devices with cascade updates, inspecting Zigbee devices across integrations, cleaning up stale or post-migration devices, visualizing the Zigbee mesh
### 5. Internal Directories (OFF-LIMITS)
Home Assistant manages internal state in directories like `.storage/`. These are:
- Not designed for direct access
- Subject to change without notice
- Potentially dangerous to modify
**Never access internal directories directly - use configuration files or MCP tools instead.**
## RESTRICTED: Internal Home Assistant Directories
**NEVER read, modify, or directly interact with these internal directories:**
| Directory | Contains | Use Instead |
|-----------|----------|-------------|
| `.storage/` | Entity/device/area registries, auth, system state | MCP: `get_devices`, `get_areas`, `get_entity_details` |
| `.cloud/` | Home Assistant Cloud state | N/A - managed by HA Cloud |
| `deps/` | Python dependency cache | N/A - managed by HA Core |
| `tts/` | Text-to-speech cache | N/A - managed by TTS integration |
| `home-assistant_v2.db` | History SQLite database | MCP: `get_history`, `get_logbook` |
| `home-assistant.log` | Raw system logs | MCP: `get_error_log` |
These contain internal Home Assistant state that:
1. Is managed exclusively by Home Assistant core
2. Can corrupt your installation if modified incorrectly
3. May be overwritten by Home Assistant at any time
4. Has no stable schema or format guarantees
**For information that seems to require internal access, there is always a proper alternative:**
- Need entity details? -> Read configuration files OR use `get_entity_details`
- Need device info? -> Use `get_devices` MCP tool
- Need to check history? -> Use `get_history` MCP tool
- Need to see errors? -> Use `get_error_log` MCP tool
## File Structure Knowledge
### Configuration Files (Primary Interface - Read/Write with User Approval)
These are the user-facing configuration files - the primary way to define Home Assistant behavior:
- `configuration.yaml` - Main configuration file
- `automations.yaml` - Automation definitions (if using UI or split config)
- `scripts.yaml` - Script definitions
- `scenes.yaml` - Scene definitions
- `secrets.yaml` - Sensitive values (NEVER commit or expose)
- `customize.yaml` - Entity customizations
- `groups.yaml` - Group definitions
- `packages/` - Package-based configuration splits
- `blueprints/` - Automation and script blueprints
- `custom_components/` - Custom integrations (HACS or manual)
- `www/` - Static files served at /local/
- `themes/` - Custom themes
- `*.yaml` in root - Any user-created YAML configuration
**These files are designed for editing** and are equally valid as MCP tools for research and changes.
### Internal Directories (OFF-LIMITS - Never Access Directly)
- `.storage/` - Internal registries and state (use MCP tools)
- `.cloud/` - Cloud authentication (managed by HA)
- `deps/` - Python dependencies (managed by HA)
- `tts/` - TTS cache (managed by HA)
- `__pycache__/` - Python bytecode (managed by Python)
- `home-assistant_v2.db` - History database (use MCP `get_history`)
- `home-assistant.log` - Logs (use MCP `get_error_log`)
## Working with YAML on the Command Line (`yq`)
To read, query, or convert YAML from the shell, use **`yq`** (the mikefarah/Go tool, pre-installed on `PATH`). It is the correct tool because it tolerates Home Assistant's custom tags — `!include`, `!secret`, `!env_var`, `!input`, and the `!include_dir_*` family.
**Do NOT reach for `python3 -c "import yaml"` (PyYAML) or Ruby's YAML for HA config** — both crash with a constructor error on the very first `!include`/`!secret`, because those tags are Home Assistant extensions, not standard YAML. `yq` parses them with no setup.
### Read / query (always safe — never errors on HA tags)
```
yq '.homeassistant.latitude' configuration.yaml # Print a nested value
yq '.automation | tag' configuration.yaml # Inspect the tag itself -> !include
yq 'keys' configuration.yaml # List top-level keys
yq -o=json '.' configuration.yaml | jq '.sensor' # Convert to JSON to pipe into jq
```
Note: output and JSON conversion strip the tag — `!secret home_latitude` prints as `home_latitude`. Use `| tag` when you need to see the tag. Never round-trip a file *through* JSON and back; that permanently loses every `!include`/`!secret`.
### Writing / editing
Prefer the sanctioned write path: **`write_config_safe`** (MCP — validates, backs up, and blocks accidental content loss) or read the full file and use the editor. Reserve `yq -i` for quick, low-risk edits, and only with these two caveats in mind:
1. **A custom tag sticks to the value you overwrite.** `yq -i '.homeassistant.latitude = 52.37'` on a `!secret`-tagged node produces the corrupt `latitude: !secret 52.37`. When replacing a tagged value, reset the tag in the *same* expression:
```
yq -i '(.homeassistant.latitude tag = "") | .homeassistant.latitude = 52.37' configuration.yaml
```
To *add* a secret reference, set the tag explicitly: `yq -i '.http.api_key = "my_api_key" | .http.api_key tag = "!secret"' configuration.yaml`
2. **`yq -i` strips blank separator lines** (and collapses inline-comment spacing) across the whole file. No data is lost, but diffs are noisier. When a clean, minimal diff matters, use the editor instead.
### Validation is not a syntax check
`yq` only confirms YAML *parses*. To validate a Home Assistant *configuration* (resolving `!include`/`!secret` and checking integration schemas), use `check_config_syntax` / `write_config_safe` (MCP), not `yq`.
## YAML Style Guide (MANDATORY)
All YAML written or modified MUST follow the official Home Assistant YAML Style Guide.
Reference: https://developers.home-assistant.io/docs/documenting/yaml-style-guide/
A Prettier formatter is configured for this environment and will auto-format files on save.
However, Prettier only enforces a subset of the rules below. You are responsible for following
ALL rules, especially those Prettier cannot enforce (marked with *).
### Indentation
2 spaces. Tabs are forbidden.
```yaml
# Good
example:
one: 1
# Bad
example:
bad: 2
```
### Booleans *
Only `true` and `false` in lowercase. Never use `Yes`, `No`, `On`, `Off`, `TRUE`, etc.
```yaml
# Good
one: true
two: false
# Bad
one: True
two: on
three: yes
```
### Strings
Double quotes for strings. Single quotes are not allowed.
```yaml
# Good
example: "Hi there!"
# Bad
example: 'Hi there!'
```
**Exceptions** (no quotes needed): entity IDs, area IDs, device IDs, platform types,
trigger types, condition types, action names, device classes, event names, attribute names,
and values from a fixed set of options (e.g., `mode`).
```yaml
# Good
actions:
- action: light.turn_on
target:
entity_id: light.living_room
area_id: living_room
data:
message: "Hello!"
transition: 10
# Bad - don't quote entity IDs and action names
actions:
- action: "light.turn_on"
target:
entity_id: "light.living_room"
```
### Sequences (Lists) *
Use block style. Flow style `[1, 2, 3]` must not be used.
Block sequences must be indented under their key.
```yaml
# Good
options:
- 1
- 2
- 3
# Bad
options: [1, 2, 3]
# Bad - not indented under key
options:
- 1
- 2
```
### Mappings *
Block style only. Flow style `{ key: val }` must not be used.
```yaml
# Good
example:
one: 1
two: 2
# Bad
example: { one: 1, two: 2 }
```
### Null Values *
Use implicit null (just `key:` with no value). Never use `null` or `~`.
```yaml
# Good
initial:
# Bad
initial: null
initial: ~
```
### Comments
Capitalized, with a space after `#`, indented to match current level.
```yaml
# Good
example:
# This is a comment
one: true
# Bad
example:
# Comment at wrong indent
#Missing space
#lowercase start
one: true
```
### Multiline Strings
Use literal `|` (preserves newlines) or folded `>` (joins lines) block scalars.
Avoid `\n` in strings. Prefer no-chomp (`|`, `>`) unless you need strip (`|-`, `>-`).
```yaml
# Good
message: |
Hello!
This is a multiline
notification message.
# Good - folded
description: >
This is a long description that
will be joined into a single line.
# Bad
message: "Hello!\nThis is a multiline\nnotification message.\n"
```
### Templates
Double quotes outside, single quotes inside. Use `states()` and `state_attr()`
helpers, not direct state object access. Split long templates across multiple lines.
```yaml
# Good
value_template: "{{ states('sensor.temperature') }}"
attribute_template: "{{ state_attr('climate.living_room', 'temperature') }}"
# Good - long template split with folded style
value_template: >-
{{
is_state('sensor.bedroom_co_status', 'Ok')
and is_state('sensor.kitchen_co_status', 'Ok')
}}
# Bad - single quotes outside
value_template: '{{ "some_value" == other_value }}'
# Bad - direct state object access
value_template: "{{ states.sensor.temperature.state }}"
```
### Service Action Targets *
Always use `target:` for entity/device/area targeting. Do not put `entity_id` at
the action level or inside `data:`.
```yaml
# Good
actions:
- action: light.turn_on
target:
entity_id: light.living_room
# Bad
actions:
- action: light.turn_on
entity_id: light.living_room
# Bad
actions:
- action: light.turn_on
data:
entity_id: light.living_room
```
### Scalar vs List *
If a property accepts both, use a scalar for single values. Do not wrap a single
value in a list. Do not use comma-separated strings.
```yaml
# Good
entity_id: light.living_room
entity_id:
- light.living_room
- light.office
# Bad - single value in a list
entity_id:
- light.living_room
# Bad - comma separated
entity_id: "light.living_room, light.office"
```
### List of Mappings *
When a property accepts a mapping or list of mappings (e.g., `actions`, `conditions`),
always use a list even for a single item.
```yaml
# Good
actions:
- action: light.turn_on
target:
entity_id: light.living_room
# Bad
actions:
action: light.turn_on
target:
entity_id: light.living_room
```
## Core Competencies
### YAML Configuration
- Follow the YAML Style Guide above for ALL configuration changes
- Use anchors (`&name`) and aliases (`*name`) for DRY configurations
- Understand `!include`, `!include_dir_named`, `!include_dir_list`, `!include_dir_merge_named`, `!include_dir_merge_list`
- To read/query these files from the shell use `yq` (tag-tolerant); PyYAML/Ruby crash on HA tags — see "Working with YAML on the Command Line"
- Know when to use packages for organized configuration
### Automations
- Write automations using both YAML and understand the UI format
- Understand triggers: state, time, event, webhook, mqtt, template, zone, device, etc.
- Understand conditions: state, numeric_state, time, template, zone, and, or, not
- Understand actions: service calls, delays, wait_template, choose, repeat, if/then/else
- Use trigger variables and automation context effectively
- Implement proper error handling with `continue_on_error`
### Templates (Jinja2)
- Write efficient Jinja2 templates for Home Assistant
- Use filters: `float`, `int`, `round`, `timestamp_custom`, `regex_match`, etc.
- Use functions: `states()`, `state_attr()`, `is_state()`, `is_state_attr()`, `has_value()`
- Access trigger data: `trigger.to_state`, `trigger.from_state`, `trigger.entity_id`
- Handle unavailable/unknown states gracefully
### Integrations
- Know common integrations and their configuration patterns
- Understand MQTT, REST, and template-based integrations
- Configure input_* helpers: input_boolean, input_number, input_select, input_text, input_datetime
- Set up utility_meter, statistics, and history_stats sensors
### Lovelace Dashboards
- Write Lovelace YAML configurations
- Know standard cards and their options
- Understand conditional cards, custom cards, and card-mod
- Configure views, themes, and resources
## Best Practices
1. **Always validate** - Remind users to check configuration before restarting
2. **Use secrets** - Never hardcode sensitive data; use `!secret` references
3. **Backup first** - Suggest backups before major changes
4. **Incremental changes** - Make small, testable changes
5. **Comments** - Add YAML comments explaining complex logic
6. **Naming conventions** - Use consistent entity_id naming (e.g., `sensor.room_type_name`)
## Safety Guidelines
- NEVER expose or display contents of `secrets.yaml`
- NEVER include API keys, tokens, or passwords in responses
- NEVER make changes without explicit user approval
- NEVER access `.storage/`, `.cloud/`, or other internal directories
- NEVER attempt to modify Home Assistant's internal databases or registries
- NEVER parse internal JSON files for entity/device/area information
- ALWAYS prefer MCP tools for querying runtime state over internal file access
- ALWAYS use `call_service` through MCP rather than modifying state files
- WARN users before changes that require restart vs reload
- SUGGEST backing up files before major modifications
- CHECK configuration validity when possible
- ALWAYS confirm with user before writing, editing, or deleting any file
## MCP Tools and Configuration Files
You have two complementary interfaces for working with Home Assistant:
### Configuration Files
Read and modify YAML files to understand and change Home Assistant's defined behavior:
- Review `automations.yaml` to understand existing automations
- Edit `configuration.yaml` to add new integrations
- Create new files in `packages/` for organized configuration
- Examine `custom_components/` for custom integration code
### MCP Tools (When Available)
Query and interact with the running Home Assistant instance:
- `get_states`, `search_entities`, `get_home_context` - Current entity states and compact area/domain/entity context
- `call_service` - Control devices (with confirmation)
- `get_history`, `get_logbook` - Historical data
- `get_devices`, `get_areas` - Device and area registry info
- `write_config_safe` - **Safe config writing with automatic validation, content protection, and backup**
- `validate_config` - Check configuration validity
- `get_error_log` - System errors and warnings
- `diagnose_entity` - Comprehensive entity troubleshooting
- `get_agent_capabilities` - OpenCode MCP capabilities and native HA `llm` / MCP readiness
- `get_ha_llm_development_guide` - Upstream references and starter template for native `<integration>/llm.py` providers
- `watch_firmware_update` - **Real-time firmware update monitoring** (ESPHome, WLED, Zigbee, etc.)
- `get_available_updates`, `update_component` - System update management
- `screenshot_url` - **Visual verification** of dashboards and UI pages (requires `screenshot_enabled` option)
### Choosing the Right Approach
| Task | Configuration Files | MCP Tools | hab CLI | zigporter CLI |
|------|---------------------|-----------|---------|---------------|
| Create/edit automations | Primary | **Write with `write_config_safe`** | `hab automation create` | N/A |
| Understand automation logic | Read YAML | Check state with `get_states` | `hab automation get` | N/A |
| Check current device state | Reference only | Primary (`get_home_context` for focused context) | `hab entity get` | N/A |
| Control devices | N/A | `call_service` | `hab action call` | N/A |
| Add new integrations | Primary | N/A | N/A | N/A |
| Troubleshoot issues | Review configs | `diagnose_entity`, `get_error_log` | `hab system health` | N/A |
| Check agent/LLM readiness | N/A | `get_agent_capabilities` | N/A | N/A |
| Develop native HA LLM tools | `custom_components/*/llm.py` | `get_ha_llm_development_guide` | N/A | N/A |
| Find entities | Grep YAML files | `search_entities` | `hab entity list --domain` | N/A |
| View history | N/A | `get_history` | N/A | N/A |
| **Manage dashboards** | Edit YAML | N/A | **`hab dashboard` (primary)** | N/A |
| **Verify UI changes** | N/A | **`screenshot_url`** | N/A | N/A |
| **Manage areas/floors** | N/A | `get_areas` (read-only) | **`hab area/floor` (CRUD)** | N/A |
| **Manage helpers** | N/A | N/A | **`hab helper` (primary)** | N/A |
| **Backups** | N/A | N/A | **`hab backup` (primary)** | N/A |
| **Blueprints** | N/A | N/A | **`hab blueprint` (primary)** | N/A |
| **Update firmware** | N/A | **`watch_firmware_update`** | N/A | N/A |
| **Check for updates** | N/A | `get_available_updates` | N/A | N/A |
| **Update HA Core/OS** | N/A | `update_component` | N/A | N/A |
| **Rename entity with cascade** | N/A | N/A | `hab entity update` (no cascade) | **`zigporter rename-entity` (primary)** |
| **Rename device with cascade** | N/A | N/A | `hab device update` (no cascade) | **`zigporter rename-device` (primary)** |
| **Inspect Zigbee device** | N/A | `get_entity_details` | `hab device list` | **`zigporter inspect --json` (cross-ref ZHA+Z2M+HA)** |
| **List Z2M devices** | N/A | N/A | N/A | **`zigporter list-z2m --json`** |
| **Clean up stale devices** | N/A | N/A | N/A | **`zigporter stale --action`** |
| **Fix post-migration entities** | N/A | N/A | N/A | **`zigporter fix-device --apply`** |
| **Zigbee mesh topology** | N/A | N/A | N/A | **`zigporter network-map`** |
### Update Management (IMPORTANT)
**For device firmware updates (ESPHome, WLED, Zigbee, etc.):**
Always use `watch_firmware_update` - it provides real-time visual progress:
```
watch_firmware_update(entity_id="update.device_firmware", start_update=true)
```
This single tool handles: starting the update, monitoring progress, and reporting results.
**For system updates (Core, OS, Supervisor, Apps):**
```
1. get_available_updates() -> Check what needs updating
2. update_component(component="core") -> Start update (returns job_id)
3. get_update_progress(job_id="...") -> Monitor progress
```
**Both approaches are valid and complementary.** Use configuration files for defining behavior and MCP tools for runtime interaction.
## Documentation Currency
Home Assistant releases monthly updates with new features, deprecations, and breaking changes. Your training data may be outdated. **Always verify configuration syntax against current documentation.**
### Before Writing or Modifying Configuration
**ALWAYS use these MCP tools before suggesting configuration changes:**
1. **Check the installed version**: Use `get_config` to see what HA version is running
2. **Fetch current integration docs**: Use `get_integration_docs` to get current YAML syntax
3. **Check for breaking changes**: Use `get_breaking_changes` to see recent syntax changes
4. **Write config safely**: Use `write_config_safe` with `dry_run=true` to validate before presenting to user
### Documentation Tools (MCP)
| Tool | When to Use |
|------|-------------|
| `get_integration_docs` | Before writing ANY integration configuration |
| `get_breaking_changes` | When user reports config stopped working after update |
| `write_config_safe` | **ALWAYS use to write config files** — validates, blocks accidental content loss, and auto-restores on failure |
| `check_config_syntax` | Quick ad-hoc deprecation check (write_config_safe includes this automatically) |
### Workflow Example
When a user asks "Help me set up a template sensor":
```
1. get_config() -> Check HA version (e.g., 2024.12.1)
2. get_integration_docs("template") -> Get current syntax and examples
3. read_file(path) -> Read the EXISTING file content first
4. Draft configuration: include ALL existing content + new changes
5. write_config_safe(path, yaml, dry_run=true) -> Pre-validate everything
6. If errors: fix and repeat step 5
7. Present validated config to user and get approval
8. write_config_safe(path, yaml) -> Write for real (auto backup + validation)
```
### Common Deprecation Patterns
Be especially careful with these frequently-changed areas:
- **Template sensors/binary_sensors**: `platform: template` under `sensor:` is deprecated; use top-level `template:`
- **Entity configurations**: Many moved from YAML to UI-based config
- **Trigger-based templates**: Newer syntax preferred over legacy template sensors
- **Device triggers**: Syntax evolves with new device types
- **MQTT platform syntax**: `platform: mqtt` under domain keys is deprecated; use top-level `mqtt:` key
- **Direct state access**: `states.sensor.x.state` is fragile; use `states('sensor.x')` helper
- **entity_id in data**: Deprecated; use `target:` for service call targeting
**When in doubt, fetch the docs. Never rely solely on training data for configuration syntax.**
## Common Tasks
### Creating an Automation
1. **Read the existing `automations.yaml` first** — you must include ALL existing automations in the final write
2. Understand the goal and identify trigger conditions
3. Determine required entities (search if MCP available)
4. Draft the automation YAML with clear comments
5. **Show the draft to the user and wait for approval** — the draft must contain all existing automations plus the new one
6. Only write the file after explicit user confirmation
7. Suggest testing approach
> **WARNING:** Never write partial content to ANY config file. Always read the existing file first and include ALL existing content in your write. `write_config_safe` will block writes that would reduce list entries, remove top-level keys, or significantly shrink the file — but you should verify this yourself before presenting the draft to the user.
### Troubleshooting
1. Check entity states and history (via MCP if available)
2. Review relevant configuration files
3. Check Home Assistant logs for errors
4. Identify common issues (unavailable entities, template errors, timing issues)
5. **Present findings and wait for user to request specific fixes**
### Optimizing Configuration
1. Identify redundant or inefficient patterns
2. **Present recommendations to user**
3. Wait for user to approve specific changes
4. Implement only the changes the user explicitly approves
@@ -0,0 +1,8 @@
Timestamp: 20260719_015459
Provider: gemini
Error: Expecting value: line 1 column 1 (char 0)
Response length: 208
Response bytes: b'The following lights are currently on: Bedroom Lights, GT-AX11000 LED, WiZ Dimmable White Bedroom 4, WiZ Dimmable White Bedroom 6, WiZ Dimmable White Bedroom 5, WiZ Dimmable White Bedroom 7, Oven1, and Oven2.'
Response repr: 'The following lights are currently on: Bedroom Lights, GT-AX11000 LED, WiZ Dimmable White Bedroom 4, WiZ Dimmable White Bedroom 6, WiZ Dimmable White Bedroom 5, WiZ Dimmable White Bedroom 7, Oven1, and Oven2.'
Full response:
The following lights are currently on: Bedroom Lights, GT-AX11000 LED, WiZ Dimmable White Bedroom 4, WiZ Dimmable White Bedroom 6, WiZ Dimmable White Bedroom 5, WiZ Dimmable White Bedroom 7, Oven1, and Oven2.
@@ -0,0 +1,13 @@
Timestamp: 20260719_015535
Provider: gemini
Error: Expecting value: line 1 column 1 (char 0)
Response length: 347
Response bytes: b'The target temperatures are set as follows:\n* **Living Room:** 23.1\xc2\xb0C (currently in cool mode)\n* **Basement:** 19\xc2\xb0C (currently in heat mode)\n* **VT Basement:** 7.0\xc2\xb0C (currently off)\n* **Climate Scheduler Climate Schedule Living Room:** 19\xc2\xb0C (currently off)\n* **Climate Scheduler Climate Schedule Basement:** 19\xc2\xb0C (currently in auto mode)'
Response repr: 'The target temperatures are set as follows:\n* **Living Room:** 23.1°C (currently in cool mode)\n* **Basement:** 19°C (currently in heat mode)\n* **VT Basement:** 7.0°C (currently off)\n* **Climate Scheduler Climate Schedule Living Room:** 19°C (currently off)\n* **Climate Scheduler Climate Schedule Basement:** 19°C (currently in auto mode)'
Full response:
The target temperatures are set as follows:
* **Living Room:** 23.1°C (currently in cool mode)
* **Basement:** 19°C (currently in heat mode)
* **VT Basement:** 7.0°C (currently off)
* **Climate Scheduler Climate Schedule Living Room:** 19°C (currently off)
* **Climate Scheduler Climate Schedule Basement:** 19°C (currently in auto mode)
+2649 -108
View File
File diff suppressed because it is too large Load Diff
+2348
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,484 @@
blueprint:
name: Ikea BILRESA scroll wheel (sensors for scroll, events for press)
description: "Ikea BILRESA blueprint met 3 kanalen.\n\n- Scroll links/rechts gebruikt
sensor.*current_switch_position_* (pulse 0/1).\n Alleen rising edge (0->1) triggert,
dus per klik exact 1 stap en direct.\n- Druk op scrollwiel blijft event.* gebruiken
zodat single/double/long/hold blijft werken.\n"
domain: automation
input:
remote:
name: Remote
description: Ikea BILRESA remote control device
selector:
device:
multiple: false
channel1_section:
name: Kanaal 1
collapsed: true
input:
click_action_ch1:
name: 'Kanaal 1: click actie'
default: []
selector:
action: {}
double_click_action_ch1:
name: 'Kanaal 1: double click actie'
default: []
selector:
action: {}
long_click_action_ch1:
name: 'Kanaal 1: long press actie'
default: []
selector:
action: {}
on_hold_action_ch1:
name: 'Kanaal 1: on-hold actie'
description: Herhaalt totdat losgelaten, zie on_hold_delay
default: []
selector:
action: {}
scroll_wheel_target_ch1:
name: 'Kanaal 1: scroll target(s)'
description: Light of media_player die je met het wiel wilt bedienen
default: []
selector:
entity:
multiple: true
filter:
- domain:
- light
- media_player
reorder: false
scroll_wheel_mode_ch1:
name: 'Kanaal 1: scroll mode'
default: 'lights: dim'
selector:
select:
options:
- 'lights: dim'
- 'lights: color temp and color hue'
- 'lights: color temp only'
- 'lights: color hue only'
- 'media: volume control'
custom_value: false
multiple: false
sort: false
channel2_section:
name: Kanaal 2
collapsed: true
input:
click_action_ch2:
name: 'Kanaal 2: click actie'
default: []
selector:
action: {}
double_click_action_ch2:
name: 'Kanaal 2: double click actie'
default: []
selector:
action: {}
long_click_action_ch2:
name: 'Kanaal 2: long press actie'
default: []
selector:
action: {}
on_hold_action_ch2:
name: 'Kanaal 2: on-hold actie'
description: Herhaalt totdat losgelaten, zie on_hold_delay
default: []
selector:
action: {}
scroll_wheel_target_ch2:
name: 'Kanaal 2: scroll target(s)'
default: []
selector:
entity:
multiple: true
filter:
- domain:
- light
- media_player
reorder: false
scroll_wheel_mode_ch2:
name: 'Kanaal 2: scroll mode'
default: 'lights: dim'
selector:
select:
options:
- 'lights: dim'
- 'lights: color temp and color hue'
- 'lights: color temp only'
- 'lights: color hue only'
- 'media: volume control'
custom_value: false
multiple: false
sort: false
channel3_section:
name: Kanaal 3
collapsed: true
input:
click_action_ch3:
name: 'Kanaal 3: click actie'
default: []
selector:
action: {}
double_click_action_ch3:
name: 'Kanaal 3: double click actie'
default: []
selector:
action: {}
long_click_action_ch3:
name: 'Kanaal 3: long press actie'
default: []
selector:
action: {}
on_hold_action_ch3:
name: 'Kanaal 3: on-hold actie'
description: Herhaalt totdat losgelaten, zie on_hold_delay
default: []
selector:
action: {}
scroll_wheel_target_ch3:
name: 'Kanaal 3: scroll target(s)'
default: []
selector:
entity:
multiple: true
filter:
- domain:
- light
- media_player
reorder: false
scroll_wheel_mode_ch3:
name: 'Kanaal 3: scroll mode'
default: 'lights: dim'
selector:
select:
options:
- 'lights: dim'
- 'lights: color temp and color hue'
- 'lights: color temp only'
- 'lights: color hue only'
- 'media: volume control'
custom_value: false
multiple: false
sort: false
global_section:
name: Globale instellingen
collapsed: true
input:
dim_step_pct:
name: Dimmer stapgrootte (%)
default: 5
selector:
number:
mode: box
step: 1.0
dim_min_pct:
name: Minimum brightness (%)
default: 5
selector:
number:
mode: box
step: 1.0
min_color_temp:
name: Minimum kleurtemperatuur (K)
default: 2200
selector:
number:
mode: box
step: 1.0
max_color_temp:
name: Maximum kleurtemperatuur (K)
default: 4000
selector:
number:
mode: box
step: 1.0
color_temp_step:
name: Kleurtemperatuur stap (K)
default: 100
selector:
number:
mode: box
step: 1.0
color_hue_step:
name: Hue stap (graden, 0..360)
default: 4
selector:
number:
mode: box
step: 1.0
color_saturation:
name: Saturation (%)
default: 100
selector:
number:
mode: box
step: 1.0
on_hold_delay:
name: On-hold delay (sec)
default: 0.1
selector:
number:
mode: box
step: 0.1
volume_step_pct:
name: Volume stap (%)
default: 2
selector:
number:
mode: box
step: 1.0
volume_max_pct:
name: Max volume (%)
default: 50
selector:
number:
mode: box
step: 1.0
source_url: https://raw.githubusercontent.com/BoGu5/ha-blueprints/a688916fc161affcb2577e1ed90bb4f7ecf778fb/kea%20BILRESA%20scroll%20wheel%20(sensors%20for%20scroll%2C%20events%20for%20press)
alias: Ikea_BILRESA_scroll_wheel
mode: queued
max: 50
max_exceeded: silent
trigger:
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ var_press_event_ch1 }}'
id: press_ch1
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ var_press_event_ch2 }}'
id: press_ch2
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ var_press_event_ch3 }}'
id: press_ch3
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_sensor_ch1 }}'
id: scroll_right_ch1
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_sensor_ch2 }}'
id: scroll_right_ch2
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_sensor_ch3 }}'
id: scroll_right_ch3
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_sensor_ch1 }}'
id: scroll_left_ch1
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_sensor_ch2 }}'
id: scroll_left_ch2
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_sensor_ch3 }}'
id: scroll_left_ch3
condition:
- condition: template
value_template: "{% set o = trigger.event.data.old_state %} {% set n = trigger.event.data.new_state
%} {{ o is not none and n is not none\n and o.state not in ['unavailable','unknown']\n
\ and n.state not in ['unavailable','unknown'] }}"
variables:
targets_entities_ch1: !input scroll_wheel_target_ch1
targets_entities_ch2: !input scroll_wheel_target_ch2
targets_entities_ch3: !input scroll_wheel_target_ch3
scroll_wheel_mode_ch1: !input scroll_wheel_mode_ch1
scroll_wheel_mode_ch2: !input scroll_wheel_mode_ch2
scroll_wheel_mode_ch3: !input scroll_wheel_mode_ch3
var_min_pct: !input dim_min_pct
var_dim_step_pct: !input dim_step_pct
var_color_hue_step: !input color_hue_step
var_color_saturation: !input color_saturation
var_min_color_temp: !input min_color_temp
var_max_color_temp: !input max_color_temp
var_color_temp_step: !input color_temp_step
var_on_hold_delay: !input on_hold_delay
var_volume_step_pct: !input volume_step_pct
var_volume_max_pct: !input volume_max_pct
action:
- variables:
channel: '{% if trigger.id in [''press_ch1'',''scroll_left_ch1'',''scroll_right_ch1'']
%}ch1 {% elif trigger.id in [''press_ch2'',''scroll_left_ch2'',''scroll_right_ch2'']
%}ch2 {% elif trigger.id in [''press_ch3'',''scroll_left_ch3'',''scroll_right_ch3'']
%}ch3 {% else %}unknown{% endif %}'
is_press: '{{ trigger.id is match(''^press_'') }}'
is_scroll: '{{ trigger.id is match(''^scroll_'') }}'
scroll_dir: '{% if trigger.id in [''scroll_left_ch1'',''scroll_left_ch2'',''scroll_left_ch3'']
%}left {% elif trigger.id in [''scroll_right_ch1'',''scroll_right_ch2'',''scroll_right_ch3'']
%}right {% else %}none{% endif %}'
pulse_rising: '{% set o = trigger.event.data.old_state.state %} {% set n = trigger.event.data.new_state.state
%} {{ (o in [''0'',''off''] and n in [''1'',''on'']) }}'
scroll_steps: '{{ 1 if (is_scroll and pulse_rising) else 0 }}'
press_event_type: "{% if is_press %}\n {{ trigger.event.data.new_state.attributes.event_type
| default('') }}\n{% else %}\n {{ '' }}\n{% endif %}"
- choose:
- conditions:
- condition: template
value_template: '{{ is_press and press_event_type == ''multi_press_1'' }}'
sequence:
- choose:
- conditions: '{{ channel == ''ch1'' }}'
sequence: !input click_action_ch1
- conditions: '{{ channel == ''ch2'' }}'
sequence: !input click_action_ch2
- conditions: '{{ channel == ''ch3'' }}'
sequence: !input click_action_ch3
- conditions:
- condition: template
value_template: '{{ is_press and press_event_type == ''multi_press_2'' }}'
sequence:
- choose:
- conditions: '{{ channel == ''ch1'' }}'
sequence: !input double_click_action_ch1
- conditions: '{{ channel == ''ch2'' }}'
sequence: !input double_click_action_ch2
- conditions: '{{ channel == ''ch3'' }}'
sequence: !input double_click_action_ch3
- conditions:
- condition: template
value_template: '{{ is_press and press_event_type == ''long_press'' }}'
sequence:
- choose:
- conditions: '{{ channel == ''ch1'' }}'
sequence:
- sequence: !input long_click_action_ch1
- repeat:
while: "{{ repeat.index < 500\n and state_attr(var_press_event_ch1,
'event_type') == 'long_press' }}"
sequence:
- sequence: !input on_hold_action_ch1
- delay: '{{ var_on_hold_delay }}'
- conditions: '{{ channel == ''ch2'' }}'
sequence:
- sequence: !input long_click_action_ch2
- repeat:
while: "{{ repeat.index < 500\n and state_attr(var_press_event_ch2,
'event_type') == 'long_press' }}"
sequence:
- sequence: !input on_hold_action_ch2
- delay: '{{ var_on_hold_delay }}'
- conditions: '{{ channel == ''ch3'' }}'
sequence:
- sequence: !input long_click_action_ch3
- repeat:
while: "{{ repeat.index < 500\n and state_attr(var_press_event_ch3,
'event_type') == 'long_press' }}"
sequence:
- sequence: !input on_hold_action_ch3
- delay: '{{ var_on_hold_delay }}'
- conditions:
- condition: template
value_template: '{{ is_scroll and scroll_steps > 0 and scroll_dir in [''left'',''right'']
}}'
sequence:
- repeat:
for_each: '{{ (targets_entities_ch1 if channel == ''ch1'' else (targets_entities_ch2
if channel == ''ch2'' else targets_entities_ch3)) }}'
sequence:
- choose:
- conditions:
- condition: template
value_template: '{{ (scroll_wheel_mode_ch1 if channel == ''ch1'' else
(scroll_wheel_mode_ch2 if channel == ''ch2'' else scroll_wheel_mode_ch3))
== ''lights: dim'' }}'
sequence:
- if:
- condition: template
value_template: '{{ is_state(repeat.item, ''on'') }}'
then:
- action: light.turn_on
target:
entity_id: '{{ repeat.item }}'
data:
brightness_step_pct: "{% if scroll_dir == 'right' %}\n {{ scroll_steps
* var_dim_step_pct }}\n{% else %}\n {{ -1 * ([scroll_steps *
var_dim_step_pct,\n state_attr(repeat.item, 'brightness')
* 100 / 254 - var_min_pct] | min) }}\n{% endif %}"
transition: 0.1
- conditions:
- condition: template
value_template: "{% set mode = (scroll_wheel_mode_ch1 if channel ==
'ch1' else (scroll_wheel_mode_ch2 if channel == 'ch2' else scroll_wheel_mode_ch3))
%} {{ mode == 'lights: color temp only'\n or (mode == 'lights: color
temp and color hue' and state_attr(repeat.item, 'color_temp_kelvin')
is not none) }}"
sequence:
- action: script.light_color_temp_helper
data:
mode: '{{ ''down'' if scroll_dir == ''left'' else ''up'' }}'
min_color_temp: '{{ var_min_color_temp }}'
max_color_temp: '{{ var_max_color_temp }}'
color_temp_step: '{{ var_color_temp_step * scroll_steps }}'
target_light: '{{ repeat.item }}'
- conditions:
- condition: template
value_template: "{% set mode = (scroll_wheel_mode_ch1 if channel ==
'ch1' else (scroll_wheel_mode_ch2 if channel == 'ch2' else scroll_wheel_mode_ch3))
%} {{ mode == 'lights: color hue only'\n or (mode == 'lights: color
temp and color hue' and state_attr(repeat.item, 'color_temp_kelvin')
is none) }}"
sequence:
- action: script.light_color_hs_helper
data:
target_light: '{{ repeat.item }}'
color_hue_step: '{{ var_color_hue_step * scroll_steps }}'
color_saturation: '{{ var_color_saturation }}'
mode: '{{ ''down'' if scroll_dir == ''left'' else ''up'' }}'
- conditions:
- condition: template
value_template: '{{ (scroll_wheel_mode_ch1 if channel == ''ch1'' else
(scroll_wheel_mode_ch2 if channel == ''ch2'' else scroll_wheel_mode_ch3))
== ''media: volume control'' }}'
sequence:
- action: media_player.volume_set
target:
entity_id: '{{ repeat.item }}'
data:
volume_level: "{{ [[\n (state_attr(repeat.item, 'volume_level')
| float(0)) * 100\n + (1 if scroll_dir == 'right' else -1) *
scroll_steps * var_volume_step_pct,\n var_volume_max_pct\n ]
| min, 0] | max / 100 }}"
trigger_variables:
var_remote: !input remote
var_press_event_ch1: "{% for item in device_entities(var_remote) %}\n {% set m
= item | regex_findall(find='^event\\\\..+_3$') %}\n {% if m | length > 0 %}{{
m[0] }}{% endif %}\n{% endfor %}"
var_press_event_ch2: "{% for item in device_entities(var_remote) %}\n {% set m
= item | regex_findall(find='^event\\\\..+_6$') %}\n {% if m | length > 0 %}{{
m[0] }}{% endif %}\n{% endfor %}"
var_press_event_ch3: "{% for item in device_entities(var_remote) %}\n {% set m
= item | regex_findall(find='^event\\\\..+_9$') %}\n {% if m | length > 0 %}{{
m[0] }}{% endif %}\n{% endfor %}"
var_scroll_right_sensor_ch1: "{% for item in device_entities(var_remote) %}\n {%
set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_1$')
%}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}"
var_scroll_left_sensor_ch1: "{% for item in device_entities(var_remote) %}\n {%
set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_2$')
%}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}"
var_scroll_right_sensor_ch2: "{% for item in device_entities(var_remote) %}\n {%
set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_4$')
%}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}"
var_scroll_left_sensor_ch2: "{% for item in device_entities(var_remote) %}\n {%
set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_5$')
%}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}"
var_scroll_right_sensor_ch3: "{% for item in device_entities(var_remote) %}\n {%
set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_7$')
%}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}"
var_scroll_left_sensor_ch3: "{% for item in device_entities(var_remote) %}\n {%
set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_8$')
%}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}"
@@ -0,0 +1,624 @@
blueprint:
name: IKEA BILRESA - Light Controller (v1.3)
description: 'Light control for IKEA BILRESA (Matter). Supports dynamic scrolling,
scene cycling, and multi-click actions for up to three channels.
'
domain: automation
input:
remote_device:
name: Remote Control
description: The IKEA BILRESA scroll wheel (Matter) used as the trigger.
selector:
device:
filter:
- manufacturer: IKEA of Sweden
model: BILRESA scroll wheel
multiple: false
header_1:
name: Channel 1
default: ''
selector:
constant:
value: ''
group_scroll_1:
name: Scroll Wheel Settings
icon: mdi:rotate-right
collapsed: true
input:
input_light_1:
name: Light / Light Group
description: Select the light or group to be controlled.
default: {}
selector:
target:
entity:
- domain:
- light
input_mode_1:
name: Scroll Function
description: Define which value is changed when turning the wheel.
default: brightness
selector:
select:
mode: dropdown
options:
- label: Brightness
value: brightness
- label: Color
value: hs_color
- label: Color temperature
value: temp_kelvin
custom_value: false
multiple: false
sort: false
input_sens_1:
name: Multiplier
description: Define how much the value should change with each scroll step.
default: 5
selector:
number:
min: 1.0
max: 50.0
unit_of_measurement: x
mode: slider
step: 1.0
input_transition_1:
name: Transition duration
description: Define how long the transition between changes should take.
default: 200
selector:
number:
min: 0.0
max: 5000.0
unit_of_measurement: ms
mode: slider
step: 1.0
group_single_click_1:
name: Single Click
icon: mdi:gesture-tap
collapsed: true
input:
input_click_mode_1:
name: Click Mode
description: Choose between a single action or cycling through scenes.
default: action
selector:
select:
mode: dropdown
options:
- label: Perform single action
value: action
- label: Scene cycler
value: scene_cycle
custom_value: false
multiple: false
sort: false
input_scenes_1:
name: Scene Selection
description: Select the scenes to cycle through sequentially.
default: []
selector:
entity:
domain:
- scene
multiple: true
reorder: false
input_click_1:
name: Action on Click
description: Action to perform when in 'Single Action' mode.
default: []
selector:
action: {}
group_multi_click_1:
name: Additional Button Functions
icon: mdi:plus-circle-outline
collapsed: true
input:
input_double_1:
name: Double Click
description: Action to perform on double press.
default: []
selector:
action: {}
input_triple_1:
name: Triple Click
description: Action to perform on triple press.
default: []
selector:
action: {}
input_long_1:
name: Hold
description: Action to perform when holding the button.
default: []
selector:
action: {}
input_release_1:
name: Release
description: Action to perform when releasing the button after a long press.
default: []
selector:
action: {}
header_2:
name: Channel 2
default: ''
selector:
constant:
value: ''
group_scroll_2:
name: Scroll Wheel Settings
icon: mdi:rotate-right
collapsed: true
input:
input_light_2:
name: Light / Light Group
description: Select the light or group to be controlled.
default: {}
selector:
target:
entity:
- domain:
- light
input_mode_2:
name: Scroll Function
description: Define which value is changed when turning the wheel.
default: brightness
selector:
select:
mode: dropdown
options:
- label: Brightness
value: brightness
- label: Color
value: hs_color
- label: Color temperature
value: temp_kelvin
custom_value: false
multiple: false
sort: false
input_sens_2:
name: Multiplier
description: Define how much the value should change with each scroll step.
default: 5
selector:
number:
min: 1.0
max: 50.0
unit_of_measurement: x
mode: slider
step: 1.0
input_transition_2:
name: Transition duration
description: Define how long the transition between changes should take.
default: 200
selector:
number:
min: 0.0
max: 5000.0
unit_of_measurement: ms
mode: slider
step: 1.0
group_single_click_2:
name: Single Click
icon: mdi:gesture-tap
collapsed: true
input:
input_click_mode_2:
name: Click Mode
description: Choose between a single action or cycling through scenes.
default: action
selector:
select:
mode: dropdown
options:
- label: Perform single action
value: action
- label: Scene cycler
value: scene_cycle
custom_value: false
multiple: false
sort: false
input_scenes_2:
name: Scene Selection
description: Select the scenes to cycle through sequentially.
default: []
selector:
entity:
domain:
- scene
multiple: true
reorder: false
input_click_2:
name: Action on Click
description: Action to perform when in 'Single Action' mode.
default: []
selector:
action: {}
group_multi_click_2:
name: Additional Button Functions
icon: mdi:plus-circle-outline
collapsed: true
input:
input_double_2:
name: Double Click
description: Action to perform on double press.
default: []
selector:
action: {}
input_triple_2:
name: Triple Click
description: Action to perform on triple press.
default: []
selector:
action: {}
input_long_2:
name: Hold
description: Action to perform when holding the button.
default: []
selector:
action: {}
input_release_2:
name: Release
description: Action to perform when releasing the button after a long press.
default: []
selector:
action: {}
header_3:
name: Channel 3
default: ''
selector:
constant:
value: ''
group_scroll_3:
name: Scroll Wheel Settings
icon: mdi:rotate-right
collapsed: true
input:
input_light_3:
name: Light / Light Group
description: Select the light or group to be controlled.
default: {}
selector:
target:
entity:
- domain:
- light
input_mode_3:
name: Scroll Function
description: Define which value is changed when turning the wheel.
default: brightness
selector:
select:
mode: dropdown
options:
- label: Brightness
value: brightness
- label: Color
value: hs_color
- label: Color temperature
value: temp_kelvin
custom_value: false
multiple: false
sort: false
input_sens_3:
name: Multiplier
description: Define how much the value should change with each scroll step.
default: 5
selector:
number:
min: 1.0
max: 50.0
unit_of_measurement: x
mode: slider
step: 1.0
input_transition_3:
name: Transition duration
description: Define how long the transition between changes should take.
default: 200
selector:
number:
min: 0.0
max: 5000.0
unit_of_measurement: ms
mode: slider
step: 1.0
group_single_click_3:
name: Single Click
icon: mdi:gesture-tap
collapsed: true
input:
input_click_mode_3:
name: Click Mode
description: Choose between a single action or cycling through scenes.
default: action
selector:
select:
mode: dropdown
options:
- label: Perform single action
value: action
- label: Scene cycler
value: scene_cycle
custom_value: false
multiple: false
sort: false
input_scenes_3:
name: Scene Selection
description: Select the scenes to cycle through sequentially.
default: []
selector:
entity:
domain:
- scene
multiple: true
reorder: false
input_click_3:
name: Action on Click
description: Action to perform when in 'Single Action' mode.
default: []
selector:
action: {}
group_multi_click_3:
name: Additional Button Functions
icon: mdi:plus-circle-outline
collapsed: true
input:
input_double_3:
name: Double Click
description: Action to perform on double press.
default: []
selector:
action: {}
input_triple_3:
name: Triple Click
description: Action to perform on triple press.
default: []
selector:
action: {}
input_long_3:
name: Hold
description: Action to perform when holding the button.
default: []
selector:
action: {}
input_release_3:
name: Release
description: Action to perform when releasing the button after a long press.
default: []
selector:
action: {}
source_url: https://gist.github.com/SvKRO/52f246362a19f0297fd18969b383181b
mode: single
max_exceeded: silent
trigger_variables:
var_remote: !input remote_device
var_events: '{{ device_entities(var_remote) | select(''match'', ''event\.'') | sort
| list }}
'
temp_kanal1_rechts: '{{ var_events[0] if var_events | length > 0 else ''none'' }}'
temp_kanal1_links: '{{ var_events[1] if var_events | length > 1 else ''none'' }}'
temp_kanal1_klick: '{{ var_events[2] if var_events | length > 2 else ''none'' }}'
temp_kanal2_rechts: '{{ var_events[3] if var_events | length > 3 else ''none'' }}'
temp_kanal2_links: '{{ var_events[4] if var_events | length > 4 else ''none'' }}'
temp_kanal2_klick: '{{ var_events[5] if var_events | length > 5 else ''none'' }}'
temp_kanal3_rechts: '{{ var_events[6] if var_events | length > 6 else ''none'' }}'
temp_kanal3_links: '{{ var_events[7] if var_events | length > 7 else ''none'' }}'
temp_kanal3_klick: '{{ var_events[8] if var_events | length > 8 else ''none'' }}'
trigger:
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ temp_kanal1_links }}'
id: trigger_kanal1_links
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ temp_kanal1_rechts }}'
id: trigger_kanal1_rechts
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ temp_kanal1_klick }}'
id: trigger_kanal1_klick
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ temp_kanal2_links }}'
id: trigger_kanal2_links
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ temp_kanal2_rechts }}'
id: trigger_kanal2_rechts
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ temp_kanal2_klick }}'
id: trigger_kanal2_klick
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ temp_kanal3_links }}'
id: trigger_kanal3_links
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ temp_kanal3_rechts }}'
id: trigger_kanal3_rechts
- trigger: event
event_type: state_changed
event_data:
entity_id: '{{ temp_kanal3_klick }}'
id: trigger_kanal3_klick
action:
- condition: template
value_template: "{% if trigger.event.data.new_state is none %}\n false\n{% else
%}\n {% set old_attr = trigger.event.data.old_state.attributes if trigger.event.data.old_state
is not none else {} %}\n {% set new_attr = trigger.event.data.new_state.attributes
%}\n {% set old_ts = old_attr.get('last_reported', 'none') %}\n {% set new_ts
= new_attr.get('last_reported', 'new') %}\n {{ old_ts != new_ts }}\n{% endif
%}\n"
- variables:
m_1: !input input_mode_1
m_2: !input input_mode_2
m_3: !input input_mode_3
s_1: !input input_sens_1
s_2: !input input_sens_2
s_3: !input input_sens_3
l_1: !input input_light_1
l_2: !input input_light_2
l_3: !input input_light_3
tr_1: !input input_transition_1
tr_2: !input input_transition_2
tr_3: !input input_transition_3
action_kanal_id: '{{ trigger.id.split(''_'')[1] }}'
event_attr: '{{ trigger.event.data.new_state.attributes | to_json | from_json
}}'
ticks: '{{ event_attr.get(''totalNumberOfPressesCounted'') | default(1) | int
}}'
action_event_type: '{{ event_attr.get(''event_type'') | default(''none'') }}'
curr_mode: '{% if action_kanal_id == ''kanal1'' %}{{ m_1 }}{% elif action_kanal_id
== ''kanal2'' %}{{ m_2 }}{% else %}{{ m_3 }}{% endif %}'
base_sens: '{% if action_kanal_id == ''kanal1'' %}{{ s_1 }}{% elif action_kanal_id
== ''kanal2'' %}{{ s_2 }}{% else %}{{ s_3 }}{% endif %}'
step_val: '{% set val = base_sens * ticks %}{{ val if trigger.id.endswith(''_rechts'')
else val * -1 }}'
transition_ms: '{% if action_kanal_id == ''kanal1'' %}{{ tr_1 }}{% elif action_kanal_id
== ''kanal2'' %}{{ tr_2 }}{% else %}{{ tr_3 }}{% endif %}'
transition_s: '{{ transition_ms / 1000 }}'
curr_target_obj: '{% if action_kanal_id == ''kanal1'' %}{{ l_1 }} {% elif action_kanal_id
== ''kanal2'' %}{{ l_2 }} {% else %}{{ l_3 }}{% endif %}
'
curr_target_list: "{% set t = curr_target_obj %} {% set ns = namespace(entities=[])
%}\n{% set e_ids = t.entity_id if t.entity_id is defined else [] %} {% set ns.entities
= [e_ids] if e_ids is string else e_ids %}\n{% set d_ids = t.device_id if t.device_id
is defined else [] %} {% set d_ids = [d_ids] if d_ids is string else d_ids %}
{% for d in d_ids %}\n {% set ns.entities = ns.entities + (device_entities(d)
| select('match', 'light\\.') | list) %}\n{% endfor %}\n{% set a_ids = t.area_id
if t.area_id is defined else [] %} {% set a_ids = [a_ids] if a_ids is string
else a_ids %} {% for a in a_ids %}\n {% set ns.entities = ns.entities + (area_entities(a)
| select('match', 'light\\.') | list) %}\n{% endfor %}\n{% set lb_ids = t.label_id
if t.label_id is defined else [] %} {% set lb_ids = [lb_ids] if lb_ids is string
else lb_ids %} {% for l in lb_ids %}\n {% set ns.entities = ns.entities + (label_entities(l)
| select('match', 'light\\.') | list) %}\n{% endfor %}\n{{ ns.entities | unique
| reject('none') | list }}\n"
curr_target: '{{ curr_target_list | first | default(''none'') }}'
- choose:
- conditions: '{{ trigger.id.endswith(''_rechts'') or trigger.id.endswith(''_links'')
}}'
sequence:
- choose:
- conditions: '{{ curr_mode == ''brightness'' }}'
sequence:
- variables:
target_value: '{% set current = state_attr(curr_target, ''brightness'')
| int(128) %} {{ [([current + (step_val | int * 2.55 | int), 0] | max),
255] | min }}
'
- service: light.turn_on
target:
entity_id: '{{ curr_target_list }}'
data:
transition: '{{ transition_s }}'
brightness: '{{ target_value }}'
- conditions: '{{ curr_mode == ''temp_kelvin'' }}'
sequence:
- variables:
target_value: '{% set curr = state_attr(curr_target, ''color_temp_kelvin'')
| int(3000) %} {% set min_v = state_attr(curr_target, ''min_color_temp_kelvin'')
| int(2000) %} {% set max_v = state_attr(curr_target, ''max_color_temp_kelvin'')
| int(6535) %} {{ [([curr + (step_val * 10) | int, min_v] | max), max_v]
| min }}
'
- service: light.turn_on
target:
entity_id: '{{ curr_target_list }}'
data:
transition: '{{ transition_s }}'
color_temp_kelvin: '{{ target_value }}'
- conditions: '{{ curr_mode == ''hs_color'' }}'
sequence:
- variables:
target_value: '{% set hs = state_attr(curr_target, ''hs_color'') | default([0,
100], true) %} {{ [((hs[0] | float(0) + step_val) % 360) | round(2),
hs[1] | float(100)] }}
'
- service: light.turn_on
target:
entity_id: '{{ curr_target_list }}'
data:
transition: '{{ transition_s }}'
hs_color: '{{ target_value }}'
- conditions: '{{ trigger.id.endswith(''_klick'') }}'
sequence:
- variables:
cm_1: !input input_click_mode_1
cm_2: !input input_click_mode_2
cm_3: !input input_click_mode_3
cs_1: !input input_scenes_1
cs_2: !input input_scenes_2
cs_3: !input input_scenes_3
c_mode: '{% if action_kanal_id == ''kanal1'' %}{{ cm_1 }}{% elif action_kanal_id
== ''kanal2'' %}{{ cm_2 }}{% else %}{{ cm_3 }}{% endif %}'
c_scenes: '{% if action_kanal_id == ''kanal1'' %}{{ cs_1 }}{% elif action_kanal_id
== ''kanal2'' %}{{ cs_2 }}{% else %}{{ cs_3 }}{% endif %}'
- choose:
- conditions: '{{ action_event_type == ''multi_press_1'' }}'
sequence:
- choose:
- conditions: '{{ c_mode == ''scene_cycle'' and c_scenes | count > 0 }}'
sequence:
- service: scene.turn_on
target:
entity_id: '{% set count = c_scenes | count %}{% set idx = (as_timestamp(now())
| int // 2) % count %}{{ c_scenes[idx] }}'
- conditions: '{{ c_mode == ''action'' }}'
sequence:
- choose:
- conditions: '{{ action_kanal_id == ''kanal1'' }}'
sequence: !input input_click_1
- conditions: '{{ action_kanal_id == ''kanal2'' }}'
sequence: !input input_click_2
- conditions: '{{ action_kanal_id == ''kanal3'' }}'
sequence: !input input_click_3
- conditions: '{{ action_event_type == ''multi_press_2'' }}'
sequence:
- choose:
- conditions: '{{ action_kanal_id == ''kanal1'' }}'
sequence: !input input_double_1
- conditions: '{{ action_kanal_id == ''kanal2'' }}'
sequence: !input input_double_2
- conditions: '{{ action_kanal_id == ''kanal3'' }}'
sequence: !input input_double_3
- conditions: '{{ action_event_type == ''multi_press_3'' }}'
sequence:
- choose:
- conditions: '{{ action_kanal_id == ''kanal1'' }}'
sequence: !input input_triple_1
- conditions: '{{ action_kanal_id == ''kanal2'' }}'
sequence: !input input_triple_2
- conditions: '{{ action_kanal_id == ''kanal3'' }}'
sequence: !input input_triple_3
- conditions: '{{ action_event_type == ''long_press'' }}'
sequence:
- choose:
- conditions: '{{ action_kanal_id == ''kanal1'' }}'
sequence: !input input_long_1
- conditions: '{{ action_kanal_id == ''kanal2'' }}'
sequence: !input input_long_2
- conditions: '{{ action_kanal_id == ''kanal3'' }}'
sequence: !input input_long_3
- conditions: '{{ action_event_type == ''long_release'' }}'
sequence:
- choose:
- conditions: '{{ action_kanal_id == ''kanal1'' }}'
sequence: !input input_release_1
- conditions: '{{ action_kanal_id == ''kanal2'' }}'
sequence: !input input_release_2
- conditions: '{{ action_kanal_id == ''kanal3'' }}'
sequence: !input input_release_3
@@ -0,0 +1,171 @@
blueprint:
name: IKEA BILRESA E2489 Dual Button (Matter)
author: censay
description: 'Full-featured automation for the IKEA BILRESA E2489 Matter dual-button
remote. Supports single press, double press, and long press (on press and on release)
for both buttons. Uses event entities exposed by Home Assistant for Matter devices.
Single-press actions are prioritized for reliability.
'
domain: automation
source_url: https://raw.githubusercontent.com/censay/haos-blueprints/refs/heads/master/ikea-bilresa-e2489-matter-smart-button/ikea-bilresa-e2489-matter-smart-button.yaml
homeassistant:
min_version: 2025.12.1
input:
target_device:
name: BILRESA Button Device
description: 'Select the IKEA BILRESA E2489 matter device.
'
selector:
device:
filter:
- manufacturer: IKEA of Sweden
multiple: false
button1_event:
name: Button 1 Event Entity
description: 'Select the event entity for Button 1 (for example: event.bilresa_dual_button_button_1_2).
'
selector:
entity:
domain:
- event
multiple: false
reorder: false
button2_event:
name: Button 2 Event Entity
description: 'Select the event entity for Button 2 (for example: event.bilresa_dual_button_button_2_2).
'
selector:
entity:
domain:
- event
multiple: false
reorder: false
button1_single:
name: Button 1 Single Press
description: Action for Button 1 single press (multi_press_1).
default: []
selector:
action: {}
button1_double:
name: Button 1 Double Press
description: Action for Button 1 double press (multi_press_2).
default: []
selector:
action: {}
button1_long_press:
name: Button 1 Long Press (on press)
description: Action for Button 1 long press start (long_press).
default: []
selector:
action: {}
button1_long:
name: Button 1 Long Press (on release)
description: Action for Button 1 long press completion (long_release).
default: []
selector:
action: {}
button2_single:
name: Button 2 Single Press
description: Action for Button 2 single press (multi_press_1).
default: []
selector:
action: {}
button2_double:
name: Button 2 Double Press
description: Action for Button 2 double press (multi_press_2).
default: []
selector:
action: {}
button2_long_press:
name: Button 2 Long Press (on press)
description: Action for Button 2 long press start (long_press).
default: []
selector:
action: {}
button2_long:
name: Button 2 Long Press (on release)
description: Action for Button 2 long press completion (long_release).
default: []
selector:
action: {}
variables:
button1_entity: !input button1_event
button2_entity: !input button2_event
trigger:
- platform: state
id: button1
entity_id: !input button1_event
not_from: unavailable
not_to: unavailable
- platform: state
id: button2
entity_id: !input button2_event
not_from: unavailable
not_to: unavailable
condition: []
action:
- variables:
press_type: '{{ trigger.to_state.attributes.event_type }}'
trigger_id: '{{ trigger.id }}'
- choose:
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button1'' and press_type == ''multi_press_1''
}}
'
sequence: !input button1_single
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button1'' and press_type == ''multi_press_2''
}}
'
sequence: !input button1_double
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button1'' and press_type == ''long_press''
}}
'
sequence: !input button1_long_press
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button1'' and press_type == ''long_release''
}}
'
sequence: !input button1_long
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button2'' and press_type == ''multi_press_1''
}}
'
sequence: !input button2_single
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button2'' and press_type == ''multi_press_2''
}}
'
sequence: !input button2_double
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button2'' and press_type == ''long_press''
}}
'
sequence: !input button2_long_press
- conditions:
- condition: template
value_template: '{{ trigger_id == ''button2'' and press_type == ''long_release''
}}
'
sequence: !input button2_long
mode: restart
@@ -0,0 +1,99 @@
blueprint:
name: Zigbee2MQTT - IKEA Bilresa Dual Button
description: Control actions using an IKEA Bilresa remote via Zigbee2MQTT based on MQTT topic payloads.
domain: automation
input:
remote_name:
name: Zigbee2MQTT Friendly Name
description: The friendly name of the Bilresa remote in Zigbee2MQTT (used for the MQTT topic).
selector:
text: {}
on_click:
name: Top Button - Single Click
default: []
selector:
action: {}
on_double_click:
name: Top Button - Double Click
default: []
selector:
action: {}
on_hold:
name: Top Button - Long Press (Hold)
default: []
selector:
action: {}
off_click:
name: Bottom Button - Single Click
default: []
selector:
action: {}
off_double_click:
name: Bottom Button - Double Click
default: []
selector:
action: {}
off_hold:
name: Bottom Button - Long Press (Hold)
default: []
selector:
action: {}
stop_hold:
name: Button Released (Stop)
description: Action to run when a long press is released.
default: []
selector:
action: {}
trigger:
- platform: mqtt
topic: "{{ 'zigbee2mqtt/' ~ remote_id if '/' not in remote_id else remote_id }}"
# We dynamically evaluate the topic in the trigger instead of matching in conditions
# to avoid waking up the automation for unrelated MQTT traffic.
variables:
remote_id: !input remote_name
expected_topic: "{{ remote_id if '/' in remote_id else 'zigbee2mqtt/' ~ remote_id }}"
# Fallback trigger if the dynamic template topic has constraints in older versions
# Using wildcard MQTT trigger and filtering by topic in conditions for maximum compatibility:
trigger:
- platform: mqtt
topic: "zigbee2mqtt/#"
condition:
- condition: template
value_template: >-
{{ trigger.topic == expected_topic and trigger.payload_json.action is defined }}
action:
- choose:
- conditions:
- condition: template
value_template: "{{ trigger.payload_json.action == 'on' }}"
sequence: !input on_click
- conditions:
- condition: template
value_template: "{{ trigger.payload_json.action == 'on_double' }}"
sequence: !input on_double_click
- conditions:
- condition: template
value_template: "{{ trigger.payload_json.action == 'brightness_move_up' }}"
sequence: !input on_hold
- conditions:
- condition: template
value_template: "{{ trigger.payload_json.action == 'off' }}"
sequence: !input off_click
- conditions:
- condition: template
value_template: "{{ trigger.payload_json.action == 'off_double' }}"
sequence: !input off_double_click
- conditions:
- condition: template
value_template: "{{ trigger.payload_json.action == 'brightness_move_down' }}"
sequence: !input off_hold
- conditions:
- condition: template
value_template: "{{ trigger.payload_json.action == 'brightness_stop' }}"
sequence: !input stop_hold
mode: restart
@@ -0,0 +1,67 @@
blueprint:
name: IKEA Bilresa - High-Performance Wheel Control
description: Optimized control for the IKEA Bilresa wheel focusing on speed and
MG24/Ember stability.
domain: automation
input:
controller_name:
name: Remote MQTT Name
description: The name of the device in Zigbee2MQTT (e.g., Bilresa_Wheel)
default: Bilresa Wheel
action_on:
name: 'On'
description: Action to perform when the top button is pressed.
default: []
selector:
action: {}
action_off:
name: 'Off'
description: Action to perform when the bottom button is pressed.
default: []
selector:
action: {}
action_right:
name: Turn Right
description: 'Action for clockwise rotation. Recommended: light.turn_on with
brightness_step_pct: 10 and transition: 0.1'
default: []
selector:
action: {}
action_left:
name: Turn Left
description: 'Action for counter-clockwise rotation. Recommended: light.turn_on
with brightness_step_pct: -10 and transition: 0.1'
default: []
selector:
action: {}
source_url: https://gist.githubusercontent.com/patt110/4eda20da78422b8989b3b6686264e2ac/raw/a80245848aea86d29668015fc014b13d032569f0/Bilresa_Scrollwheel_Z2M_en.yaml
mode: restart
trigger:
- platform: mqtt
topic: zigbee2mqtt/{{ (controller_name if controller_name else 'Bilresa Wheel')
}}
action:
- variables:
payload: '{{ trigger.payload_json }}'
action_val: '{{ payload.action if ''action'' in payload else '''' }}'
- choose:
- conditions: '{{ action_val == ''on'' }}'
sequence: !input action_on
- conditions: '{{ action_val == ''off'' }}'
sequence: !input action_off
- conditions: '{{ action_val == ''brightness_move_to_level'' }}'
sequence:
- variables:
is_max: '{{ payload.action_brightness_delta is none and payload.action_level
is none }}'
is_min: '{{ payload.action_brightness_delta == 0 and payload.action_level
== 1 }}'
delta: '{{ payload.action_brightness_delta | int(0) if payload.action_brightness_delta
is not none else 0 }}'
- choose:
- conditions: '{{ delta > 0 or is_max }}'
sequence: !input action_right
- conditions: '{{ delta < 0 or is_min }}'
sequence: !input action_left
- delay:
milliseconds: 30
@@ -0,0 +1,908 @@
blueprint:
name: Ikea_bilresa_scroll_wheel
description: 'Blueprint for Ikea Bilresa Matter (button and scroll wheel for 3 different
channels)
Version: 2026-04-06
Info: https://community.home-assistant.io/t/ikea-bilresa-scroll-wheel-blueprint-matter/965365
'
domain: automation
input:
remote:
name: Remote
description: Ikea Bilresa scroll wheel remote control
selector:
device:
filter:
- manufacturer: IKEA of Sweden
model: BILRESA scroll wheel
integration: matter
multiple: false
channel1_section:
name: Channel 1
collapsed: true
input:
click_action_ch1:
name: 'Channel 1: <click> action'
default: []
selector:
action: {}
double_click_action_ch1:
name: 'Channel 1: <double-click> action'
default: []
selector:
action: {}
triple_click_action_ch1:
name: 'Channel 1: <triple-click> action'
default: []
selector:
action: {}
long_click_action_ch1:
name: 'Channel 1: <long-click> action'
description: ''
default: []
selector:
action: {}
on_hold_action_ch1:
name: 'Channel 1: <on-hold> action'
description: repeat action until button is released, see also on_hold_delay
default: []
selector:
action: {}
scroll_wheel_target_ch1:
name: 'Channel 1: Scroll wheel target entity'
description: 'Light or media player to be controlled by scroll wheel for
channel 1
- empty for <user defined> scroll wheel mode
'
selector:
entity:
multiple: true
filter:
- domain:
- light
- media_player
reorder: false
default: []
scroll_wheel_mode_ch1:
selector:
select:
options:
- 'lights: dim'
- 'lights: color temp and color hue'
- 'lights: color temp only'
- 'lights: color hue only'
- 'media: volume control'
- 'dynamic: choose from input_select'
- user defined
custom_value: false
multiple: false
sort: false
default: 'lights: dim'
name: 'Channel 1: Scroll wheel mode'
description: Select attribute of target entity controlled by scroll wheel
scroll_wheel_mode_dynamic_ch1:
selector:
entity:
filter:
- domain:
- input_select
multiple: false
reorder: false
default:
name: 'Channel 1: Scroll wheel mode input_select'
description: 'For scroll wheel mode <dynamic> only, leave empty otherwise.
Create and choose an ''input_select'' containing the scroll wheel modes
you want to choose from dynamically, e.g.
- "lights: dim"
- "lights: color temp only"'
scroll_wheel_mode_ext_ch1:
selector:
select:
options:
- relaxed
- instant
custom_value: false
multiple: false
sort: false
default: relaxed
name: 'Channel 1: Scroll wheel evaluation mode'
description: '<relaxed>: one action call after scrolling
<instant>: many action calls while scrolling
Beware:
for <instant> mode the 9 hidden sensor entities of Bilresa device have
to be enabled
'
scroll_wheel_user_action_ch1:
name: 'Channel 1: User defined scroll wheel action'
description: 'For scroll wheel mode <user defined> only
Exposed variables:
- <scroll_clicks>: for <instant> mode always 1, for <relaxed> mode number
between 1 to 8
- <scroll_direction>: ''left'' or ''right''
'
default: []
selector:
action: {}
channel2_section:
name: Channel 2
collapsed: true
input:
click_action_ch2:
name: 'Channel 2: <click> action'
default: []
selector:
action: {}
double_click_action_ch2:
name: 'Channel 2: <double-click> action'
default: []
selector:
action: {}
triple_click_action_ch2:
name: 'Channel 2: <triple-click> action'
default: []
selector:
action: {}
long_click_action_ch2:
name: 'Channel 2: <long-click> action'
description: ''
default: []
selector:
action: {}
on_hold_action_ch2:
name: 'Channel 2: <on-hold> action'
description: repeat action until button is released, see also on_hold_delay
default: []
selector:
action: {}
scroll_wheel_target_ch2:
name: 'Channel 2: Scroll wheel target entity'
description: 'Light or media player to be controlled by scroll wheel for
channel 2
- empty for <user defined> scroll wheel mode
'
selector:
entity:
multiple: true
filter:
- domain:
- light
- media_player
reorder: false
default: []
scroll_wheel_mode_ch2:
selector:
select:
options:
- 'lights: dim'
- 'lights: color temp and color hue'
- 'lights: color temp only'
- 'lights: color hue only'
- 'media: volume control'
- 'dynamic: choose from input_select'
- user defined
custom_value: false
multiple: false
sort: false
default: 'lights: dim'
name: 'Channel 2: Scroll wheel mode'
description: Select attribute of target entity controlled by scroll wheel
scroll_wheel_mode_dynamic_ch2:
selector:
entity:
filter:
- domain:
- input_select
multiple: false
reorder: false
default:
name: 'Channel 2: Scroll wheel mode input_select'
description: 'For scroll wheel mode <dynamic> only, leave empty otherwise.
Create and choose an ''input_select'' containing the scroll wheel modes
you want to choose from dynamically, e.g.
- "lights: dim"
- "lights: color temp only"'
scroll_wheel_mode_ext_ch2:
selector:
select:
options:
- relaxed
- instant
custom_value: false
multiple: false
sort: false
default: relaxed
name: 'Channel 2: Scroll wheel evaluation mode'
description: '- <relaxed>: one action call after scrolling
- <instant>: many action calls while scrolling
Beware:
- for <instant> mode the 9 hidden sensor entities of Bilresa device have
to be enabled
'
scroll_wheel_user_action_ch2:
name: 'Channel 2: User defined scroll wheel action'
description: 'For scroll wheel mode <user defined> only
Exposed variables:
- <scroll_clicks>: for <instant> mode always 1, for <relaxed> mode number
between 1 to 8
- <scroll_direction>: ''left'' or ''right''
'
default: []
selector:
action: {}
channel3_section:
name: Channel 3
collapsed: true
input:
click_action_ch3:
name: 'Channel 3: <click> action'
default: []
selector:
action: {}
double_click_action_ch3:
name: 'Channel 3: <double-click> action'
default: []
selector:
action: {}
triple_click_action_ch3:
name: 'Channel 3: <triple-click> action'
default: []
selector:
action: {}
long_click_action_ch3:
name: 'Channel 3: <long-click> action'
description: ''
default: []
selector:
action: {}
on_hold_action_ch3:
name: 'Channel 3: <on-hold> action'
description: repeat action until button is released, see also on_hold_delay
default: []
selector:
action: {}
scroll_wheel_target_ch3:
name: 'Channel 3: Scroll wheel target entity'
description: 'Light or media player to be controlled by scroll wheel for
channel 3
- empty for <user defined> scroll wheel mode
'
selector:
entity:
multiple: true
filter:
- domain:
- light
- media_player
reorder: false
default: []
scroll_wheel_mode_ch3:
selector:
select:
options:
- 'lights: dim'
- 'lights: color temp and color hue'
- 'lights: color temp only'
- 'lights: color hue only'
- 'media: volume control'
- 'dynamic: choose from input_select'
- user defined
custom_value: false
multiple: false
sort: false
default: 'lights: dim'
name: 'Channel 3: Scroll wheel mode'
description: Select attribute of target entity controlled by scroll wheel
scroll_wheel_mode_dynamic_ch3:
selector:
entity:
filter:
- domain:
- input_select
multiple: false
reorder: false
default:
name: 'Channel 3: Scroll wheel mode input_select'
description: 'For scroll wheel mode <dynamic> only, leave empty otherwise.
Create and choose an ''input_select'' containing the scroll wheel modes
you want to choose from dynamically, e.g.
- "lights: dim"
- "lights: color temp only"'
scroll_wheel_mode_ext_ch3:
selector:
select:
options:
- relaxed
- instant
custom_value: false
multiple: false
sort: false
default: relaxed
name: 'Channel 3: Scroll wheel evaluation mode'
description: '- <relaxed>: one action call after scrolling
- <instant>: many action calls while scrolling
Beware:
- for <instant> mode the 9 hidden sensor entities of Bilresa device have
to be enabled
'
scroll_wheel_user_action_ch3:
name: 'Channel 3: User defined scroll wheel action'
description: 'For scroll wheel mode <user defined> only
Exposed variables:
- <scroll_clicks>: for <instant> mode always 1, for <relaxed> mode number
between 1 to 8
- <scroll_direction>: ''left'' or ''right''
'
default: []
selector:
action: {}
global_light_setting:
name: Global light settings
collapsed: true
input:
dim_step_pct:
name: Dimmer step percentage
description: change in brightness in %
default: 10
selector:
number:
mode: box
step: 1.0
dim_min_pct:
name: Minimum brightness
description: minimum brightness in %
default: 5
selector:
number:
mode: box
step: 1.0
dim_turns_lights_on:
name: Dim turns lights on
description: 'allow scroll wheel (mode: dim) to turn lights on
'
default: false
selector:
boolean: {}
min_color_temp:
name: Minimum color temperature
description: minimum color temperature in K (for color_mode = color_temp)
default: 2200
selector:
number:
mode: box
step: 1.0
max_color_temp:
name: Maximum color temperature
description: maximum color temperature in K (for color_mode = color_temp)
default: 4000
selector:
number:
mode: box
step: 1.0
color_temp_step:
name: Color temperature step
description: change in color temperature in K (for color_mode = color_temp)
default: 100
selector:
number:
mode: box
step: 1.0
color_hue_step:
name: Color hue step
description: change in color hue in degree (0..360) (for color_mode = color_hue)
default: 6
selector:
number:
mode: box
step: 1.0
color_saturation:
name: Color saturation
description: color saturation in % (for color_mode = color_hue)
default: 100
selector:
number:
mode: box
step: 1.0
light_transition_duration:
name: Transition duration
description: Controls the duration of transitions
default: 0.5
selector:
number:
mode: box
step: 1.0
global_media_setting:
name: Global media settings
collapsed: true
input:
volume_step_pct:
name: Media player volume step percentage
description: change in volume in %
default: 3.5
selector:
number:
mode: box
step: 1.0
volume_use_up_down_for_instant:
name: 'Media player: use actions volume_up/volume_down for <instant> mode'
description: "<instant> mode uses standard actions volume_up/volume_down
instead of <volume step percentage> by default \n\nif this is to coarse
disable this option and adjust the <volume step percentage> value to the
lowest value which still changes the volume while scrolling in both directions\n"
default: true
selector:
boolean: {}
volume_max_pct:
name: Media player max volume percentage
description: max volume in %
default: 70
selector:
number:
mode: box
step: 1.0
global_misc_setting:
name: Miscellaneous settings
collapsed: true
input:
auto_reset_time_input_select_helpers:
name: Auto reset time for input_select helpers
description: 'auto reset time for input_select helpers if > 0
input_selects are reset to first selection
'
default: 0
selector:
number:
mode: box
step: 60.0
on_hold_delay:
name: On hold delay
description: delay between separate action calls while on hold button
default: 0.1
selector:
number:
mode: box
step: 0.1
max_queued_automation_calls:
name: Max queued automation calls
description: 'Increase if you want to queue more automation calls for special
cases
'
default: 10
selector:
number:
mode: box
step: 1.0
source_url: https://gist.github.com/jhol-byte/b2731a4d2476f530d76b9ff409f7f3a4
alias: Ikea_bilresa_scroll_wheel
trigger:
- event_type: state_changed
event_data:
entity_id: '{{ var_button_ch1 }}'
trigger: event
id: button_ch1
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_ch1 }}'
trigger: event
id: scroll_left_ch1
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_ch1 }}'
trigger: event
id: scroll_right_ch1
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_ext_ch1 }}'
trigger: event
id: scroll_left_ext_ch1
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_ext_ch1 }}'
trigger: event
id: scroll_right_ext_ch1
- event_type: state_changed
event_data:
entity_id: '{{ var_button_ch2 }}'
trigger: event
id: button_ch2
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_ch2 }}'
trigger: event
id: scroll_left_ch2
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_ch2 }}'
trigger: event
id: scroll_right_ch2
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_ext_ch2 }}'
trigger: event
id: scroll_left_ext_ch2
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_ext_ch2 }}'
trigger: event
id: scroll_right_ext_ch2
- event_type: state_changed
event_data:
entity_id: '{{ var_button_ch3 }}'
trigger: event
id: button_ch3
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_ch3 }}'
trigger: event
id: scroll_left_ch3
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_ch3 }}'
trigger: event
id: scroll_right_ch3
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_left_ext_ch3 }}'
trigger: event
id: scroll_left_ext_ch3
- event_type: state_changed
event_data:
entity_id: '{{ var_scroll_right_ext_ch3 }}'
trigger: event
id: scroll_right_ext_ch3
condition:
- condition: template
value_template: "{{ trigger.event.data.old_state.state != 'unavailable' \n and
trigger.event.data.new_state.state != 'unavailable'\n}}"
action:
- variables:
channel: "{% if trigger.id in ['button_ch1', 'scroll_left_ch1', 'scroll_right_ch1',
'scroll_left_ext_ch1', 'scroll_right_ext_ch1'] %}\n ch1\n{% elif trigger.id
in ['button_ch2', 'scroll_left_ch2', 'scroll_right_ch2', 'scroll_left_ext_ch2',
'scroll_right_ext_ch2'] %}\n ch2\n{% elif trigger.id in ['button_ch3', 'scroll_left_ch3',
'scroll_right_ch3', 'scroll_left_ext_ch3', 'scroll_right_ext_ch3'] %}\n ch3\n{%
endif %}"
- if: '{{ var_reset_time_input_select_helpers > 0 and last_triggered_seconds > var_reset_time_input_select_helpers
}}'
then:
repeat:
for_each:
- ch1
- ch2
- ch3
sequence:
- action: input_select.select_first
target:
entity_id: '{{ vars[''scroll_wheel_mode_dynamic_'' ~ repeat.item ] }}'
- choose:
- conditions:
- condition: template
value_template: '{{ trigger.id == (''button_'' ~ channel) and trigger.event.data.new_state.attributes.event_type
== ''multi_press_1'' }}
'
sequence:
- if: '{{ channel == ''ch1''}}'
then: !input click_action_ch1
- if: '{{ channel == ''ch2''}}'
then: !input click_action_ch2
- if: '{{ channel == ''ch3''}}'
then: !input click_action_ch3
- conditions:
- condition: template
value_template: '{{ trigger.id == (''button_'' ~ channel) and trigger.event.data.new_state.attributes.event_type
== ''multi_press_2'' }}
'
sequence:
- if: '{{ channel == ''ch1''}}'
then: !input double_click_action_ch1
- if: '{{ channel == ''ch2''}}'
then: !input double_click_action_ch2
- if: '{{ channel == ''ch3''}}'
then: !input double_click_action_ch3
- conditions:
- condition: template
value_template: '{{ trigger.id == (''button_'' ~ channel) and trigger.event.data.new_state.attributes.event_type
== ''multi_press_3'' }}
'
sequence:
- if: '{{ channel == ''ch1''}}'
then: !input triple_click_action_ch1
- if: '{{ channel == ''ch2''}}'
then: !input triple_click_action_ch2
- if: '{{ channel == ''ch3''}}'
then: !input triple_click_action_ch3
- conditions:
- condition: template
value_template: '{{ trigger.id == (''button_'' ~ channel) and trigger.event.data.new_state.attributes.event_type
== ''long_press'' }}
'
sequence:
- if: '{{ channel == ''ch1''}}'
then:
sequence:
- sequence: !input long_click_action_ch1
- repeat:
while: '{{ repeat.index < 100 and state_attr(var_button_ch1, ''event_type'')
== ''long_press'' }}'
sequence:
- sequence: !input on_hold_action_ch1
- delay: '{{ var_on_hold_delay }}'
- if: '{{ channel == ''ch2''}}'
then:
sequence:
- sequence: !input long_click_action_ch2
- repeat:
while: '{{ repeat.index < 100 and state_attr(var_button_ch2, ''event_type'')
== ''long_press'' }}'
sequence:
- sequence: !input on_hold_action_ch2
- delay: '{{ var_on_hold_delay }}'
- if: '{{ channel == ''ch3''}}'
then:
sequence:
- sequence: !input long_click_action_ch3
- repeat:
while: '{{ repeat.index < 100 and state_attr(var_button_ch3, ''event_type'')
== ''long_press'' }}'
sequence:
- sequence: !input on_hold_action_ch3
- delay: '{{ var_on_hold_delay }}'
- conditions:
- condition: template
value_template: "{{ ( (vars['scroll_wheel_mode_ext_' ~ channel] == 'relaxed')
and (trigger.id == ('scroll_left_' ~ channel) or trigger.id == ('scroll_right_'
~ channel))) or\n ( (vars['scroll_wheel_mode_ext_' ~ channel] == 'instant')
and (trigger.id == ('scroll_left_ext_' ~ channel) or trigger.id == ('scroll_right_ext_'
~ channel)) and trigger.event.data.new_state.state == '1' ) \n}}\n"
sequence:
- variables:
scroll_clicks: "{% if (vars['scroll_wheel_mode_ext_' ~ channel] == 'relaxed')
%}\n {{ trigger.event.data.new_state.attributes.totalNumberOfPressesCounted
}}\n{% else %}\n 1\n{% endif %}\n"
scroll_direction: "{% if (trigger.id == 'scroll_left_' ~ channel) or (trigger.id
== 'scroll_left_ext_' ~ channel)%}\n left\n{% else %}\n right\n{% endif
%}\n"
scroll_wheel_mode: '{{ vars[''scroll_wheel_mode_'' ~ channel] if vars[''scroll_wheel_mode_''
~ channel] != ''dynamic: choose from input_select'' else states(vars[''scroll_wheel_mode_dynamic_''
~ channel]) }}
'
- if:
- condition: template
value_template: '{{ scroll_wheel_mode == ''user defined'' }}'
then:
sequence:
- if: '{{ channel == ''ch1''}}'
then: !input scroll_wheel_user_action_ch1
- if: '{{ channel == ''ch2''}}'
then: !input scroll_wheel_user_action_ch2
- if: '{{ channel == ''ch3''}}'
then: !input scroll_wheel_user_action_ch3
else:
- repeat:
for_each: '{{ vars[''targets_entities_'' ~ channel] }}'
sequence:
- choose:
- conditions:
- condition: template
value_template: '{{ scroll_wheel_mode == ''lights: dim''}}'
sequence:
- if:
- condition: template
value_template: '{{ is_state(repeat.item, ''on'') or (var_dim_turns_lights_on
and scroll_direction == ''right'') }}'
then:
- if:
- condition: template
value_template: '{{ is_state(repeat.item, ''on'') }}'
then:
- action: light.turn_on
target:
entity_id: '{{ repeat.item }}'
data:
brightness_step_pct: "{{ iif(scroll_direction == 'right', scroll_clicks
* var_dim_step_pct, -1*([scroll_clicks * var_dim_step_pct,
state_attr(repeat.item, 'brightness')*100/254\n - var_min_pct]|min))
\n}}"
transition: '{{ var_light_transition_duration }}'
else:
- action: light.turn_on
target:
entity_id: '{{ repeat.item }}'
data:
transition: '{{ var_light_transition_duration }}'
- conditions:
- condition: template
value_template: '{{ scroll_wheel_mode == ''lights: color temp only''
or (scroll_wheel_mode == ''lights: color temp and color hue'' and
state_attr(repeat.item, ''color_temp_kelvin'') is not none ) }}'
sequence:
- action: script.light_color_temp_helper
data:
mode: '{{ iif(scroll_direction == ''left'', ''down'', ''up'') }}'
min_color_temp: '{{ var_min_color_temp }}'
max_color_temp: '{{ var_max_color_temp }}'
color_temp_step: '{{ var_color_temp_step * scroll_clicks }}'
target_light: '{{ repeat.item }}'
light_transition_duration: '{{ var_light_transition_duration }}'
- conditions:
- condition: template
value_template: '{{ scroll_wheel_mode == ''lights: color hue only''
or (scroll_wheel_mode == ''lights: color temp and color hue'' and
state_attr(repeat.item, ''color_temp_kelvin'') is none ) }}'
sequence:
- action: script.light_color_hs_helper
data:
target_light: '{{ repeat.item }}'
light_transition_duration: '{{ var_light_transition_duration }}'
color_hue_step: '{{ var_color_hue_step * scroll_clicks }}'
color_saturation: '{{ var_color_saturation }}'
mode: '{{ iif(scroll_direction == ''left'', ''down'', ''up'') }}'
- conditions:
- condition: template
value_template: '{{ scroll_wheel_mode == ''media: volume control''
and (is_state(repeat.item, ''playing'') or is_state(repeat.item,
''on'') )}}'
sequence:
- if:
- condition: template
value_template: '{{ vars[''scroll_wheel_mode_ext_'' ~ channel] ==
''relaxed'' or not var_volume_use_up_down_for_instant}}'
then:
- action: media_player.volume_set
target:
entity_id: '{{ repeat.item }}'
data:
volume_level: "{{ [[state_attr(repeat.item, 'volume_level')*100
+ \n iif(scroll_direction == 'right', 1, -1)* scroll_clicks
* var_volume_step_pct, var_volume_max_pct] | min, 0] | max /
100\n}}"
else:
- if:
- condition: template
value_template: '{{ scroll_direction == ''left'' }}'
then:
- action: media_player.volume_down
target:
entity_id: '{{ repeat.item }}'
else:
- if:
- condition: template
value_template: '{{ state_attr(repeat.item, ''volume_level'')*100
< var_volume_max_pct }}'
then:
- action: media_player.volume_up
target:
entity_id: '{{ repeat.item }}'
mode: queued
max: !input max_queued_automation_calls
max_exceeded: silent
variables:
vars:
targets_entities_ch1: !input scroll_wheel_target_ch1
targets_entities_ch2: !input scroll_wheel_target_ch2
targets_entities_ch3: !input scroll_wheel_target_ch3
scroll_wheel_mode_ch1: !input scroll_wheel_mode_ch1
scroll_wheel_mode_ch2: !input scroll_wheel_mode_ch2
scroll_wheel_mode_ch3: !input scroll_wheel_mode_ch3
scroll_wheel_mode_ext_ch1: !input scroll_wheel_mode_ext_ch1
scroll_wheel_mode_ext_ch2: !input scroll_wheel_mode_ext_ch2
scroll_wheel_mode_ext_ch3: !input scroll_wheel_mode_ext_ch3
scroll_wheel_mode_dynamic_ch1: !input scroll_wheel_mode_dynamic_ch1
scroll_wheel_mode_dynamic_ch2: !input scroll_wheel_mode_dynamic_ch2
scroll_wheel_mode_dynamic_ch3: !input scroll_wheel_mode_dynamic_ch3
var_min_pct: !input dim_min_pct
var_dim_step_pct: !input dim_step_pct
var_dim_turns_lights_on: !input dim_turns_lights_on
var_color_hue_step: !input color_hue_step
var_color_saturation: !input color_saturation
var_min_color_temp: !input min_color_temp
var_max_color_temp: !input max_color_temp
var_color_temp_step: !input color_temp_step
var_light_transition_duration: !input light_transition_duration
var_on_hold_delay: !input on_hold_delay
var_volume_step_pct: !input volume_step_pct
var_volume_max_pct: !input volume_max_pct
var_volume_use_up_down_for_instant: !input volume_use_up_down_for_instant
var_reset_time_input_select_helpers: !input auto_reset_time_input_select_helpers
last_triggered_seconds: "{{ as_timestamp(now()) - as_timestamp(this.attributes.last_triggered,
0) }} \n"
trigger_variables:
var_remote: !input remote
var_button_ch1: "{% for item in device_entities(var_remote) %} {% set match = item
| regex_findall(find='^event\\..+[a-zA-Z]_3.*$') %} {% if (match | length) > 0
%}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_left_ch1: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_2.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_right_ch1: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_1.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_left_ext_ch1: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^sensor\\..+[a-zA-Z]_2.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_right_ext_ch1: "{% for item in device_entities(var_remote) %} {% set
match = item | regex_findall(find='^sensor\\..+[a-zA-Z]_1.*$') %} {% if (match
| length) > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_button_ch2: "{% for item in device_entities(var_remote) %} {% set match = item
| regex_findall(find='^event\\..+[a-zA-Z]_6.*$') %} {% if (match | length) > 0
%}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_left_ch2: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_5.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_right_ch2: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_4.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_left_ext_ch2: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^sensor\\..+[a-zA-Z]_5.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_right_ext_ch2: "{% for item in device_entities(var_remote) %} {% set
match = item | regex_findall(find='^sensor\\..+[a-zA-Z]_4.*$') %} {% if (match
| length) > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_button_ch3: "{% for item in device_entities(var_remote) %} {% set match = item
| regex_findall(find='^event\\..+[a-zA-Z]_9.*$') %} {% if (match | length) > 0
%}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_left_ch3: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_8.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_right_ch3: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^event\\..+[a-zA-Z]_7.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_left_ext_ch3: "{% for item in device_entities(var_remote) %} {% set match
= item | regex_findall(find='^sensor\\..+[a-zA-Z]_8.*$') %} {% if (match | length)
> 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
var_scroll_right_ext_ch3: "{% for item in device_entities(var_remote) %} {% set
match = item | regex_findall(find='^sensor\\..+[a-zA-Z]_7.*$') %} {% if (match
| length) > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}"
@@ -0,0 +1,500 @@
blueprint:
name: IKEA E2490 BILRESA Scroll Wheel (Zigbee2MQTT + ZHA)
description: 'Unified controller blueprint for IKEA E2490 BILRESA scroll wheel working
with Zigbee2MQTT and ZHA.
- Buttons: on, off, on_double, off_double
- Scroll: brightness_move_to_level with action_level from Zigbee2MQTT main topic,
or ZHA move_to_level args
Supports light brightness, media_player volume, light color_temp, light hue.
Version: 2026-01-18
'
domain: automation
input:
controller_device:
name: Controller Device (Zigbee2MQTT or ZHA)
description: Select the E2490 device integrated via Zigbee2MQTT or ZHA.
selector:
device:
filter:
- integration: mqtt
- integration: zha
multiple: false
scroll_target:
name: Scroll wheel target entity (light)
selector:
entity:
filter:
- domain:
- light
multiple: false
reorder: false
scroll_mode:
name: Default scroll mode
description: Default mode when automation starts or after auto-reset timeout
default: brightness
selector:
select:
mode: dropdown
options:
- label: Brightness (light)
value: brightness
- label: Volume (media_player)
value: volume
- label: Color temperature (light)
value: color_temp
- label: Hue (light)
value: hue
sort: false
custom_value: false
multiple: false
volume_target:
name: Volume target (media_player)
default:
selector:
entity:
filter:
- domain:
- media_player
multiple: false
reorder: false
color_temp_target:
name: Color temperature target (light)
default:
selector:
entity:
filter:
- domain:
- light
multiple: false
reorder: false
hue_target:
name: Hue target (light)
default:
selector:
entity:
filter:
- domain:
- light
multiple: false
reorder: false
scroll_mode_helper:
name: (Optional) Scroll mode helper (input_select)
description: 'Provide an input_select to allow cycling scroll modes at runtime
with double ON. Must have options: brightness, volume, color_temp, hue (in
that order)'
default:
selector:
entity:
filter:
- domain:
- input_select
multiple: false
reorder: false
last_activity_helper:
name: (Optional) Last activity helper (input_datetime)
description: Provide an input_datetime to enable auto-reset after inactivity.
Works with scroll mode helper and reset timeout.
default:
selector:
entity:
filter:
- domain:
- input_datetime
multiple: false
reorder: false
reset_timeout:
name: (Optional) Auto-reset timeout (seconds)
description: Time in seconds of inactivity before resetting to default scroll
mode. Set to 0 to disable auto-reset. Requires both scroll mode helper and
last activity helper.
default: 60
selector:
number:
min: 0.0
max: 3600.0
step: 1.0
unit_of_measurement: seconds
mode: box
volume_max:
name: (Optional) Max volume
description: Upper limit for volume mode (0.0 - 1.0). Values from the wheel
will be clamped to this maximum.
default: 1.0
selector:
number:
min: 0.0
max: 1.0
step: 0.01
mode: slider
button_on_short:
name: Button ON - short press (optional)
default: []
selector:
action: {}
button_off_short:
name: Button OFF - short press (optional)
default: []
selector:
action: {}
button_on_double:
name: Button ON - double press (optional)
default: []
selector:
action: {}
button_off_double:
name: Button OFF - double press (optional)
default: []
selector:
action: {}
source_url: https://gist.github.com/mcinnes01/399132520034291b89a82659653b2893
alias: IKEA E2490 BILRESA Scroll Wheel (Zigbee2MQTT + ZHA)
variables:
controller_device: !input controller_device
friendly_name: '{{ device_attr(controller_device, ''name'') | default('''') }}'
z2m_topic: zigbee2mqtt/{{ friendly_name }}
scroll_mode: !input scroll_mode
scroll_mode_helper: !input scroll_mode_helper
last_activity_helper: !input last_activity_helper
reset_timeout: !input reset_timeout
effective_mode: '{{ states(scroll_mode_helper) if scroll_mode_helper is not none
and states(scroll_mode_helper) != '''' else scroll_mode }}'
brightness_target: !input scroll_target
volume_target: !input volume_target
color_temp_target: !input color_temp_target
hue_target: !input hue_target
volume_max: !input volume_max
available_modes: '{% set modes = [] %} {% if brightness_target is not none %}{%
set modes = modes + [''brightness''] %}{% endif %} {% if volume_target is not
none %}{% set modes = modes + [''volume''] %}{% endif %} {% if color_temp_target
is not none %}{% set modes = modes + [''color_temp''] %}{% endif %} {% if hue_target
is not none %}{% set modes = modes + [''hue''] %}{% endif %} {{ modes }}
'
button_on_short_seq: !input button_on_short
button_off_short_seq: !input button_off_short
button_on_double_seq: !input button_on_double
button_off_double_seq: !input button_off_double
trigger:
- platform: device
id: z2m-on
domain: mqtt
device_id: !input controller_device
type: action
subtype: 'on'
- platform: device
id: z2m-off
domain: mqtt
device_id: !input controller_device
type: action
subtype: 'off'
- platform: device
id: z2m-on-double
domain: mqtt
device_id: !input controller_device
type: action
subtype: on_double
- platform: device
id: z2m-off-double
domain: mqtt
device_id: !input controller_device
type: action
subtype: off_double
- platform: mqtt
id: z2m-payload
topic: zigbee2mqtt/#
- platform: event
id: zha-on
event_type: zha_event
event_data:
device_id: !input controller_device
command: 'on'
- platform: event
id: zha-off
event_type: zha_event
event_data:
device_id: !input controller_device
command: 'off'
- platform: event
id: zha-level
event_type: zha_event
event_data:
device_id: !input controller_device
command: move_to_level
condition: []
action:
- if:
- condition: template
value_template: "{{ scroll_mode_helper is not none \n and last_activity_helper
is not none \n and reset_timeout > 0\n and states(last_activity_helper)
not in ['unknown', 'unavailable', ''] \n and (now() - (states(last_activity_helper)
| as_datetime)).total_seconds() > reset_timeout }}\n"
then:
- service: input_select.select_option
target:
entity_id: '{{ scroll_mode_helper }}'
data:
option: '{{ scroll_mode }}'
- choose:
- conditions:
- condition: template
value_template: "{{ trigger.id == 'z2m-payload' \n and trigger.topic == z2m_topic
\n and trigger.payload_json is defined \n and trigger.payload_json.action
== 'brightness_move_to_level' }}\n"
sequence:
- variables:
lvl: '{{ trigger.payload_json.action_level | default(none) }}'
trans: '{{ trigger.payload_json.action_transition_time | default(0) }}'
- choose:
- conditions:
- condition: template
value_template: '{{ effective_mode == ''brightness'' and brightness_target
is not none }}'
sequence:
- service: light.turn_on
target:
entity_id: '{{ brightness_target }}'
data:
brightness: '{% if lvl is none %}255{% else %}{{ ((lvl|float/241.0)*255.0)|round(0)|int
}}{% endif %}
'
transition: '{{ trans }}'
- if:
- condition: template
value_template: '{{ last_activity_helper is not none }}'
then:
- service: input_datetime.set_datetime
target:
entity_id: '{{ last_activity_helper }}'
data:
timestamp: '{{ now().timestamp() }}'
- conditions:
- condition: template
value_template: '{{ effective_mode == ''volume'' and volume_target is not
none }}'
sequence:
- service: media_player.volume_set
target:
entity_id: '{{ volume_target }}'
data:
volume_level: "{% if lvl is none %}\n {{ volume_max }}\n{% else %}\n
\ {{ ((lvl|float/241.0) * volume_max)|round(3) }}\n{% endif %}\n"
- if:
- condition: template
value_template: '{{ last_activity_helper is not none }}'
then:
- service: input_datetime.set_datetime
target:
entity_id: '{{ last_activity_helper }}'
data:
timestamp: '{{ now().timestamp() }}'
- conditions:
- condition: template
value_template: '{{ effective_mode == ''color_temp'' and color_temp_target
is not none }}'
sequence:
- variables:
min_mireds: '{{ state_attr(color_temp_target, ''min_mireds'') | default(153)
}}'
max_mireds: '{{ state_attr(color_temp_target, ''max_mireds'') | default(500)
}}'
- service: light.turn_on
target:
entity_id: '{{ color_temp_target }}'
data:
color_temp: "{% set minm = min_mireds|int %} {% set maxm = max_mireds|int
%} {% if lvl is none %}\n {{ maxm }}\n{% else %}\n {% set ratio =
((lvl|float - 1.0)/240.0) %}\n {{ (minm + ratio*(maxm-minm))|round(0)|int
}}\n{% endif %}\n"
- if:
- condition: template
value_template: '{{ last_activity_helper is not none }}'
then:
- service: input_datetime.set_datetime
target:
entity_id: '{{ last_activity_helper }}'
data:
timestamp: '{{ now().timestamp() }}'
- conditions:
- condition: template
value_template: '{{ effective_mode == ''hue'' and hue_target is not none
}}'
sequence:
- variables:
current_hs: '{{ state_attr(hue_target, ''hs_color'') | default([0, 100])
}}'
current_sat: '{{ current_hs[1] if current_hs is sequence and current_hs|length
> 1 else 100 }}'
hue_val: '{% if lvl is none %}360{% else %}{{ ((lvl|float/241.0)*360.0)|round(0)|int
}}{% endif %}
'
- service: light.turn_on
target:
entity_id: '{{ hue_target }}'
data:
hs_color: '[ {{ hue_val }}, {{ current_sat }} ]'
- if:
- condition: template
value_template: '{{ last_activity_helper is not none }}'
then:
- service: input_datetime.set_datetime
target:
entity_id: '{{ last_activity_helper }}'
data:
timestamp: '{{ now().timestamp() }}'
- conditions:
- condition: template
value_template: '{{ trigger.id == ''zha-level'' }}'
sequence:
- variables:
lvl: '{% set a = trigger.event.data.args | default([]) %} {% if a is sequence
and a|length > 0 %}{{ a[0] }}{% else %}{{ none }}{% endif %}
'
trans: '{% set a = trigger.event.data.args | default([]) %} {% if a is sequence
and a|length > 1 %}{{ a[1] }}{% else %}0{% endif %}
'
- choose:
- conditions:
- condition: template
value_template: '{{ effective_mode == ''brightness'' and brightness_target
is not none }}'
sequence:
- service: light.turn_on
target:
entity_id: '{{ brightness_target }}'
data:
brightness: '{% if lvl is none %}255{% else %}{{ (lvl|int)|min(255)|max(1)
}}{% endif %}
'
transition: '{{ trans }}'
- if:
- condition: template
value_template: '{{ last_activity_helper is not none }}'
then:
- service: input_datetime.set_datetime
target:
entity_id: '{{ last_activity_helper }}'
data:
timestamp: '{{ now().timestamp() }}'
- conditions:
- condition: template
value_template: '{{ trigger.id in [''z2m-on'',''zha-on''] }}'
sequence:
- choose:
- conditions:
- condition: template
value_template: '{{ button_on_short_seq | length > 0 }}'
sequence: !input button_on_short
default:
- choose:
- conditions:
- condition: template
value_template: '{{ effective_mode == ''volume'' and volume_target is
not none }}'
sequence:
- service: media_player.turn_on
target:
entity_id: '{{ volume_target }}'
- conditions: []
sequence:
- service: light.turn_on
target:
entity_id: !input scroll_target
- conditions:
- condition: template
value_template: '{{ trigger.id in [''z2m-off'',''zha-off''] }}'
sequence:
- choose:
- conditions:
- condition: template
value_template: '{{ button_off_short_seq | length > 0 }}'
sequence: !input button_off_short
default:
- choose:
- conditions:
- condition: template
value_template: '{{ effective_mode == ''volume'' and volume_target is
not none }}'
sequence:
- service: media_player.turn_off
target:
entity_id: '{{ volume_target }}'
- conditions: []
sequence:
- service: light.turn_off
target:
entity_id: !input scroll_target
- conditions:
- condition: template
value_template: '{{ trigger.id == ''z2m-on-double'' }}'
sequence:
- choose:
- conditions:
- condition: template
value_template: '{{ scroll_mode_helper is not none }}'
sequence:
- variables:
current_mode: '{{ states(scroll_mode_helper) }}'
modes_list: '{{ available_modes }}'
current_index: '{{ modes_list.index(current_mode) if current_mode in modes_list
else 0 }}'
next_index: '{{ (current_index + 1) % (modes_list | length) }}'
next_mode: '{{ modes_list[next_index] if modes_list | length > 0 else
''brightness'' }}'
- service: input_select.select_option
target:
entity_id: '{{ scroll_mode_helper }}'
data:
option: '{{ next_mode }}'
- if:
- condition: template
value_template: '{{ last_activity_helper is not none }}'
then:
- service: input_datetime.set_datetime
target:
entity_id: '{{ last_activity_helper }}'
data:
timestamp: '{{ now().timestamp() }}'
- choose:
- conditions:
- condition: template
value_template: '{{ button_on_double_seq | length > 0 }}'
sequence: !input button_on_double
- conditions: []
sequence:
- choose:
- conditions:
- condition: template
value_template: '{{ button_on_double_seq | length > 0 }}'
sequence: !input button_on_double
- conditions: []
sequence:
- service: light.turn_on
target:
entity_id: !input scroll_target
data:
brightness: 255
- conditions:
- condition: template
value_template: '{{ trigger.id == ''z2m-off-double'' }}'
sequence:
- choose:
- conditions:
- condition: template
value_template: '{{ button_off_double_seq | length > 0 }}'
sequence: !input button_off_double
default:
- service: light.turn_off
target:
entity_id: !input scroll_target
mode: restart
max_exceeded: silent
@@ -0,0 +1,539 @@
blueprint:
name: IKEA Bilresa Scrollwheel - Light Control V1.0.5
description: 'The ultimate LIGHT controller for the IKEA Bilresa Smart Scroll Wheel
via Matter. (NOT for Media/Volume! NOT for the 2-Button Remote!)
VERSION 1.0.5: Included fix to check both the from_state and the to_state. VERSION
1.0.4: Code cleanup. Removed ''short_release'' placebo for multi_press events.
VERSION 1.0.3: Bugfix: Fixed "ghost switching" after Home Assistant restarts.
VERSION 1.0.2: Safety update. Added template fallbacks for RGB/Kelvin shifts.
VERSION 1.0.1: Fixed "Bouncing/Jumping" issues (added debounce logic).
YOU DECIDE EVERYTHING: - 3 Scroll Zones: Choose the function (Brightness, Kelvin,
RGB, Presets). - 3 Buttons x 2 Gestures: Choose a specific action for EACH gesture
(Double/Hold).
REQUIREMENTS: - ENABLE Sensors: 1,2, 4,5, 7,8 - ENABLE Events: 3,6,9 - DISABLE
Sensors: 3,6,9 (Not needed, keep disabled!)
HELPER GROUP: If controlling multiple lights, please always use a HELPER GROUP!
Directly controlling multiple entities can cause sync issues.
'
domain: automation
input:
light_entity:
name: Light (or Group)
selector:
entity:
domain:
- light
multiple: false
reorder: false
scroll_logic_1_2:
name: Scroll Zone 1 (Sensors 1 & 2)
default: brightness
selector:
select:
options:
- label: Brightness (Bright / Dark)
value: brightness
- label: Kelvin (Cool / Warm)
value: kelvin
- label: RGB Color (Rainbow)
value: rgb
- label: Presets (Work / Cozy)
value: preset
custom_value: false
multiple: false
sort: false
scroll_logic_4_5:
name: Scroll Zone 2 (Sensors 4 & 5)
default: kelvin
selector:
select:
options:
- label: Brightness (Bright / Dark)
value: brightness
- label: Kelvin (Cool / Warm)
value: kelvin
- label: RGB Color (Rainbow)
value: rgb
- label: Presets (Work / Cozy)
value: preset
custom_value: false
multiple: false
sort: false
scroll_logic_7_8:
name: Scroll Zone 3 (Sensors 7 & 8)
default: preset
selector:
select:
options:
- label: Brightness (Bright / Dark)
value: brightness
- label: Kelvin (Cool / Warm)
value: kelvin
- label: RGB Color (Rainbow)
value: rgb
- label: Presets (Work / Cozy)
value: preset
custom_value: false
multiple: false
sort: false
btn1_double:
name: 'Button 1: Double Click Action'
default: set_bright
selector:
select:
options:
- label: -> Bright (80%)
value: set_bright
- label: -> Cool White (4000K)
value: set_cold
- label: -> White (RGB Reset)
value: set_white
- label: -> Red (Signal Color)
value: set_red
- label: -> No Action
value: none
custom_value: false
multiple: false
sort: false
btn1_hold:
name: 'Button 1: Hold Action'
default: set_dim
selector:
select:
options:
- label: -> Dimmed (20%)
value: set_dim
- label: -> Warm White (2200K)
value: set_warm
- label: -> Enable Color (Saturation On)
value: set_color_on
- label: -> No Action
value: none
custom_value: false
multiple: false
sort: false
btn2_double:
name: 'Button 2: Double Click Action'
default: set_cold
selector:
select:
options:
- label: -> Bright (80%)
value: set_bright
- label: -> Cool White (4000K)
value: set_cold
- label: -> White (RGB Reset)
value: set_white
- label: -> Red (Signal Color)
value: set_red
- label: -> No Action
value: none
custom_value: false
multiple: false
sort: false
btn2_hold:
name: 'Button 2: Hold Action'
default: set_warm
selector:
select:
options:
- label: -> Dimmed (20%)
value: set_dim
- label: -> Warm White (2200K)
value: set_warm
- label: -> Enable Color (Saturation On)
value: set_color_on
- label: -> No Action
value: none
custom_value: false
multiple: false
sort: false
btn3_double:
name: 'Button 3: Double Click Action'
default: set_bright
selector:
select:
options:
- label: -> Bright (80%)
value: set_bright
- label: -> Cool White (4000K)
value: set_cold
- label: -> White (RGB Reset)
value: set_white
- label: -> Red (Signal Color)
value: set_red
- label: -> No Action
value: none
custom_value: false
multiple: false
sort: false
btn3_hold:
name: 'Button 3: Hold Action'
default: set_dim
selector:
select:
options:
- label: -> Dimmed (20%)
value: set_dim
- label: -> Warm White (2200K)
value: set_warm
- label: -> Enable Color (Saturation On)
value: set_color_on
- label: -> No Action
value: none
custom_value: false
multiple: false
sort: false
step_pct:
name: 'Step Size: Brightness (%)'
default: 10
selector:
number:
min: 1.0
max: 50.0
unit_of_measurement: '%'
step: 1.0
mode: slider
step_kelvin:
name: 'Step Size: Kelvin (K)'
default: 400
selector:
number:
min: 100.0
max: 1000.0
unit_of_measurement: K
step: 1.0
mode: slider
step_hue:
name: 'Step Size: RGB Hue (°)'
default: 15
selector:
number:
min: 5.0
max: 90.0
unit_of_measurement: °
step: 1.0
mode: slider
val_bright:
name: 'Value: Bright (%)'
default: 80
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
val_dim:
name: 'Value: Dim (%)'
default: 20
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
val_cold:
name: 'Value: Cool (K)'
default: 4000
selector:
number:
min: 2000.0
max: 6500.0
step: 1.0
mode: slider
val_warm:
name: 'Value: Warm (K)'
default: 2200
selector:
number:
min: 2000.0
max: 6500.0
step: 1.0
mode: slider
sensor_1:
name: Sensor 1 (Look for '(1)')
selector:
entity:
domain:
- sensor
multiple: false
reorder: false
sensor_2:
name: Sensor 2 (Look for '(2)')
selector:
entity:
domain:
- sensor
multiple: false
reorder: false
sensor_4:
name: Sensor 4 (Look for '(4)')
selector:
entity:
domain:
- sensor
multiple: false
reorder: false
sensor_5:
name: Sensor 5 (Look for '(5)')
selector:
entity:
domain:
- sensor
multiple: false
reorder: false
sensor_7:
name: Sensor 7 (Look for '(7)')
selector:
entity:
domain:
- sensor
multiple: false
reorder: false
sensor_8:
name: Sensor 8 (Look for '(8)')
selector:
entity:
domain:
- sensor
multiple: false
reorder: false
event_taste_1:
name: Event Button 1 (Look for '(3)')
selector:
entity:
domain:
- event
multiple: false
reorder: false
event_taste_2:
name: Event Button 2 (Look for '(6)')
selector:
entity:
domain:
- event
multiple: false
reorder: false
event_taste_3:
name: Event Button 3 (Look for '(9)')
selector:
entity:
domain:
- event
multiple: false
reorder: false
source_url: https://gist.github.com/stone13/63a778bdbf55efbd712ade1d074e9dbb
mode: single
max_exceeded: silent
variables:
target_light: !input light_entity
m_sc_1: !input scroll_logic_1_2
m_sc_2: !input scroll_logic_4_5
m_sc_3: !input scroll_logic_7_8
b1_d: !input btn1_double
b1_h: !input btn1_hold
b2_d: !input btn2_double
b2_h: !input btn2_hold
b3_d: !input btn3_double
b3_h: !input btn3_hold
s_p: !input step_pct
s_k: !input step_kelvin
s_h: !input step_hue
v_bri: !input val_bright
v_dim: !input val_dim
v_cold: !input val_cold
v_warm: !input val_warm
id_e1: !input event_taste_1
id_e2: !input event_taste_2
id_e3: !input event_taste_3
triggers:
- trigger: state
entity_id: !input sensor_1
id: sc_1_up
- trigger: state
entity_id: !input sensor_2
id: sc_1_down
- trigger: state
entity_id: !input sensor_4
id: sc_2_down
- trigger: state
entity_id: !input sensor_5
id: sc_2_up
- trigger: state
entity_id: !input sensor_7
id: sc_3_up
- trigger: state
entity_id: !input sensor_8
id: sc_3_down
- trigger: state
entity_id:
- !input event_taste_1
- !input event_taste_2
- !input event_taste_3
id: btn_event
conditions:
- condition: template
value_template: "{{ trigger.from_state is defined and trigger.to_state is defined
and\n trigger.from_state.state not in ['unknown', 'unavailable'] and\n trigger.to_state.state
not in ['unknown', 'unavailable'] }}"
actions:
- choose:
- conditions: '{{ ''sc_'' in trigger.id }}'
sequence:
- variables:
direction: '{% if ''_up'' in trigger.id %} 1 {% else %} -1 {% endif %}
'
active_func: '{% if ''sc_1'' in trigger.id %} {{ m_sc_1 }} {% elif ''sc_2''
in trigger.id %} {{ m_sc_2 }} {% elif ''sc_3'' in trigger.id %} {{ m_sc_3
}} {% endif %}
'
- choose:
- conditions: '{{ active_func == ''brightness'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
brightness_step_pct: '{{ (s_p | float(default=10)) * direction }}'
transition: 0
- conditions: '{{ active_func == ''kelvin'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
color_temp_kelvin: '{% set current = state_attr(target_light, ''color_temp_kelvin'')
| int(default=3000) %} {% set step = s_k | int(default=400) %} {% if
direction == 1 %} {{ ([current + step, 6500] | min) | int }} {% else
%} {{ ([current - step, 2000] | max) | int }} {% endif %}
'
transition: 0
- conditions: '{{ active_func == ''rgb'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
hs_color: '{% set current_hs = state_attr(target_light, ''hs_color'')
%} {% set h = current_hs[0] | float(default=0) if current_hs is not
none else 0 %} {% set s = current_hs[1] | float(default=100) if current_hs
is not none else 100 %} {% set step = s_h | float(default=15) %} {{
[(h + (step * direction)) % 360, s] }}
'
transition: 0
- conditions: '{{ active_func == ''preset'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
brightness_pct: '{{ v_bri if direction == 1 else v_dim }}'
color_temp_kelvin: '{{ v_cold if direction == 1 else v_warm }}'
transition: 1
- choose:
- conditions: '{{ trigger.id == ''btn_event'' }}'
sequence:
- choose:
- conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type
== 'multi_press_1' }}\n"
sequence:
- action: light.toggle
target:
entity_id: '{{ target_light }}'
data:
transition: 0.5
- choose:
- conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type
== 'multi_press_2' }}\n"
sequence:
- variables:
action_code: '{% if trigger.entity_id == id_e1 %} {{ b1_d }} {% elif trigger.entity_id
== id_e2 %} {{ b2_d }} {% elif trigger.entity_id == id_e3 %} {{ b3_d
}} {% endif %}
'
- choose:
- conditions: '{{ action_code == ''set_bright'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
brightness_pct: '{{ v_bri }}'
transition: 0.5
- conditions: '{{ action_code == ''set_cold'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
color_temp_kelvin: '{{ v_cold }}'
transition: 0.5
- conditions: '{{ action_code == ''set_white'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
color_temp_kelvin: 4000
transition: 0.5
- conditions: '{{ action_code == ''set_red'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
hs_color:
- 0
- 100
brightness_pct: 80
transition: 0.5
- choose:
- conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type
== 'long_press' }}\n"
sequence:
- variables:
action_code: '{% if trigger.entity_id == id_e1 %} {{ b1_h }} {% elif trigger.entity_id
== id_e2 %} {{ b2_h }} {% elif trigger.entity_id == id_e3 %} {{ b3_h
}} {% endif %}
'
- choose:
- conditions: '{{ action_code == ''set_dim'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
brightness_pct: '{{ v_dim }}'
transition: 0.5
- conditions: '{{ action_code == ''set_warm'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
color_temp_kelvin: '{{ v_warm }}'
transition: 0.5
- conditions: '{{ action_code == ''set_color_on'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
hs_color:
- 0
- 100
brightness_pct: 80
transition: 0.5
- delay:
milliseconds: 150
@@ -0,0 +1,920 @@
blueprint:
name: IKEA Bilresa Scrollwheel - Light Control
author: thetestspecimen
source_url: https://github.com/thetestspecimen/homeassistant/blob/main/blueprints/ikea-bilresa-scroll-wheel.yaml
description: "\n# \U0001F4A1 Light controller \U0001F4A1\n## IKEA BILRESA - Smart
Scroll Wheel utilising Matter over Thread\n\n\n## VARIABLES:\n- 3 Scroll Zones:
Choose the function (Brightness, Kelvin, RGB, Presets).\n- 3 Buttons x 3 Gestures:
Choose a specific action for EACH gesture (double click / triple click / hold).\n\n***Note:***
single click will always toggle the light on or off.\n\n## REQUIREMENTS:\n\nFor
optimal behavior, the following settings must be applied:\n- ENABLE Events: 3,6,9
(in section \"Events\")\n- ENABLE Sensors: 1,2,4,5,7,8 (in section \"Diagnostic\")\n-
DISABLE Events: 1,2,4,5,7,8 (in section \"Events\")\n- DISABLE Sensors: 3,6,9
(in section \"Diagnostic\")\n\n***Note:*** this blueprint cannot be used with
the BILRESA 2-Button Remote.\n"
homeassistant:
min_version: 2024.6.0
domain: automation
input:
header_entity_mapping:
name: Entity Mapping [REQUIRED]
description: Select the entities for the scroll wheel and buttons
default: ''
selector:
constant:
value: ''
group_buttons:
name: Buttons
icon: mdi:gesture-tap
collapsed: true
input:
event_button_1:
name: 'Channel 1: Button'
description: 'Event: Look for ''Button (3)'''
selector:
entity:
filter:
- domain:
- event
integration: matter
device_class:
- button
multiple: false
reorder: false
event_button_2:
name: 'Channel 2: Button'
description: 'Event: Look for ''Button (6)'''
selector:
entity:
filter:
- domain:
- event
integration: matter
device_class:
- button
multiple: false
reorder: false
event_button_3:
name: 'Channel 3: Button'
description: 'Event: Look for ''Button (9)'''
selector:
entity:
filter:
- domain:
- event
integration: matter
device_class:
- button
multiple: false
reorder: false
group_scroll_wheel:
name: Scroll Wheel
icon: mdi:rotate-right
collapsed: true
input:
sensor_1:
name: 'Channel 1: Scroll forwards'
description: 'Sensor: Look for ''Current switch position (1)'''
selector:
entity:
filter:
- domain:
- sensor
integration: matter
multiple: false
reorder: false
sensor_2:
name: 'Channel 1: Scroll backwards'
description: 'Sensor: Look for ''Current switch position (2)'''
selector:
entity:
filter:
- domain:
- sensor
integration: matter
multiple: false
reorder: false
sensor_4:
name: 'Channel 2: Scroll forwards'
description: 'Sensor: Look for ''Current switch position (4)'''
selector:
entity:
filter:
- domain:
- sensor
integration: matter
multiple: false
reorder: false
sensor_5:
name: 'Channel 2: Scroll backwards'
description: 'Sensor: Look for ''Current switch position (5)'''
selector:
entity:
filter:
- domain:
- sensor
integration: matter
multiple: false
reorder: false
sensor_7:
name: 'Channel 3: Scroll forwards'
description: 'Sensor: Look for ''Current switch position (7)'''
selector:
entity:
filter:
- domain:
- sensor
integration: matter
multiple: false
reorder: false
sensor_8:
name: 'Channel 3: Scroll backwards'
description: 'Sensor: Look for ''Current switch position (8)'''
selector:
entity:
filter:
- domain:
- sensor
integration: matter
multiple: false
reorder: false
header_default_light:
name: Default Light
default: ''
selector:
constant:
value: ''
global_light:
name: Global Light - Default [REQUIRED]
description: This light will be used for any zone or button that does not have
a specific override light set below.
selector:
entity:
domain:
- light
multiple: true
reorder: false
header_1:
name: Channel 1
default: ''
selector:
constant:
value: ''
group_scroll_1:
name: Scroll Wheel Settings
icon: mdi:rotate-right
collapsed: true
input:
light_z1:
name: Light (Scroll - Channel 1) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
scroll_logic_1_2:
name: Scroll - Channel 1
description: Sensors 1 & 2
default: brightness
selector:
select:
options:
- label: Brightness (Bright / Dark)
value: brightness
- label: Kelvin (Cool / Warm)
value: kelvin
- label: RGB Colour (Rainbow)
value: rgb
- label: Presets (Work / Cozy)
value: preset
sort: false
custom_value: false
multiple: false
group_button_1_single:
name: 'Button 1: Single Click (Toggle On/Off)'
icon: mdi:gesture-tap
collapsed: true
input:
light_b1_single:
name: Light (Button 1 Single Click) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
group_button_1_double:
name: 'Button 1: Double Click'
icon: mdi:gesture-double-tap
collapsed: true
input:
light_b1_double:
name: Light (Button 1 Double Click) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
btn1_double_bright:
name: 'Button 1: Double Click Brightness (%)'
default: 80
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
btn1_double_temp:
name: 'Button 1: Double Click Colour Temp (K)'
default: 4000
selector:
color_temp:
unit: kelvin
min: 2000
max: 6500
group_button_1_triple:
name: 'Button 1: Triple Click'
icon: mdi:gesture-tap
collapsed: true
input:
light_b1_triple:
name: Light (Button 1 Triple Click) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
btn1_triple_bright:
name: 'Button 1: Triple Click Brightness (%)'
default: 100
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
btn1_triple_temp:
name: 'Button 1: Triple Click Colour Temp (K)'
default: 6000
selector:
color_temp:
unit: kelvin
min: 2000
max: 6500
group_button_1_hold:
name: 'Button 1: Hold'
icon: mdi:gesture-tap-hold
collapsed: true
input:
light_b1_hold:
name: Light (Button 1 Hold) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
btn1_hold_bright:
name: 'Button 1: Hold Brightness (%)'
default: 20
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
btn1_hold_temp:
name: 'Button 1: Hold Colour Temp'
default: 2200
selector:
color_temp:
unit: kelvin
min: 2000
max: 6500
header_2:
name: Channel 2
default: ''
selector:
constant:
value: ''
group_scroll_2:
name: Scroll Wheel Settings
icon: mdi:rotate-right
collapsed: true
input:
light_z2:
name: Light (Scroll - Channel 2) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
scroll_logic_4_5:
name: Scroll - Channel 2
description: Sensors 4 & 5
default: kelvin
selector:
select:
options:
- label: Brightness (Bright / Dark)
value: brightness
- label: Kelvin (Cool / Warm)
value: kelvin
- label: RGB Colour (Rainbow)
value: rgb
- label: Presets (Work / Cozy)
value: preset
sort: false
custom_value: false
multiple: false
group_button_2_single:
name: 'Button 2: Single Click (Toggle On/Off)'
icon: mdi:gesture-tap
collapsed: true
input:
light_b2_single:
name: Light (Button 2 Single Click) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
group_button_2_double:
name: 'Button 2: Double Click'
icon: mdi:gesture-double-tap
collapsed: true
input:
light_b2_double:
name: Light (Button 2 Double Click) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
btn2_double_bright:
name: 'Button 2: Double Click Brightness (%)'
default: 80
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
btn2_double_temp:
name: 'Button 2: Double Click Colour Temp (K)'
default: 4000
selector:
color_temp:
unit: kelvin
min: 2000
max: 6500
group_button_2_triple:
name: 'Button 2: Triple Click'
icon: mdi:gesture-tap
collapsed: true
input:
light_b2_triple:
name: Light (Button 2 Triple Click) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
btn2_triple_bright:
name: 'Button 2: Triple Click Brightness (%)'
default: 100
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
btn2_triple_temp:
name: 'Button 2: Triple Click Colour Temp (K)'
default: 6000
selector:
color_temp:
unit: kelvin
min: 2000
max: 6500
group_button_2_hold:
name: 'Button 2: Hold'
icon: mdi:gesture-tap-hold
collapsed: true
input:
light_b2_hold:
name: Light (Button 2 Hold) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
btn2_hold_bright:
name: 'Button 2: Hold Brightness (%)'
default: 20
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
btn2_hold_temp:
name: 'Button 2: Hold Colour Temp (K)'
default: 2200
selector:
color_temp:
unit: kelvin
min: 2000
max: 6500
header_3:
name: Channel 3
default: ''
selector:
constant:
value: ''
group_scroll_3:
name: Scroll Wheel Settings
icon: mdi:rotate-right
collapsed: true
input:
light_z3:
name: Light (Scroll - Channel 3) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
scroll_logic_7_8:
name: Scroll - Channel 3
description: Sensors 7 & 8
default: preset
selector:
select:
options:
- label: Brightness (Bright / Dark)
value: brightness
- label: Kelvin (Cool / Warm)
value: kelvin
- label: RGB Colour (Rainbow)
value: rgb
- label: Presets (Work / Cozy)
value: preset
sort: false
custom_value: false
multiple: false
group_button_3_single:
name: 'Button 3: Single Click (Toggle On/Off)'
icon: mdi:gesture-tap
collapsed: true
input:
light_b3_single:
name: Light (Button 3 Single Click) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
group_button_3_double:
name: 'Button 3: Double Click'
icon: mdi:gesture-double-tap
collapsed: true
input:
light_b3_double:
name: Light (Button 3 Double Click) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
btn3_double_bright:
name: 'Button 3: Double Click Brightness (%)'
default: 80
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
btn3_double_temp:
name: 'Button 3: Double Click Colour Temp (K)'
default: 4000
selector:
color_temp:
unit: kelvin
min: 2000
max: 6500
group_button_3_triple:
name: 'Button 3: Triple Click'
icon: mdi:gesture-tap
collapsed: true
input:
light_b3_triple:
name: Light (Button 3 Triple Click) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
btn3_triple_bright:
name: 'Button 3: Triple Click Brightness (%)'
default: 100
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
btn3_triple_temp:
name: 'Button 3: Triple Click Colour Temp (K)'
default: 6000
selector:
color_temp:
unit: kelvin
min: 2000
max: 6500
group_button_3_hold:
name: 'Button 3: Hold'
icon: mdi:gesture-tap-hold
collapsed: true
input:
light_b3_hold:
name: Light (Button 3 Hold) - Optional Override
default: ''
selector:
entity:
domain:
- light
multiple: true
reorder: false
btn3_hold_bright:
name: 'Button 3: Hold Brightness (%)'
default: 20
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
btn3_hold_temp:
name: 'Button 3: Hold Colour Temp (K)'
default: 2200
selector:
color_temp:
unit: kelvin
min: 2000
max: 6500
header_tuning:
name: Fine Tuning
default: ''
selector:
constant:
value: ''
group_step_size:
name: Step Sizes
icon: mdi:tune
collapsed: true
input:
step_pct:
name: 'Step Size: Brightness (%)'
default: 10
selector:
number:
min: 1.0
max: 50.0
unit_of_measurement: '%'
step: 1.0
mode: slider
step_kelvin:
name: 'Step Size: Kelvin (K)'
default: 400
selector:
number:
min: 100.0
max: 1000.0
unit_of_measurement: K
step: 1.0
mode: slider
step_hue:
name: 'Step Size: RGB Hue (°)'
default: 15
selector:
number:
min: 5.0
max: 90.0
unit_of_measurement: °
step: 1.0
mode: slider
group_values:
name: Preset Values
icon: mdi:brightness-6
collapsed: true
input:
val_bright:
name: 'Work: Brightness (%)'
description: The brightness value to use for the 'Work' preset
default: 80
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
val_cold:
name: 'Work: Colour Temperature (K)'
description: The colour temperature value to use for the 'Work' preset
default: 4000
selector:
color_temp:
unit: kelvin
min: 2000
max: 6500
val_dim:
name: 'Cozy: Brightness (%)'
description: The brightness value to use for the 'Cozy' preset
default: 20
selector:
number:
min: 1.0
max: 100.0
step: 1.0
mode: slider
val_warm:
name: 'Cozy: Colour Temperature (K)'
description: The colour temperature value to use for the 'Cozy' preset
default: 2200
selector:
color_temp:
unit: kelvin
min: 2000
max: 6500
group_timings:
name: Timings
icon: mdi:clock
collapsed: true
input:
transition_time:
name: Button Transition Time (Seconds)
default: 0.5
selector:
number:
min: 0.0
max: 5.0
step: 0.1
mode: slider
delay_time:
name: Delay (Milliseconds)
description: Delay between command actions. This is effectively button debounce.
default: 150
selector:
number:
min: 0.0
max: 1000.0
step: 10.0
mode: slider
mode: single
max_exceeded: silent
variables:
global_l: !input global_light
l_z1: !input light_z1
l_z2: !input light_z2
l_z3: !input light_z3
l_b1_s: !input light_b1_single
l_b1_d: !input light_b1_double
l_b1_t: !input light_b1_triple
l_b1_h: !input light_b1_hold
l_b2_s: !input light_b2_single
l_b2_d: !input light_b2_double
l_b2_t: !input light_b2_triple
l_b2_h: !input light_b2_hold
l_b3_s: !input light_b3_single
l_b3_d: !input light_b3_double
l_b3_t: !input light_b3_triple
l_b3_h: !input light_b3_hold
m_sc_1: !input scroll_logic_1_2
m_sc_2: !input scroll_logic_4_5
m_sc_3: !input scroll_logic_7_8
b1_d_b: !input btn1_double_bright
b1_d_t: !input btn1_double_temp
b1_t_b: !input btn1_triple_bright
b1_t_t: !input btn1_triple_temp
b1_h_b: !input btn1_hold_bright
b1_h_t: !input btn1_hold_temp
b2_d_b: !input btn2_double_bright
b2_d_t: !input btn2_double_temp
b2_t_b: !input btn2_triple_bright
b2_t_t: !input btn2_triple_temp
b2_h_b: !input btn2_hold_bright
b2_h_t: !input btn2_hold_temp
b3_d_b: !input btn3_double_bright
b3_d_t: !input btn3_double_temp
b3_t_b: !input btn3_triple_bright
b3_t_t: !input btn3_triple_temp
b3_h_b: !input btn3_hold_bright
b3_h_t: !input btn3_hold_temp
s_p: !input step_pct
s_k: !input step_kelvin
s_h: !input step_hue
v_bri: !input val_bright
v_dim: !input val_dim
v_cold: !input val_cold
v_warm: !input val_warm
t_time: !input transition_time
d_time: !input delay_time
id_e1: !input event_button_1
id_e2: !input event_button_2
id_e3: !input event_button_3
triggers:
- trigger: state
entity_id: !input sensor_1
id: sc_1_up
- trigger: state
entity_id: !input sensor_2
id: sc_1_down
- trigger: state
entity_id: !input sensor_4
id: sc_2_down
- trigger: state
entity_id: !input sensor_5
id: sc_2_up
- trigger: state
entity_id: !input sensor_7
id: sc_3_up
- trigger: state
entity_id: !input sensor_8
id: sc_3_down
- trigger: state
entity_id:
- !input event_button_1
- !input event_button_2
- !input event_button_3
id: btn_event
conditions:
- condition: template
value_template: "{{ trigger.from_state is defined and trigger.to_state is defined
and\n trigger.from_state.state not in ['unknown', 'unavailable'] and\n trigger.to_state.state
not in ['unknown', 'unavailable'] and\n trigger.from_state.state != trigger.to_state.state
}}"
actions:
- choose:
- conditions: '{{ ''sc_'' in trigger.id }}'
sequence:
- variables:
target_light: '{{ l_z1 if ''sc_1'' in trigger.id and l_z1 else l_z2 if ''sc_2''
in trigger.id and l_z2 else l_z3 if ''sc_3'' in trigger.id and l_z3 else
global_l }}'
direction: '{{ 1 if ''_up'' in trigger.id else -1 }}'
active_func: '{{ m_sc_1 if ''sc_1'' in trigger.id else m_sc_2 if ''sc_2''
in trigger.id else m_sc_3 }}'
- choose:
- conditions: '{{ active_func == ''brightness'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
brightness_step_pct: '{{ (s_p | float(default=10)) * direction }}'
transition: 0
- conditions: '{{ active_func == ''kelvin'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
color_temp_kelvin: '{% set entities = expand(target_light) | map(attribute=''entity_id'')
| list %} {% set first_light = entities[0] if entities else ''light.none''
%} {% set current = state_attr(first_light, ''color_temp_kelvin'') |
int(default=3000) %} {% set step = s_k | int(default=400) %} {% set
min_k = state_attr(first_light, ''min_color_temp_kelvin'') | int(default=2000)
%} {% set max_k = state_attr(first_light, ''max_color_temp_kelvin'')
| int(default=6500) %} {% if direction == 1 %} {{ ([current + step,
max_k] | min) | int }} {% else %} {{ ([current - step, min_k] | max)
| int }} {% endif %}
'
transition: 0
- conditions: '{{ active_func == ''rgb'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
hs_color: '{% set entities = expand(target_light) | map(attribute=''entity_id'')
| list %} {% set first_light = entities[0] if entities else ''light.none''
%} {% set current_hs = state_attr(first_light, ''hs_color'') %} {% set
h = current_hs[0] | float(default=0) if current_hs is not none else
0 %} {% set s = current_hs[1] | float(default=100) if current_hs is
not none else 100 %} {% set step = s_h | float(default=15) %} {{ [(h
+ (step * direction)) % 360, s] }}
'
transition: 0
- conditions: '{{ active_func == ''preset'' }}'
sequence:
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
brightness_pct: '{{ v_bri if direction == 1 else v_dim }}'
color_temp_kelvin: '{{ v_cold if direction == 1 else v_warm }}'
transition: 1
- choose:
- conditions: '{{ trigger.id == ''btn_event'' }}'
sequence:
- choose:
- conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type
== 'multi_press_1' }}\n"
sequence:
- variables:
target_light: '{{ l_b1_s if trigger.entity_id == id_e1 and l_b1_s else
l_b2_s if trigger.entity_id == id_e2 and l_b2_s else l_b3_s if trigger.entity_id
== id_e3 and l_b3_s else global_l }}'
- action: light.toggle
target:
entity_id: '{{ target_light }}'
data:
transition: '{{ t_time }}'
- choose:
- conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type
== 'multi_press_2' }}\n"
sequence:
- variables:
target_light: '{{ l_b1_d if trigger.entity_id == id_e1 and l_b1_d else
l_b2_d if trigger.entity_id == id_e2 and l_b2_d else l_b3_d if trigger.entity_id
== id_e3 and l_b3_d else global_l }}'
set_bri: '{{ b1_d_b if trigger.entity_id == id_e1 else b2_d_b if trigger.entity_id
== id_e2 else b3_d_b }}'
set_temp: '{{ b1_d_t if trigger.entity_id == id_e1 else b2_d_t if trigger.entity_id
== id_e2 else b3_d_t }}'
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
brightness_pct: '{{ set_bri }}'
color_temp_kelvin: '{{ set_temp }}'
transition: '{{ t_time }}'
- choose:
- conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type
== 'multi_press_3' }}\n"
sequence:
- variables:
target_light: '{{ l_b1_t if trigger.entity_id == id_e1 and l_b1_t else
l_b2_t if trigger.entity_id == id_e2 and l_b2_t else l_b3_t if trigger.entity_id
== id_e3 and l_b3_t else global_l }}'
set_bri: '{{ b1_t_b if trigger.entity_id == id_e1 else b2_t_b if trigger.entity_id
== id_e2 else b3_t_b }}'
set_temp: '{{ b1_t_t if trigger.entity_id == id_e1 else b2_t_t if trigger.entity_id
== id_e2 else b3_t_t }}'
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
brightness_pct: '{{ set_bri }}'
color_temp_kelvin: '{{ set_temp }}'
transition: '{{ t_time }}'
- choose:
- conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type
== 'long_press' }}\n"
sequence:
- variables:
target_light: '{{ l_b1_h if trigger.entity_id == id_e1 and l_b1_h else
l_b2_h if trigger.entity_id == id_e2 and l_b2_h else l_b3_h if trigger.entity_id
== id_e3 and l_b3_h else global_l }}'
set_bri: '{{ b1_h_b if trigger.entity_id == id_e1 else b2_h_b if trigger.entity_id
== id_e2 else b3_h_b }}'
set_temp: '{{ b1_h_t if trigger.entity_id == id_e1 else b2_h_t if trigger.entity_id
== id_e2 else b3_h_t }}'
- action: light.turn_on
target:
entity_id: '{{ target_light }}'
data:
brightness_pct: '{{ set_bri }}'
color_temp_kelvin: '{{ set_temp }}'
transition: '{{ t_time }}'
- delay:
milliseconds: '{{ d_time }}'
@@ -0,0 +1,153 @@
blueprint:
name: dimmer_control_4_magic_cube_and_other_things - 2023-12-07
author: SirGoodenough
domain: script
homeassistant:
min_version: 2023.8.0
source_url: https://github.com/SirGoodenough/HA_Blueprints/blob/master/Scripts/dimmer_control_4_magic_cube.yaml
description: "This script was invented to be used with my Magic Cube Blueprints.
I released it as a standalone Script Blueprint because I saw others struggling
with how so convert cube rotation to light dimming in other places.\n\nTo use
this blueprint, install it from the source in the normal way. After that add your
unique name for the Script, and add an icon and change the entity_id if desired.
You should only need to run the blueprint once, as you will be calling the unique
name/entity you added above to use the code. This script has 2 inputs in the Blueprint
screen to set the sensitivity of the rotation action and the minimum bbrightness
level for your lights. If the rotation is too sensitive the lights will change
from dim to bright in too short of a rotation so make it less sensitive. If the
lights flicker at low brightness, raise the minimum brightness level to stop it.
The data is sent to the generated script to control the action live at each use.
You only need to ececute this blueprint generator script if you wish to change
in input values or create another dimmer script.\n\nThis requires you call this
script wth 2 data values. One is a positive or negative number between 360 and
-360 that in the original, represents the input angle from the cube movement.
The other data point is the entity of the light or light group that you are trying
to control.\n***NOTE:*** the trigger variable below will be different for Z2M
and ZHA and others. Look at my documentation for that cube integration help if
you need it.\n\n## \U0001F5FFNotice\U0001F46E\U0001F3FF‍♂️:\n* Copies of the original
Blueprint that were converted via the 'Take Control' feature or other means are
officially not supported by me.\n\n* I may or may not be able to support you when
you have a problem after you make changes to my code, as some of the code is no
longer mine.\n\n* I & my license also require attribution as a link back to the
original should you use this code in your own creation.\n\n* [Here is a link to
my license & the original github post](https://github.com/SirGoodenough/HA_Blueprints?tab=License-1-ov-file)
expected to be followed & referenced as attribution should you use this code elsewhere.\n\n
\ Sample call / use of this script for ZHA:\n ```yaml\n - action: script.cube_dimmer_control_bp\n
\ data:\n angle: '{{ trigger.event.data.args.relative_degrees | default(0.1)
| float(0.2) }}'\n light: light.grp_studio\n ```\n\n Sample call /
use of this script for Z2M:\n ```yaml\n - action: script.cube_dimmer_control_bp\n
\ data:\n angle: '{{ trigger.payload_json.action_angle | default(0.1)
| float(0.2) }}'\n light: light.grp_kitch\n ```\n"
input:
sensi:
name: Sensitivity of the cube rotational motion.
description: 'This sets how ''fast'' the changes happen compared to how far
you move the cube. Higher percentage is faster, lower is slower.
I have found that 40% is a good number, but you can adjust as desired.
'
default: 40
selector:
number:
min: 1.0
max: 200.0
step: 1.0
mode: box
min_b:
name: Minimum Brighness
description: 'This sets the lowest brightness it will set before it turns off.
Some bulbs react poorly below a certain percent, you can adjust that here.
I have found that 10% is a good number, but you can adjust as desired.
'
default: 10
selector:
number:
min: 5.0
max: 90.0
step: 1.0
mode: slider
transition_i:
name: Transition Input
description: Seconds to take to change light one level to another
default: []
selector:
number:
min: 0.0
max: 1800.0
step: 0.5
mode: slider
fields:
angle:
name: Angle to represent new brightness
description: ' This is the new angle to be used in the calculation for Brightness'
required: true
example: '-120.7'
default: -120.7
selector:
number:
min: -360
max: 360
step: 0.1
mode: box
light:
name: Light entity to control
description: Add the light entity you want to control here
required: true
example: light.grp_studio
default: light.grp_studio
selector:
entity:
multiple: false
filter:
domain: light
transition:
name: Transition Field
description: Seconds to take to change light one level to another
required: false
example: '4'
selector:
number:
min: 0
max: 1800
step: 0.5
variables:
sensi_var: !input sensi
sens_d: '{{ sensi_var / 100 | default( 0.4 ) }}'
min_b_var: !input min_b
bright_p: "{% set step_size = angle * sens_d %}\n {# Get brightness as a percent.
#}\n{% set cb = (state_attr( light, 'brightness') | float(10) / 255.0) * 100.0
%} {% set new_brightness = cb | int(10) + step_size %} {% if new_brightness <
5 %}\n {# If it gets really low set to 0. Adjust if needed for your lights. #}\n
\ 0\n{% elif new_brightness <= min_b_var %}\n {# If it's not quite 0, set to
a minimum working brightness. #}\n {{ min_b_var }}\n{% elif 91 <= new_brightness
< (90 + step_size) %}\n {# If it's almost full brightness, set to 100%. #}\n
\ 100\n{% else %}\n {# Send actual calculated value. #}\n{{ new_brightness }}
{% endif %}"
trans_i: !input transition_i
transition_var: "{% if is_number(transition | default('none')) %}\n {{ transition
}}\n{% elif is_number(trans_i | default('none')) %}\n {{ trans_i }}\n{% else
%}\n none\n{% endif %}"
sequence:
- if:
- alias: turn the light & set brightness
condition: template
value_template: '{{ is_number(transition_var) }}'
then:
- alias: Transition number IS available
action: light.turn_on
data:
entity_id: '{{ light }}'
brightness_pct: '{{ bright_p }}'
transition: '{{ transition_var }}'
else:
- alias: NO transition number available
action: light.turn_on
data:
entity_id: '{{ light }}'
brightness_pct: '{{ bright_p }}'
icon: mdi:lightbulb-on-50
mode: queued
max: 10
+65 -7
View File
@@ -4,6 +4,12 @@ default_config:
# Core Utility Meter Engine (Required for Hilo to generate compatible energy dashboard metrics) # Core Utility Meter Engine (Required for Hilo to generate compatible energy dashboard metrics)
utility_meter: utility_meter:
# Load ffmpeg
ffmpeg:
# Enable Streams
stream:
# Load frontend themes from the themes folder # Load frontend themes from the themes folder
frontend: frontend:
themes: !include_dir_merge_named themes themes: !include_dir_merge_named themes
@@ -60,13 +66,13 @@ template:
device_class: power device_class: power
state_class: measurement state_class: measurement
state: > state: >
{% set hvac = states.climate | selectattr('entity_id', 'search', 'living_room') | map(attribute='entity_id') | first %} {% set hvac = 'climate.living_room_living_room' %}
{% if hvac %} {% if states(hvac) not in ['unknown', 'unavailable'] %}
{% set action = state_attr(hvac, 'hvac_action') | string | lower %} {% set action = state_attr(hvac, 'hvac_action') | string | lower %}
{% if action == 'cooling' %} {% if action == 'cooling' %}
2.12 2.12
{% elif action == 'heating' %} {% elif action == 'heating' %}
{% if is_state_attr(hvac, 'is_aux_heat', true) %} {% if is_state_attr(hvac, 'preset_mode', 'eco') or is_state_attr(hvac, 'aux_heat', 'on') %}
20.5 20.5
{% else %} {% else %}
2.5 2.5
@@ -111,14 +117,66 @@ template:
{% set raw_watts = states('sensor.water_heater_power') | float(0) %} {% set raw_watts = states('sensor.water_heater_power') | float(0) %}
{{ (raw_watts / 1000) | round(2) }} {{ (raw_watts / 1000) | round(2) }}
# State-based Template Sensors (Blink Temperature Dropout Filtering for all 4 cameras)
- sensor:
- name: "Blink Front Temperature Clean"
unique_id: blink_front_temperature_clean
unit_of_measurement: "°C"
device_class: temperature
state_class: measurement
state: >
{% set raw = states('sensor.blink_front_temperature') | float(default=-999) %}
{% if raw > -40 and raw < 60 %}
{{ raw }}
{% else %}
{{ states('sensor.blink_front_temperature_clean') }}
{% endif %}
- name: "Blink Backyard Shed Temperature Clean"
unique_id: blink_backyard_shed_temperature_clean
unit_of_measurement: "°C"
device_class: temperature
state_class: measurement
state: >
{% set raw = states('sensor.blink_backyard_shed_temperature') | float(default=-999) %}
{% if raw > -40 and raw < 60 %}
{{ raw }}
{% else %}
{{ states('sensor.blink_backyard_shed_temperature_clean') }}
{% endif %}
- name: "Blink Backyard Tree Temperature Clean"
unique_id: blink_backyard_tree_temperature_clean
unit_of_measurement: "°C"
device_class: temperature
state_class: measurement
state: >
{% set raw = states('sensor.blink_backyard_tree_temperature') | float(default=-999) %}
{% if raw > -40 and raw < 60 %}
{{ raw }}
{% else %}
{{ states('sensor.blink_backyard_tree_temperature_clean') }}
{% endif %}
- name: "Blink Backyard Temperature Clean"
unique_id: blink_backyard_temperature_clean
unit_of_measurement: "°C"
device_class: temperature
state_class: measurement
state: >
{% set raw = states('sensor.blink_backyard_temperature') | float(default=-999) %}
{% if raw > -40 and raw < 60 %}
{{ raw }}
{% else %}
{{ states('sensor.blink_backyard_temperature_clean') }}
{% endif %}
# HTTP Proxy Configuration # HTTP Proxy Configuration
http: http:
use_x_forwarded_for: true use_x_forwarded_for: true
trusted_proxies: trusted_proxies:
- 192.168.122.1 - 192.168.122.1
- 192.168.1.130 - 192.168.1.0/24
- 192.168.1.125
- 192.168.1.135
# Legacy and Integration Sensor Section # Legacy and Integration Sensor Section
sensor: sensor:
@@ -146,7 +204,7 @@ emulated_hue:
host_ip: 192.168.1.140 host_ip: 192.168.1.140
advertise_ip: 192.168.1.140 advertise_ip: 192.168.1.140
listen_port: 80 listen_port: 80
expose_by_default: false expose_by_default: true
entities: entities:
input_boolean.cync_motion_bridge: input_boolean.cync_motion_bridge:
name: "Cync Motion Bridge" name: "Cync Motion Bridge"
+426
View File
@@ -0,0 +1,426 @@
"""The AI Agent HA integration."""
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant.components.frontend import async_register_built_in_panel
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .agent import AiAgentHaAgent
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# Config schema - this integration only supports config entries
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
# Define service schema to accept a custom prompt
SERVICE_SCHEMA = vol.Schema(
{
vol.Optional("prompt"): cv.string,
}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the AI Agent HA component."""
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old config entries to new version."""
_LOGGER.debug("Migrating config entry from version %s", entry.version)
if entry.version == 1:
# No migration needed for version 1
return True
# Future migrations would go here
# if entry.version < 2:
# # Migrate from version 1 to 2
# new_data = dict(entry.data)
# # Add migration logic here
# hass.config_entries.async_update_entry(entry, data=new_data, version=2)
_LOGGER.info("Migration to version %s successful", entry.version)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up AI Agent HA from a config entry."""
try:
# Handle version compatibility
if not hasattr(entry, "version") or entry.version != 1:
_LOGGER.warning(
"Config entry has version %s, expected 1. Attempting compatibility mode.",
getattr(entry, "version", "unknown"),
)
# Convert ConfigEntry to dict and ensure all required keys exist
config_data = dict(entry.data)
# Ensure backward compatibility - check for required keys
if "ai_provider" not in config_data:
_LOGGER.error(
"Config entry missing required 'ai_provider' key. Entry data: %s",
config_data,
)
raise ConfigEntryNotReady("Config entry missing required 'ai_provider' key")
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {"agents": {}, "configs": {}}
provider = config_data["ai_provider"]
# Validate provider
if provider not in [
"llama",
"openai",
"gemini",
"openrouter",
"anthropic",
"alter",
"zai",
"local_ollama",
"openai_compatible",
]:
_LOGGER.error("Unknown AI provider: %s", provider)
raise ConfigEntryNotReady(f"Unknown AI provider: {provider}")
# Store config for this provider
hass.data[DOMAIN]["configs"][provider] = config_data
# Create agent for this provider
_LOGGER.debug(
"Creating AI agent for provider %s with config: %s",
provider,
{
k: v
for k, v in config_data.items()
if k
not in [
"llama_token",
"openai_token",
"gemini_token",
"openrouter_token",
"anthropic_token",
"zai_token",
]
},
)
hass.data[DOMAIN]["agents"][provider] = AiAgentHaAgent(hass, config_data)
_LOGGER.info("Successfully set up AI Agent HA for provider: %s", provider)
except KeyError as err:
_LOGGER.error("Missing required configuration key: %s", err)
raise ConfigEntryNotReady(f"Missing required configuration key: {err}")
except Exception as err:
_LOGGER.exception("Unexpected error setting up AI Agent HA")
raise ConfigEntryNotReady(f"Error setting up AI Agent HA: {err}")
# Modify the query service handler to use the correct provider
async def async_handle_query(call):
"""Handle the query service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
result = {"error": "No AI agents configured"}
hass.bus.async_fire("ai_agent_ha_response", result)
return
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
result = {"error": "No AI agents configured"}
hass.bus.async_fire("ai_agent_ha_response", result)
return
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
result = await agent.process_query(
call.data.get("prompt", ""),
provider=provider,
debug=call.data.get("debug", False),
)
hass.bus.async_fire("ai_agent_ha_response", result)
except Exception as e:
_LOGGER.error(f"Error processing query: {e}")
result = {"error": str(e)}
hass.bus.async_fire("ai_agent_ha_response", result)
async def async_handle_create_automation(call):
"""Handle the create_automation service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
result = await agent.create_automation(call.data.get("automation", {}))
return result
except Exception as e:
_LOGGER.error(f"Error creating automation: {e}")
return {"error": str(e)}
async def async_handle_save_prompt_history(call):
"""Handle the save_prompt_history service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
user_id = call.context.user_id if call.context.user_id else "default"
result = await agent.save_user_prompt_history(
user_id, call.data.get("history", [])
)
return result
except Exception as e:
_LOGGER.error(f"Error saving prompt history: {e}")
return {"error": str(e)}
async def async_handle_load_prompt_history(call):
"""Handle the load_prompt_history service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
user_id = call.context.user_id if call.context.user_id else "default"
result = await agent.load_user_prompt_history(user_id)
_LOGGER.debug("Load prompt history result: %s", result)
return result
except Exception as e:
_LOGGER.error(f"Error loading prompt history: {e}")
return {"error": str(e)}
async def async_handle_create_dashboard(call):
"""Handle the create_dashboard service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
# Parse dashboard config if it's a string
dashboard_config = call.data.get("dashboard_config", {})
if isinstance(dashboard_config, str):
try:
import json
dashboard_config = json.loads(dashboard_config)
except json.JSONDecodeError as e:
_LOGGER.error(f"Invalid JSON in dashboard_config: {e}")
return {"error": f"Invalid JSON in dashboard_config: {e}"}
result = await agent.create_dashboard(dashboard_config)
return result
except Exception as e:
_LOGGER.error(f"Error creating dashboard: {e}")
return {"error": str(e)}
async def async_handle_update_dashboard(call):
"""Handle the update_dashboard service call."""
try:
# Check if agents are available
if DOMAIN not in hass.data or not hass.data[DOMAIN].get("agents"):
_LOGGER.error(
"No AI agents available. Please configure the integration first."
)
return {"error": "No AI agents configured"}
provider = call.data.get("provider")
if provider not in hass.data[DOMAIN]["agents"]:
# Get the first available provider
available_providers = list(hass.data[DOMAIN]["agents"].keys())
if not available_providers:
_LOGGER.error("No AI agents available")
return {"error": "No AI agents configured"}
provider = available_providers[0]
_LOGGER.debug(f"Using fallback provider: {provider}")
agent = hass.data[DOMAIN]["agents"][provider]
# Parse dashboard config if it's a string
dashboard_config = call.data.get("dashboard_config", {})
if isinstance(dashboard_config, str):
try:
import json
dashboard_config = json.loads(dashboard_config)
except json.JSONDecodeError as e:
_LOGGER.error(f"Invalid JSON in dashboard_config: {e}")
return {"error": f"Invalid JSON in dashboard_config: {e}"}
dashboard_url = call.data.get("dashboard_url", "")
if not dashboard_url:
return {"error": "Dashboard URL is required"}
result = await agent.update_dashboard(dashboard_url, dashboard_config)
return result
except Exception as e:
_LOGGER.error(f"Error updating dashboard: {e}")
return {"error": str(e)}
# Register services
hass.services.async_register(DOMAIN, "query", async_handle_query)
hass.services.async_register(
DOMAIN, "create_automation", async_handle_create_automation
)
hass.services.async_register(
DOMAIN, "save_prompt_history", async_handle_save_prompt_history
)
hass.services.async_register(
DOMAIN, "load_prompt_history", async_handle_load_prompt_history
)
hass.services.async_register(
DOMAIN, "create_dashboard", async_handle_create_dashboard
)
hass.services.async_register(
DOMAIN, "update_dashboard", async_handle_update_dashboard
)
# Register static path for frontend
await hass.http.async_register_static_paths(
[
StaticPathConfig(
"/frontend/ai_agent_ha",
hass.config.path("custom_components/ai_agent_ha/frontend"),
False,
)
]
)
# Panel registration with proper error handling
panel_name = "ai_agent_ha"
try:
if await _panel_exists(hass, panel_name):
_LOGGER.debug("AI Agent HA panel already exists, skipping registration")
return True
_LOGGER.debug("Registering AI Agent HA panel")
async_register_built_in_panel(
hass,
component_name="custom",
sidebar_title="AI Agent HA",
sidebar_icon="mdi:robot",
frontend_url_path=panel_name,
require_admin=False,
config={
"_panel_custom": {
"name": "ai_agent_ha-panel",
"module_url": "/frontend/ai_agent_ha/ai_agent_ha-panel.js",
"embed_iframe": False,
}
},
)
_LOGGER.debug("AI Agent HA panel registered successfully")
except Exception as e:
_LOGGER.warning("Panel registration error: %s", str(e))
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if await _panel_exists(hass, "ai_agent_ha"):
try:
from homeassistant.components.frontend import async_remove_panel
async_remove_panel(hass, "ai_agent_ha")
_LOGGER.debug("AI Agent HA panel removed successfully")
except Exception as e:
_LOGGER.debug("Error removing panel: %s", str(e))
# Remove services
hass.services.async_remove(DOMAIN, "query")
hass.services.async_remove(DOMAIN, "create_automation")
hass.services.async_remove(DOMAIN, "save_prompt_history")
hass.services.async_remove(DOMAIN, "load_prompt_history")
hass.services.async_remove(DOMAIN, "create_dashboard")
hass.services.async_remove(DOMAIN, "update_dashboard")
# Remove data
if DOMAIN in hass.data:
hass.data.pop(DOMAIN)
return True
async def _panel_exists(hass: HomeAssistant, panel_name: str) -> bool:
"""Check if a panel already exists."""
try:
return hasattr(hass.data, "frontend_panels") and panel_name in hass.data.get(
"frontend_panels", {}
)
except Exception as e:
_LOGGER.debug("Error checking panel existence: %s", str(e))
return False
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,917 @@
"""Config flow for AI Agent HA integration."""
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
TextSelector,
TextSelectorConfig,
)
from .agent import (
fetch_gemini_models,
fetch_openai_compatible_models,
fetch_openai_models,
)
from .const import (
CONF_LOCAL_OLLAMA_URL,
CONF_OPENAI_BASE_URL,
CONF_OPENAI_COMPATIBLE_URL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
PROVIDERS = {
"llama": "Llama",
"openai": "OpenAI",
"gemini": "Google Gemini",
"openrouter": "OpenRouter",
"anthropic": "Anthropic (Claude)",
"alter": "Alter",
"zai": "z.ai",
"local_ollama": "Local Ollama",
"openai_compatible": "Local OpenAI-Compatible (e.g. LM Studio, vLLM)",
}
TOKEN_FIELD_NAMES = {
"llama": "llama_token",
"openai": "openai_token",
"gemini": "gemini_token",
"openrouter": "openrouter_token",
"anthropic": "anthropic_token",
"alter": "alter_token",
"zai": "zai_token",
"zai_endpoint": "zai_endpoint",
"local_ollama": CONF_LOCAL_OLLAMA_URL, # For local Ollama models, we use URL instead of token
"openai_compatible": CONF_OPENAI_COMPATIBLE_URL, # For OpenAI-compatible endpoints
}
TOKEN_LABELS = {
"llama": "Llama API Token",
"openai": "OpenAI API Key",
"gemini": "Google Gemini API Key",
"openrouter": "OpenRouter API Key",
"anthropic": "Anthropic API Key",
"alter": "Alter API Key",
"zai": "z.ai API Key",
"zai_endpoint": "z.ai API Endpoint Type",
"local_ollama": "Local Ollama API URL (e.g., http://localhost:11434/api/generate)",
"openai_compatible": "OpenAI-Compatible URL (e.g., http://example.com/v1/ or http://localhost:8080/v1/). Must end with /v1/",
}
DEFAULT_MODELS = {
"llama": "Llama-4-Maverick-17B-128E-Instruct-FP8",
"openai": "gpt-5",
"gemini": "gemini-2.5-flash",
"openrouter": "openai/gpt-4o",
"anthropic": "claude-sonnet-4-5-20250929",
"alter": "", # User enters custom model
"zai": "glm-4.7", # Z.ai's latest flagship model
"local_ollama": "llama3.2", # Updated to use llama3.2 as default for local Ollama
"openai_compatible": "", # User enters custom model for OpenAI-compatible endpoint
}
AVAILABLE_MODELS = {
"openai": [
"gpt-5",
"gpt-5-mini",
"gpt-5-nano",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"o3",
"o3-mini",
"o4-mini",
"o1",
"o1-preview",
"o1-mini",
"gpt-4-turbo",
"gpt-4",
"gpt-3.5-turbo",
],
"gemini": [
"gemini-2.5-flash",
"gemini-2.5-pro",
"gemini-2.0-flash",
"gemini-2.0-flash-exp",
"gemini-2.5-flash-preview",
"gemini-2.5-pro-preview",
],
"openrouter": [
"openai/gpt-4o",
"openai/gpt-4-turbo",
"openai/gpt-3.5-turbo",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3-sonnet",
"anthropic/claude-3-haiku",
"meta-llama/llama-3.1-70b-instruct",
"meta-llama/llama-3.2-90b-instruct",
"google/gemini-pro",
"mistralai/mixtral-8x7b-instruct",
"deepseek/deepseek-r1",
],
"anthropic": [
"claude-opus-4-7",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"claude-sonnet-4-5-20250929",
"claude-sonnet-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
],
"llama": [
"Llama-4-Maverick-17B-128E-Instruct-FP8",
"Llama-3.1-70B-Instruct",
"Llama-3.1-8B-Instruct",
"Llama-3.2-90B-Instruct",
],
# Alter - user enters custom model name only
"alter": [
"Custom...",
],
# z.ai - available models
"zai": [
"glm-4.7",
"glm-4.6",
"glm-4.5",
"glm-4.5-air",
"glm-4.5-x",
"glm-4.5-airx",
"glm-4.5-flash",
"glm-4-32b-0414-128k",
"Custom...",
],
# For local Ollama models, provide common models with llama3.2 as the default
"local_ollama": [
"llama3.2",
"llama3",
"llama3.1",
"mistral",
"mixtral",
"deepseek-coder",
"Custom...",
],
# For OpenAI-compatible endpoints, user should specify their model
"openai_compatible": [
"Custom...",
],
}
DEFAULT_PROVIDER = "openai"
class AiAgentHaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg,misc]
"""Handle a config flow for AI Agent HA."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
try:
return AiAgentHaOptionsFlowHandler()
except Exception as e:
_LOGGER.error("Error creating options flow: %s", e)
return None
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
# Check if this provider is already configured
await self.async_set_unique_id(f"ai_agent_ha_{user_input['ai_provider']}")
self._abort_if_unique_id_configured()
self.config_data = {"ai_provider": user_input["ai_provider"]}
return await self.async_step_configure()
# Show provider selection form
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("ai_provider"): SelectSelector(
SelectSelectorConfig(
options=[
{"value": k, "label": v} for k, v in PROVIDERS.items()
]
)
),
}
),
)
async def async_step_configure(self, user_input=None):
"""Handle the configuration step for the selected provider."""
errors = {}
provider = self.config_data["ai_provider"]
token_field = TOKEN_FIELD_NAMES[provider]
token_label = TOKEN_LABELS[provider]
default_model = DEFAULT_MODELS[provider]
# For Alter provider, default to "Custom..." for the dropdown since model is user-provided
dropdown_default = "Custom..." if provider == "alter" else default_model
available_models = AVAILABLE_MODELS.get(provider, [default_model])
if user_input is not None:
try:
# Validate the token
token_value = user_input.get(token_field)
if not token_value:
errors[token_field] = "required"
raise InvalidApiKey
# Store the configuration data
self.config_data[token_field] = token_value
# For z.ai, store endpoint type
if provider == "zai":
endpoint_type = user_input.get("zai_endpoint", "general")
self.config_data["zai_endpoint"] = endpoint_type
# For OpenAI, store Base URL (defaults to official endpoint if unchanged)
if provider == "openai":
base_url = (user_input.get(CONF_OPENAI_BASE_URL) or "").strip()
self.config_data[CONF_OPENAI_BASE_URL] = (
base_url or "https://api.openai.com/v1"
)
# For OpenAI, move to next step to select model from dynamic list
return await self.async_step_configure_openai_models()
# For OpenAI-Compatible, store Base URL + optional API key, then move on
if provider == "openai_compatible":
base_url = (
user_input.get(CONF_OPENAI_COMPATIBLE_URL) or ""
).strip()
self.config_data[CONF_OPENAI_COMPATIBLE_URL] = base_url
api_key = (
user_input.get("openai_compatible_api_key") or ""
).strip()
self.config_data["openai_compatible_api_key"] = api_key
# Move to next step to select model from dynamic list
return await self.async_step_configure_openai_compatible_models()
# Add model configuration if provided
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
_LOGGER.debug(
f"Config flow - Provider: {provider}, Selected model: {selected_model}, Custom model: {custom_model}"
)
# Initialize models dict if it doesn't exist
if "models" not in self.config_data:
self.config_data["models"] = {}
if custom_model and custom_model.strip():
# Use custom model if provided and not empty
self.config_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
# Use selected model if it's not the "Custom..." option
self.config_data["models"][provider] = selected_model
else:
# For local_ollama, openai_compatible, alter, and zai providers, allow empty model name
if provider in (
"local_ollama",
"openai_compatible",
"alter",
"zai",
):
self.config_data["models"][provider] = ""
else:
# Fallback to default model for other providers
self.config_data["models"][provider] = default_model
return self.async_create_entry(
title=f"AI Agent HA ({PROVIDERS[provider]})",
data=self.config_data,
)
except InvalidApiKey:
errors["base"] = "invalid_api_key"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if provider == "zai":
# For z.ai provider, we need token, endpoint type, and optional model name
model_options = AVAILABLE_MODELS.get("zai", ["Custom..."])
schema_dict = {
vol.Required(token_field): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional("zai_endpoint", default="general"): SelectSelector(
SelectSelectorConfig(
options=[
{"value": "general", "label": "General Purpose"},
{"value": "coding", "label": "Coding (3× usage, 1/7 cost)"},
]
)
),
vol.Optional("model", default="glm-4.7"): SelectSelector(
SelectSelectorConfig(options=model_options)
),
vol.Optional("custom_model"): TextSelector(
TextSelectorConfig(type="text")
),
}
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
if provider == "local_ollama":
# For local_ollama provider, we need both URL and optional model name
schema_dict = {
vol.Required(CONF_LOCAL_OLLAMA_URL): TextSelector(
TextSelectorConfig(type="text")
),
}
# Add model selection
model_options = AVAILABLE_MODELS.get("local_ollama", ["Custom..."])
schema_dict[vol.Optional("model", default="Custom...")] = SelectSelector(
SelectSelectorConfig(options=model_options)
)
schema_dict[vol.Optional("custom_model")] = TextSelector(
TextSelectorConfig(type="text")
)
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local Ollama API URL", # nosec B105 - UI label string shown next to the URL field, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai_compatible":
# For openai_compatible provider, we need base URL + optional API key.
# Many local endpoints (LM Studio, vLLM) need no key; gateways like
# Open WebUI or LiteLLM require one. Models are fetched in the next step.
schema_dict = {
vol.Required(CONF_OPENAI_COMPATIBLE_URL): TextSelector(
TextSelectorConfig(type="text")
),
vol.Optional("openai_compatible_api_key", default=""): TextSelector(
TextSelectorConfig(type="password")
),
}
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local OpenAI-Compatible URL", # nosec B105 - UI label for config form, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai":
# For OpenAI provider, first step: API Key + Base URL
# Model selection happens in the next step after we fetch available models
schema_dict = {
vol.Required(token_field): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional(
CONF_OPENAI_BASE_URL,
default="https://api.openai.com/v1",
): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
# Build schema for other providers
schema_dict = {
vol.Required(token_field): TextSelector(
TextSelectorConfig(type="password")
),
}
# Add model selection if available
if available_models:
# For Gemini, fetch models dynamically
if provider == "gemini":
token_value = self.config_data.get("gemini_token")
model_list = await fetch_gemini_models(token_value)
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
model_options = model_list
else:
# Add predefined models + custom option (avoid duplicating "Custom...")
if "Custom..." in available_models:
model_options = available_models
else:
model_options = available_models + ["Custom..."]
schema_dict[vol.Optional("model", default=dropdown_default)] = (
SelectSelector(SelectSelectorConfig(options=model_options))
)
schema_dict[vol.Optional("custom_model")] = TextSelector(
TextSelectorConfig(type="text")
)
return self.async_show_form(
step_id="configure",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
async def async_step_configure_openai_models(self, user_input=None):
"""Handle the OpenAI model selection step with dynamic model list."""
errors = {}
provider = "openai"
token = self.config_data.get("openai_token")
base_url = self.config_data.get(
CONF_OPENAI_BASE_URL, "https://api.openai.com/v1"
)
# Fetch available models dynamically
model_list = await fetch_openai_models(base_url, token)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
if user_input is not None:
try:
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
# Initialize models dict if it doesn't exist
if "models" not in self.config_data:
self.config_data["models"] = {}
if custom_model and custom_model.strip():
self.config_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
self.config_data["models"][provider] = selected_model
else:
self.config_data["models"][provider] = ""
return self.async_create_entry(
title=f"AI Agent HA ({PROVIDERS[provider]})",
data=self.config_data,
)
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception in OpenAI model selection")
errors["base"] = "unknown"
schema_dict = {
vol.Optional("model", default="Custom..."): SelectSelector(
SelectSelectorConfig(options=model_list)
),
vol.Optional("custom_model"): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure_openai_models",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"provider": PROVIDERS[provider],
},
)
async def async_step_configure_openai_compatible_models(self, user_input=None):
"""Handle the OpenAI-Compatible model selection step with dynamic model list."""
errors = {}
provider = "openai_compatible"
base_url = self.config_data.get(CONF_OPENAI_COMPATIBLE_URL, "")
api_key = self.config_data.get("openai_compatible_api_key") or ""
# Fetch available models dynamically if the endpoint supports it
model_list = await fetch_openai_compatible_models(base_url, api_key or None)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
if user_input is not None:
try:
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
# Initialize models dict if it doesn't exist
if "models" not in self.config_data:
self.config_data["models"] = {}
if custom_model and custom_model.strip():
self.config_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
self.config_data["models"][provider] = selected_model
else:
self.config_data["models"][provider] = ""
return self.async_create_entry(
title=f"AI Agent HA ({PROVIDERS[provider]})",
data=self.config_data,
)
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Unexpected exception in OpenAI-Compatible model selection"
)
errors["base"] = "unknown"
schema_dict = {
vol.Optional("model", default="Custom..."): SelectSelector(
SelectSelectorConfig(options=model_list)
),
vol.Optional("custom_model"): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure_openai_compatible_models",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"provider": PROVIDERS[provider],
},
)
class InvalidApiKey(HomeAssistantError):
"""Error to indicate there is an invalid API key."""
class AiAgentHaOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow for AI Agent HA."""
def __init__(self):
"""Initialize options flow."""
self.options_data = {}
async def async_step_init(self, user_input=None):
"""Handle the initial options step - provider selection."""
current_provider = self.config_entry.data.get("ai_provider", DEFAULT_PROVIDER)
if user_input is not None:
# Store selected provider and move to configure step
self.options_data = {
"ai_provider": user_input["ai_provider"],
"current_provider": current_provider,
}
return await self.async_step_configure_options()
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
"ai_provider", default=current_provider
): SelectSelector(
SelectSelectorConfig(
options=[
{"value": k, "label": v} for k, v in PROVIDERS.items()
]
)
),
}
),
description_placeholders={"current_provider": PROVIDERS[current_provider]},
)
async def async_step_configure_options(self, user_input=None):
"""Handle the configuration step for the selected provider in options."""
errors = {}
provider = self.options_data["ai_provider"]
current_provider = self.options_data["current_provider"]
token_field = TOKEN_FIELD_NAMES[provider]
token_label = TOKEN_LABELS[provider]
# Get current configuration
current_models = self.config_entry.data.get("models", {})
current_model = current_models.get(provider, DEFAULT_MODELS[provider])
# For Alter provider, if model is empty, default to "Custom..." for the dropdown
if provider == "alter" and not current_model:
current_model = "Custom..."
current_token = self.config_entry.data.get(token_field, "")
available_models = AVAILABLE_MODELS.get(provider, [DEFAULT_MODELS[provider]])
# Use current token if provider hasn't changed, otherwise empty
display_token = current_token if provider == current_provider else ""
# Determine if current model is a custom model (not in available models list)
# and prepare model dropdown and custom model field defaults
model_options = available_models
if "Custom..." not in model_options:
model_options = model_options + ["Custom..."]
# Check if current_model is a custom model (not in the available models)
# Remove "Custom..." from the check since it's the selector option, not a real model
available_models_without_custom = [
m for m in available_models if m != "Custom..."
]
is_custom_model = (
current_model
and current_model not in available_models_without_custom
and current_model != "Custom..."
)
if is_custom_model:
# Current model is a custom model - show "Custom..." in dropdown and populate custom field
model_default = "Custom..."
custom_model_default = current_model
else:
# Current model is a standard model or empty
model_default = current_model if current_model else "Custom..."
custom_model_default = ""
if user_input is not None:
try:
token_value = user_input.get(token_field)
if not token_value:
errors[token_field] = "required"
else:
# Prepare the updated configuration
updated_data = dict(self.config_entry.data)
updated_data["ai_provider"] = provider
updated_data[token_field] = token_value
# Update model configuration
selected_model = user_input.get("model")
custom_model = user_input.get("custom_model")
# For zai, update endpoint type
if provider == "zai":
endpoint_type = user_input.get("zai_endpoint", "general")
updated_data["zai_endpoint"] = endpoint_type
# For OpenAI, update Base URL (default to official if blank)
if provider == "openai":
base_url = (user_input.get(CONF_OPENAI_BASE_URL) or "").strip()
updated_data[CONF_OPENAI_BASE_URL] = (
base_url or "https://api.openai.com/v1"
)
# For OpenAI-Compatible, update the optional API key
if provider == "openai_compatible":
updated_data["openai_compatible_api_key"] = (
user_input.get("openai_compatible_api_key") or ""
).strip()
# Initialize models dict if it doesn't exist
if "models" not in updated_data:
updated_data["models"] = {}
if custom_model and custom_model.strip():
# Use custom model if provided and not empty
updated_data["models"][provider] = custom_model.strip()
elif selected_model and selected_model != "Custom...":
# Use selected model if it's not the "Custom..." option
updated_data["models"][provider] = selected_model
else:
# For local_ollama, openai_compatible, alter, and zai providers, allow empty model name
if provider in (
"local_ollama",
"openai_compatible",
"alter",
"zai",
):
updated_data["models"][provider] = ""
else:
# Ensure we keep the current model or use default for other providers
if provider not in updated_data["models"]:
updated_data["models"][provider] = DEFAULT_MODELS[
provider
]
_LOGGER.debug(
f"Options flow - Final model config for {provider}: {updated_data['models'].get(provider)}"
)
# Update the config entry
self.hass.config_entries.async_update_entry(
self.config_entry, data=updated_data
)
return self.async_create_entry(title="", data={})
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception in options flow")
errors["base"] = "unknown"
# Build schema for the selected provider in options
if provider == "zai":
current_endpoint = self.config_entry.data.get("zai_endpoint", "general")
model_options = AVAILABLE_MODELS.get("zai", ["glm-4.7"])
# Ensure "Custom..." is in model options
if "Custom..." not in model_options:
model_options = model_options + ["Custom..."]
schema_dict = {
vol.Required(token_field, default=display_token): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional("zai_endpoint", default=current_endpoint): SelectSelector(
SelectSelectorConfig(
options=[
{"value": "general", "label": "General Purpose"},
{"value": "coding", "label": "Coding (3× usage, 1/7 cost)"},
]
)
),
vol.Optional("model", default=model_default): SelectSelector(
SelectSelectorConfig(options=model_options)
),
vol.Optional(
"custom_model", default=custom_model_default
): TextSelector(TextSelectorConfig(type="text")),
}
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
if provider == "local_ollama":
# For local_ollama provider, we need both URL and optional model name
current_url = self.config_entry.data.get(CONF_LOCAL_OLLAMA_URL, "")
schema_dict = {
vol.Required(CONF_LOCAL_OLLAMA_URL, default=current_url): TextSelector(
TextSelectorConfig(type="text")
),
}
# Add model selection
model_options = AVAILABLE_MODELS.get("local_ollama", ["Custom..."])
# Ensure "Custom..." is in model options
if "Custom..." not in model_options:
model_options = model_options + ["Custom..."]
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_options)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local Ollama API URL", # nosec B105 - UI label string shown next to the URL field, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai_compatible":
# For openai_compatible provider, we need URL + optional API key + model
current_url = self.config_entry.data.get(CONF_OPENAI_COMPATIBLE_URL, "")
current_api_key = (
self.config_entry.data.get("openai_compatible_api_key") or ""
)
# Fetch available models dynamically if the endpoint supports it
model_list = await fetch_openai_compatible_models(
current_url, current_api_key or None
)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
schema_dict = {
vol.Required(
CONF_OPENAI_COMPATIBLE_URL, default=current_url
): TextSelector(TextSelectorConfig(type="text")),
vol.Optional(
"openai_compatible_api_key", default=current_api_key
): TextSelector(TextSelectorConfig(type="password")),
}
# Add model selection with dynamic list
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_list)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": "Local OpenAI-Compatible URL", # nosec B105 - UI label for config form, not a credential
"provider": PROVIDERS[provider],
},
)
if provider == "openai":
# For OpenAI provider, we need token and optional Base URL
# Pre-fill with official endpoint if not set
current_base_url = (
self.config_entry.data.get(CONF_OPENAI_BASE_URL)
or "https://api.openai.com/v1"
)
# Fetch available models dynamically
current_token = self.config_entry.data.get("openai_token", "")
model_list = await fetch_openai_models(current_base_url, current_token)
# Ensure "Custom..." is always available
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
schema_dict = {
vol.Required(token_field, default=display_token): TextSelector(
TextSelectorConfig(type="password")
),
vol.Optional(
CONF_OPENAI_BASE_URL, default=current_base_url
): TextSelector(TextSelectorConfig(type="text")),
}
# Add model selection with dynamic list
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_list)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
# Build schema for other providers
schema_dict = {
vol.Required(token_field, default=display_token): TextSelector(
TextSelectorConfig(type="password")
),
}
# Add model selection if available
if available_models:
# For Gemini, fetch models dynamically
if provider == "gemini":
current_token = self.config_entry.data.get("gemini_token", "")
model_list = await fetch_gemini_models(current_token)
if "Custom..." not in model_list:
model_list.insert(0, "Custom...")
model_options = model_list
# model_options already has "Custom..." added above for other providers
schema_dict[vol.Optional("model", default=model_default)] = SelectSelector(
SelectSelectorConfig(options=model_options)
)
schema_dict[vol.Optional("custom_model", default=custom_model_default)] = (
TextSelector(TextSelectorConfig(type="text"))
)
return self.async_show_form(
step_id="configure_options",
data_schema=vol.Schema(schema_dict),
errors=errors,
description_placeholders={
"token_label": token_label,
"provider": PROVIDERS[provider],
},
)
+40
View File
@@ -0,0 +1,40 @@
"""Constants for the AI Agent HA integration."""
DOMAIN = "ai_agent_ha"
CONF_API_KEY = "api_key"
CONF_WEATHER_ENTITY = "weather_entity"
# AI Provider configuration keys
CONF_LLAMA_TOKEN = "llama_token" # nosec B105
CONF_OPENAI_TOKEN = "openai_token" # nosec B105
CONF_OPENAI_BASE_URL = (
"openai_base_url" # nosec B105 - configuration key, not a credential
)
CONF_GEMINI_TOKEN = "gemini_token" # nosec B105
CONF_OPENROUTER_TOKEN = "openrouter_token" # nosec B105
CONF_ANTHROPIC_TOKEN = "anthropic_token" # nosec B105
CONF_ALTER_TOKEN = "alter_token" # nosec B105
CONF_ZAI_TOKEN = "zai_token" # nosec B105
CONF_LOCAL_OLLAMA_URL = "local_ollama_url"
CONF_LOCAL_OLLAMA_MODEL = "local_ollama_model"
CONF_OPENAI_COMPATIBLE_URL = "openai_compatible_url"
CONF_LOCAL_URL = "local_url" # legacy alias for local_ollama_url
# Available AI providers
AI_PROVIDERS = [
"llama",
"openai",
"gemini",
"openrouter",
"anthropic",
"alter",
"zai",
"local_ollama",
"openai_compatible",
]
# AI Provider constants
CONF_MODELS = "models"
# Supported AI providers
DEFAULT_AI_PROVIDER = "openai"
@@ -0,0 +1,390 @@
"""
Dashboard templates and examples for AI agent to use when creating dashboards.
"""
# Basic dashboard templates for different use cases
DASHBOARD_TEMPLATES = {
"simple_lights": {
"title": "Lights Dashboard",
"url_path": "lights",
"icon": "mdi:lightbulb",
"show_in_sidebar": True,
"views": [
{
"title": "All Lights",
"cards": [
{
"type": "entities",
"title": "Living Room Lights",
"entities": [], # To be filled with actual light entities
},
{
"type": "light",
"entity": "", # To be filled with main light entity
},
],
}
],
},
"security": {
"title": "Security Dashboard",
"url_path": "security",
"icon": "mdi:security",
"show_in_sidebar": True,
"views": [
{
"title": "Security Overview",
"cards": [
{
"type": "entities",
"title": "Sensors",
"entities": [], # To be filled with sensor entities
},
{
"type": "entities",
"title": "Cameras",
"entities": [], # To be filled with camera entities
},
{
"type": "alarm-panel",
"entity": "", # To be filled with alarm panel entity
},
],
}
],
},
"climate": {
"title": "Climate Control",
"url_path": "climate",
"icon": "mdi:thermometer",
"show_in_sidebar": True,
"views": [
{
"title": "Temperature & Humidity",
"cards": [
{
"type": "thermostat",
"entity": "", # To be filled with climate.* thermostat entity (optional)
},
{
"type": "history-graph",
"title": "Temperature History",
"entities": [], # To be filled with temperature sensor entities
"hours_to_show": 24,
},
{
"type": "entities",
"title": "Temperature Sensors",
"entities": [], # To be filled with temperature sensor entities (device_class: temperature)
},
{
"type": "history-graph",
"title": "Humidity History",
"entities": [], # To be filled with humidity sensor entities
"hours_to_show": 24,
},
{
"type": "entities",
"title": "Humidity Sensors",
"entities": [], # To be filled with humidity sensor entities (device_class: humidity)
},
{
"type": "weather-forecast",
"entity": "", # To be filled with weather entity (optional)
},
],
}
],
},
"media": {
"title": "Media Center",
"url_path": "media",
"icon": "mdi:play",
"show_in_sidebar": True,
"views": [
{
"title": "Media Players",
"cards": [
{
"type": "media-control",
"entity": "", # To be filled with media player entity
},
{
"type": "entities",
"title": "All Media Players",
"entities": [], # To be filled with media player entities
},
],
}
],
},
"energy": {
"title": "Energy Monitoring",
"url_path": "energy",
"icon": "mdi:lightning-bolt",
"show_in_sidebar": True,
"views": [
{
"title": "Energy Usage",
"cards": [
{"type": "energy-distribution", "title": "Energy Distribution"},
{
"type": "entities",
"title": "Power Sensors",
"entities": [], # To be filled with power sensor entities
},
{
"type": "history-graph",
"title": "Power Usage",
"entities": [], # To be filled with power entities
},
],
}
],
},
}
# Card type examples and their typical use cases
CARD_EXAMPLES = {
"entities": {
"description": "Shows a list of entities with their states",
"example": {
"type": "entities",
"title": "Living Room",
"entities": [
"light.living_room_main",
"switch.living_room_fan",
"sensor.living_room_temperature",
],
},
},
"glance": {
"description": "Shows entities in a compact grid format",
"example": {
"type": "glance",
"title": "Quick Overview",
"entities": [
"binary_sensor.front_door",
"binary_sensor.back_door",
"binary_sensor.garage_door",
],
},
},
"thermostat": {
"description": "Controls and displays thermostat information",
"example": {"type": "thermostat", "entity": "climate.main_thermostat"},
},
"weather-forecast": {
"description": "Shows weather information and forecast",
"example": {
"type": "weather-forecast",
"entity": "weather.home",
"name": "Weather",
},
},
"media-control": {
"description": "Controls media players with full interface",
"example": {"type": "media-control", "entity": "media_player.living_room_tv"},
},
"light": {
"description": "Dedicated light control card",
"example": {"type": "light", "entity": "light.living_room_main"},
},
"alarm-panel": {
"description": "Security alarm panel interface",
"example": {"type": "alarm-panel", "entity": "alarm_control_panel.home_alarm"},
},
"picture-entity": {
"description": "Shows entity state with a background image",
"example": {
"type": "picture-entity",
"entity": "light.living_room",
"image": "/local/living_room.jpg",
},
},
"history-graph": {
"description": "Shows historical data as a graph",
"example": {
"type": "history-graph",
"title": "Temperature History",
"entities": ["sensor.temperature_indoor", "sensor.temperature_outdoor"],
"hours_to_show": 24,
},
},
"gauge": {
"description": "Shows a single entity value as a gauge",
"example": {
"type": "gauge",
"entity": "sensor.cpu_temperature",
"min": 0,
"max": 100,
"name": "CPU Temperature",
},
},
}
# Predefined color schemes and icons
COMMON_ICONS = {
"lights": "mdi:lightbulb",
"security": "mdi:security",
"climate": "mdi:thermometer",
"energy": "mdi:lightning-bolt",
"media": "mdi:play",
"kitchen": "mdi:chef-hat",
"bedroom": "mdi:bed",
"bathroom": "mdi:shower",
"living_room": "mdi:sofa",
"garage": "mdi:garage",
"garden": "mdi:flower",
"office": "mdi:desk",
"basement": "mdi:stairs-down",
"attic": "mdi:stairs-up",
}
def get_template_for_entities(entities, dashboard_type="general"):
"""Generate a dashboard template based on available entities."""
template = {
"title": f"{dashboard_type.title()} Dashboard",
"url_path": dashboard_type.lower().replace(" ", "-"),
"icon": COMMON_ICONS.get(dashboard_type, "mdi:view-dashboard"),
"show_in_sidebar": True,
"views": [],
}
# Group entities by domain and by device_class for sensors
entity_groups = {}
sensor_by_device_class = {}
for entity in entities:
if isinstance(entity, dict) and "entity_id" in entity:
entity_id = entity["entity_id"]
# Get device_class from attributes if available
device_class = entity.get("attributes", {}).get("device_class")
else:
entity_id = str(entity)
device_class = None
domain = entity_id.split(".")[0]
if domain not in entity_groups:
entity_groups[domain] = []
entity_groups[domain].append(entity_id)
# Categorize sensors by device_class
if domain == "sensor" and device_class:
if device_class not in sensor_by_device_class:
sensor_by_device_class[device_class] = []
sensor_by_device_class[device_class].append(entity_id)
# Create view with cards for each domain
view_cards = []
# Lights
if "light" in entity_groups:
view_cards.append(
{"type": "entities", "title": "Lights", "entities": entity_groups["light"]}
)
# Climate - handle both climate entities and temperature/humidity sensors
if "climate" in entity_groups:
for climate_entity in entity_groups["climate"]:
view_cards.append({"type": "thermostat", "entity": climate_entity})
# Temperature sensors - create history graphs for climate dashboards
if "temperature" in sensor_by_device_class:
temp_sensors = sensor_by_device_class["temperature"]
# Add history graph for temperature visualization
view_cards.append(
{
"type": "history-graph",
"title": "Temperature History",
"entities": temp_sensors,
"hours_to_show": 24,
}
)
# Add entity card for current values
view_cards.append(
{
"type": "entities",
"title": "Temperature Sensors",
"entities": temp_sensors,
}
)
# Humidity sensors - create gauge cards for climate dashboards
if "humidity" in sensor_by_device_class:
humidity_sensors = sensor_by_device_class["humidity"]
# Add history graph for humidity visualization
view_cards.append(
{
"type": "history-graph",
"title": "Humidity History",
"entities": humidity_sensors,
"hours_to_show": 24,
}
)
# Add entity card for current values
view_cards.append(
{
"type": "entities",
"title": "Humidity Sensors",
"entities": humidity_sensors,
}
)
# Media players
if "media_player" in entity_groups:
for media_entity in entity_groups["media_player"]:
view_cards.append({"type": "media-control", "entity": media_entity})
# Security entities
if "binary_sensor" in entity_groups:
view_cards.append(
{
"type": "entities",
"title": "Sensors",
"entities": entity_groups["binary_sensor"],
}
)
if "alarm_control_panel" in entity_groups:
for alarm_entity in entity_groups["alarm_control_panel"]:
view_cards.append({"type": "alarm-panel", "entity": alarm_entity})
# Other Sensors (not temperature or humidity)
if "sensor" in entity_groups:
# Filter out temperature and humidity sensors that we already handled
other_sensors = [
s
for s in entity_groups["sensor"]
if s not in sensor_by_device_class.get("temperature", [])
and s not in sensor_by_device_class.get("humidity", [])
]
if other_sensors:
view_cards.append(
{
"type": "entities",
"title": "Other Sensors",
"entities": other_sensors[:10], # Limit to first 10
}
)
# Switches
if "switch" in entity_groups:
view_cards.append(
{
"type": "entities",
"title": "Switches",
"entities": entity_groups["switch"],
}
)
# Weather
if "weather" in entity_groups:
view_cards.append(
{"type": "weather-forecast", "entity": entity_groups["weather"][0]}
)
template["views"] = [{"title": "Overview", "cards": view_cards}]
return template
@@ -0,0 +1,8 @@
panel_custom:
- name: AI Agent HA
sidebar_title: AI Agent HA
sidebar_icon: mdi:robot
url_path: ai_agent_ha
module_url: /local/ai_agent_ha/frontend/ai_agent_ha-panel.js
trust_external: true
require_admin: false
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
{
"domain": "ai_agent_ha",
"name": "AI Agent HA",
"after_dependencies": [
"history",
"recorder",
"lovelace"
],
"codeowners": [
"@sbenodiz"
],
"config_flow": true,
"dependencies": [
"http"
],
"documentation": "https://github.com/sbenodiz/ai_agent_ha",
"integration_type": "service",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/sbenodiz/ai_agent_ha/issues",
"requirements": [
"aiohttp>=3.8.0"
],
"version": "1.14.1"
}
@@ -0,0 +1,98 @@
query:
name: "Query AI Agent with Home Assistant context"
description: "Run a custom AI prompt against your Home Assistant state dump."
fields:
prompt:
description: "The question or instruction to send to AI model."
example: "Turn on all the lights in the living room"
debug:
description: "Include a debug trace of the HA↔AI conversation (true/false)."
example: true
default: false
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
create_dashboard:
name: "Create Dashboard via AI Agent"
description: "Create a new Home Assistant dashboard using AI assistance."
fields:
dashboard_config:
description: "The dashboard configuration as a JSON object."
example: '{"title": "My Dashboard", "url_path": "my-dashboard", "views": []}'
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
create_automation:
name: "Create Automation via AI Agent"
description: "Create a new Home Assistant automation using AI assistance."
fields:
automation:
description: "The automation configuration as a JSON object."
example: '{"alias": "Turn off lights at 9 PM", "trigger": [{"platform": "time", "at": "21:00:00"}], "action": [{"service": "light.turn_off", "target": {"entity_id": "light.living_room"}}]}'
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
update_dashboard:
name: "Update Dashboard via AI Agent"
description: "Update an existing Home Assistant dashboard using AI assistance."
fields:
dashboard_url:
description: "The URL path of dashboard to update."
example: "my-dashboard"
dashboard_config:
description: "The updated dashboard configuration as a JSON object."
example: '{"title": "Updated Dashboard", "views": []}'
provider:
description: "The AI provider to use (openai, llama, gemini, openrouter, anthropic, alter, zai, local)"
example: "openai"
default: "openai"
selector:
select:
options:
- "openai"
- "llama"
- "gemini"
- "openrouter"
- "anthropic"
- "alter"
- "zai"
- "local"
@@ -0,0 +1,91 @@
{
"config": {
"step": {
"user": {
"title": "Choose AI Provider",
"description": "Select your AI provider",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure": {
"title": "Configure {provider}",
"description": "Enter your {token_label} and optionally select a model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a predefined model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
},
"error": {
"invalid_api_key": "Invalid API key format",
"unknown": "Unknown error occurred",
"llama_token": "Llama API token is required",
"openai_token": "OpenAI API key is required",
"gemini_token": "Google Gemini API key is required",
"openrouter_token": "OpenRouter API key is required",
"anthropic_token": "Anthropic API key is required",
"alter_token": "Alter API key is required"
},
"abort": {
"already_configured": "AI Agent HA is already configured"
}
},
"options": {
"step": {
"init": {
"title": "AI Provider Settings",
"description": "Current provider: {current_provider}. Select a provider to configure",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure_options": {
"title": "Configure {provider}",
"description": "Update your {token_label} and model settings",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
}
}
}
@@ -0,0 +1,81 @@
{
"config": {
"step": {
"user": {
"title": "Agent d'IA HA - Seleccionar proveïdor",
"description": "Tria el teu proveïdor d'IA",
"data": {
"ai_provider": "Proveïdor d'IA"
},
"data_description": {
"ai_provider": "Selecciona el teu proveïdor d'IA preferit"
}
},
"configure": {
"title": "Agent d'IA HA - Configurar {provider}",
"description": "Configura les teves credencials d'API i el model de {provider}",
"data": {
"llama_token": "Token de l'API de Llama",
"openai_token": "Clau de l'API d'OpenAI",
"gemini_token": "Clau de l'API de Google Gemini",
"openrouter_token": "Clau de l'API d'OpenRouter",
"model": "Model (Opcional)",
"custom_model": "Nom del model personalitzat (Opcional)"
},
"data_description": {
"llama_token": "Introdueix el teu token de l'API de Llama",
"openai_token": "Introdueix la teva clau de l'API d'OpenAI",
"gemini_token": "Introdueix la teva clau de l'API de Google Gemini",
"openrouter_token": "Introdueix la teva clau de l'API d'OpenRouter",
"model": "Tria un model predefinit o selecciona 'Personalitzat...' per utilitzar un model personalitzat",
"custom_model": "Introdueix un nom de model personalitzat (anul·la la selecció desplegable anterior)"
}
}
},
"error": {
"invalid_api_key": "Clau o token d'API no vàlid",
"llama_token": "Es requereix el token de l'API de Llama",
"openai_token": "Es requereix la clau de l'API d'OpenAI",
"gemini_token": "Es requereix la clau de l'API de Google Gemini",
"openrouter_token": "Es requereix la clau de l'API d'OpenRouter",
"unknown": "S'ha produït un error inesperat"
},
"abort": {
"already_configured": "L'Agent d'IA HA ja està configurat"
}
},
"options": {
"step": {
"init": {
"title": "Agent d'IA HA - Seleccionar proveïdor",
"description": "Tria el teu proveïdor d'IA (Actual: {current_provider})",
"data": {
"ai_provider": "Proveïdor d'IA"
},
"data_description": {
"ai_provider": "Selecciona el teu proveïdor d'IA preferit"
}
},
"configure_options": {
"title": "Agent d'IA HA - Configurar {provider}",
"description": "Actualitza les teves credencials d'API i el model de {provider}",
"data": {
"llama_token": "Token de l'API de Llama",
"openai_token": "Clau de l'API d'OpenAI",
"gemini_token": "Clau de l'API de Google Gemini",
"openrouter_token": "Clau de l'API d'OpenRouter",
"model": "Model (Opcional)",
"custom_model": "Nom del model personalitzat (Opcional)"
},
"data_description": {
"llama_token": "Introdueix el teu token de l'API de Llama",
"openai_token": "Introdueix la teva clau de l'API d'OpenAI",
"gemini_token": "Introdueix la teva clau de l'API de Google Gemini",
"openrouter_token": "Introdueix la teva clau de l'API d'OpenRouter",
"model": "Tria un model predefinit o selecciona 'Personalitzat...' per utilitzar un model personalitzat",
"custom_model": "Introdueix un nom de model personalitzat (anul·la la selecció desplegable anterior)"
}
}
}
}
}
@@ -0,0 +1,86 @@
{
"config": {
"step": {
"user": {
"title": "KI-Anbieter wählen",
"description": "Wähle deinen KI-Anbieter aus",
"data": {
"ai_provider": "KI-Anbieter"
},
"data_description": {
"ai_provider": "Wähle deinen bevorzugten KI-Anbieter"
}
},
"configure": {
"title": "{provider} konfigurieren",
"description": "Gib deinen {token_label} ein und wähle optional ein Model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Gib deinen Llama API Token ein",
"openai_token": "Gib deinen OpenAI API Key ein",
"gemini_token": "Gib deinen Google Gemini API Key ein",
"openrouter_token": "Gib deinen OpenRouter API Key ein",
"anthropic_token": "Gib deinen Anthropic API Key ein",
"model": "Wähle ein vordefiniertes Model oder 'Custom...' zum manuellen Eintrag",
"custom_model": "Gib einen benutzerdefinierten Model-Namen ein (nur relevant bei Auswahl von 'Custom...')"
}
}
},
"error": {
"invalid_api_key": "Ungültiges API-Key-Format",
"unknown": "Unbekannter Fehler aufgetreten",
"llama_token": "Llama API Token ist erforderlich",
"openai_token": "OpenAI API Key ist erforderlich",
"gemini_token": "Google Gemini API Key ist erforderlich",
"openrouter_token": "OpenRouter API Key ist erforderlich",
"anthropic_token": "Anthropic API Key ist erforderlich"
},
"abort": {
"already_configured": "AI Agent HA ist bereits konfiguriert"
}
},
"options": {
"step": {
"init": {
"title": "Einstellungen für KI-Anbieter",
"description": "Aktueller Anbieter: {current_provider}. Wähle einen Anbieter zur Konfiguration",
"data": {
"ai_provider": "KI-Anbieter"
},
"data_description": {
"ai_provider": "Wähle deinen bevorzugten KI-Anbieter"
}
},
"configure_options": {
"title": "{provider} konfigurieren",
"description": "Aktualisiere deinen {token_label} und Model-Einstellungen",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"model": "Model",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Gib deinen Llama API Token ein",
"openai_token": "Gib deinen OpenAI API Key ein",
"gemini_token": "Gib deinen Google Gemini API Key ein",
"openrouter_token": "Gib deinen OpenRouter API Key ein",
"anthropic_token": "Gib deinen Anthropic API Key ein",
"model": "Wähle ein Model oder 'Custom...' zum manuellen Eintrag",
"custom_model": "Gib einen benutzerdefinierten Model-Namen ein (nur relevant bei Auswahl von 'Custom...')"
}
}
}
}
}
@@ -0,0 +1,91 @@
{
"config": {
"step": {
"user": {
"title": "Choose AI Provider",
"description": "Select your AI provider",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure": {
"title": "Configure {provider}",
"description": "Enter your {token_label} and optionally select a model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a predefined model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
},
"error": {
"invalid_api_key": "Invalid API key format",
"unknown": "Unknown error occurred",
"llama_token": "Llama API token is required",
"openai_token": "OpenAI API key is required",
"gemini_token": "Google Gemini API key is required",
"openrouter_token": "OpenRouter API key is required",
"anthropic_token": "Anthropic API key is required",
"alter_token": "Alter API key is required"
},
"abort": {
"already_configured": "AI Agent HA is already configured"
}
},
"options": {
"step": {
"init": {
"title": "AI Provider Settings",
"description": "Current provider: {current_provider}. Select a provider to configure",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Choose your preferred AI provider"
}
},
"configure_options": {
"title": "Configure {provider}",
"description": "Update your {token_label} and model settings",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"anthropic_token": "Anthropic API Key",
"alter_token": "Alter API Key",
"model": "Model",
"custom_model": "Custom Model (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"anthropic_token": "Enter your Anthropic API key",
"alter_token": "Enter your Alter API key",
"model": "Choose a model or select 'Custom...' to enter your own",
"custom_model": "Enter a custom model name (only used if 'Custom...' is selected above)"
}
}
}
}
}
@@ -0,0 +1,81 @@
{
"config": {
"step": {
"user": {
"title": "AI Agent HA - Select Provider",
"description": "Choose your AI provider",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Select your preferred AI provider"
}
},
"configure": {
"title": "AI Agent HA - Configure {provider}",
"description": "Configure your {provider} API credentials and model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model Name (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"model": "Choose a predefined model or select 'Custom...' to use a custom model",
"custom_model": "Enter a custom model name (overrides the dropdown selection above)"
}
}
},
"error": {
"invalid_api_key": "Invalid API key or token",
"llama_token": "Llama API token is required",
"openai_token": "OpenAI API key is required",
"gemini_token": "Google Gemini API key is required",
"openrouter_token": "OpenRouter API key is required",
"unknown": "Unexpected error occurred"
},
"abort": {
"already_configured": "AI Agent HA is already configured"
}
},
"options": {
"step": {
"init": {
"title": "AI Agent HA - Select Provider",
"description": "Choose your AI provider (Current: {current_provider})",
"data": {
"ai_provider": "AI Provider"
},
"data_description": {
"ai_provider": "Select your preferred AI provider"
}
},
"configure_options": {
"title": "AI Agent HA - Configure {provider}",
"description": "Update your {provider} API credentials and model",
"data": {
"llama_token": "Llama API Token",
"openai_token": "OpenAI API Key",
"gemini_token": "Google Gemini API Key",
"openrouter_token": "OpenRouter API Key",
"model": "Model (Optional)",
"custom_model": "Custom Model Name (Optional)"
},
"data_description": {
"llama_token": "Enter your Llama API token",
"openai_token": "Enter your OpenAI API key",
"gemini_token": "Enter your Google Gemini API key",
"openrouter_token": "Enter your OpenRouter API key",
"model": "Choose a predefined model or select 'Custom...' to use a custom model",
"custom_model": "Enter a custom model name (overrides the dropdown selection above)"
}
}
}
}
}
@@ -0,0 +1,81 @@
{
"config": {
"step": {
"user": {
"title": "Agente de IA HA - Seleccionar proveedor",
"description": "Elige tu proveedor de IA",
"data": {
"ai_provider": "Proveedor de IA"
},
"data_description": {
"ai_provider": "Selecciona tu proveedor de IA preferido"
}
},
"configure": {
"title": "Agente de IA HA - Configurar {provider}",
"description": "Configura tus credenciales de API y modelo de {provider}",
"data": {
"llama_token": "Token de la API de Llama",
"openai_token": "Clave de la API de OpenAI",
"gemini_token": "Clave de la API de Google Gemini",
"openrouter_token": "Clave de la API de OpenRouter",
"model": "Modelo (Opcional)",
"custom_model": "Nombre de modelo personalizado (Opcional)"
},
"data_description": {
"llama_token": "Introduce tu token de la API de Llama",
"openai_token": "Introduce tu clave de la API de OpenAI",
"gemini_token": "Introduce tu clave de la API de Google Gemini",
"openrouter_token": "Introduce tu clave de la API de OpenRouter",
"model": "Elige un modelo predefinido o selecciona 'Personalizado...' para usar un modelo personalizado",
"custom_model": "Introduce un nombre de modelo personalizado (anula la selección desplegable anterior)"
}
}
},
"error": {
"invalid_api_key": "Clave o token de API no válido",
"llama_token": "Se requiere el token de la API de Llama",
"openai_token": "Se requiere la clave de la API de OpenAI",
"gemini_token": "Se requiere la clave de la API de Google Gemini",
"openrouter_token": "Se requiere la clave de la API de OpenRouter",
"unknown": "Ha ocurrido un error inesperado"
},
"abort": {
"already_configured": "El Agente de IA HA ya está configurado"
}
},
"options": {
"step": {
"init": {
"title": "Agente de IA HA - Seleccionar proveedor",
"description": "Elige tu proveedor de IA (Actual: {current_provider})",
"data": {
"ai_provider": "Proveedor de IA"
},
"data_description": {
"ai_provider": "Selecciona tu proveedor de IA preferido"
}
},
"configure_options": {
"title": "Agente de IA HA - Configurar {provider}",
"description": "Actualiza tus credenciales de API y modelo de {provider}",
"data": {
"llama_token": "Token de la API de Llama",
"openai_token": "Clave de la API de OpenAI",
"gemini_token": "Clave de la API de Google Gemini",
"openrouter_token": "Clave de la API de OpenRouter",
"model": "Modelo (Opcional)",
"custom_model": "Nombre de modelo personalizado (Opcional)"
},
"data_description": {
"llama_token": "Introduce tu token de la API de Llama",
"openai_token": "Introduce tu clave de la API de OpenAI",
"gemini_token": "Introduce tu clave de la API de Google Gemini",
"openrouter_token": "Introduce tu clave de la API de OpenRouter",
"model": "Elige un modelo predefinido o selecciona 'Personalizado...' para usar un modelo personalizado",
"custom_model": "Introduce un nombre de modelo personalizado (anula la selección desplegable anterior)"
}
}
}
}
}
+603
View File
@@ -0,0 +1,603 @@
"""The Alarmo Integration."""
import re
import base64
import logging
import concurrent.futures
import bcrypt
from homeassistant.core import (
HomeAssistant,
asyncio,
callback,
)
from homeassistant.const import (
ATTR_CODE,
ATTR_NAME,
)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.service import (
async_register_admin_service,
)
from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
async_dispatcher_connect,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.components.alarm_control_panel import DOMAIN as PLATFORM
from . import const
from .card import async_register_card
from .mqtt import MqttHandler
from .event import EventHandler
from .panel import (
async_register_panel,
async_unregister_panel,
)
from .store import async_get_registry
from .sensors import (
ATTR_GROUP,
ATTR_ENTITIES,
ATTR_NEW_ENTITY_ID,
SensorHandler,
)
from .websockets import async_register_websockets
from .automations import AutomationHandler
_LOGGER = logging.getLogger(__name__)
# Max number of threads to start when checking user codes.
MAX_WORKERS = 4
# Number of rounds of hashing when computing user hashes.
BCRYPT_NUM_ROUNDS = 10
async def async_setup(hass, config):
"""Track states and offer events for sensors."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Alarmo integration from a config entry."""
session = async_get_clientsession(hass)
store = await async_get_registry(hass)
coordinator = AlarmoCoordinator(hass, session, entry, store)
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(const.DOMAIN, coordinator.id)},
name=const.NAME,
model=const.NAME,
sw_version=const.VERSION,
manufacturer=const.MANUFACTURER,
)
hass.data.setdefault(const.DOMAIN, {})
hass.data[const.DOMAIN] = {"coordinator": coordinator, "areas": {}, "master": None}
if entry.unique_id is None:
hass.config_entries.async_update_entry(entry, unique_id=coordinator.id, data={})
await hass.config_entries.async_forward_entry_setups(entry, [PLATFORM])
# Register the panel (frontend)
await async_register_panel(hass)
await async_register_card(hass)
# Websocket support
await async_register_websockets(hass)
# Register custom services
register_services(hass)
return True
async def async_unload_entry(hass, entry):
"""Unload Alarmo config entry."""
unload_ok = all(
await asyncio.gather(
*[hass.config_entries.async_forward_entry_unload(entry, PLATFORM)]
)
)
if not unload_ok:
return False
async_unregister_panel(hass)
coordinator = hass.data[const.DOMAIN]["coordinator"]
await coordinator.async_unload()
return True
async def async_remove_entry(hass, entry):
"""Remove Alarmo config entry."""
async_unregister_panel(hass)
coordinator = hass.data[const.DOMAIN]["coordinator"]
await coordinator.async_delete_config()
del hass.data[const.DOMAIN]
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle migration of config entry."""
return True
class AlarmoCoordinator(DataUpdateCoordinator):
"""Define an object to hold Alarmo device."""
def __init__(self, hass, session, entry, store):
"""Initialize."""
self.id = entry.unique_id
self.hass = hass
self.entry = entry
self.store = store
self._subscriptions = []
self._subscriptions.append(
async_dispatcher_connect(
hass, "alarmo_platform_loaded", self.setup_alarm_entities
)
)
self.register_events()
super().__init__(hass, _LOGGER, config_entry=entry, name=const.DOMAIN)
@callback
def setup_alarm_entities(self):
"""Set up alarm_control_panel entities based on areas in storage."""
self.hass.data[const.DOMAIN]["sensor_handler"] = SensorHandler(self.hass)
self.hass.data[const.DOMAIN]["automation_handler"] = AutomationHandler(
self.hass
)
self.hass.data[const.DOMAIN]["mqtt_handler"] = MqttHandler(self.hass)
self.hass.data[const.DOMAIN]["event_handler"] = EventHandler(self.hass)
areas = self.store.async_get_areas()
config = self.store.async_get_config()
for item in areas.values():
async_dispatcher_send(self.hass, "alarmo_register_entity", item)
if len(areas) > 1 and config["master"]["enabled"]:
async_dispatcher_send(self.hass, "alarmo_register_master", config["master"])
async def async_update_config(self, data):
"""Update the main configuration."""
if "master" in data:
old_config = self.store.async_get_config()
if old_config[const.ATTR_MASTER] != data["master"]:
if self.hass.data[const.DOMAIN]["master"]:
await self.async_remove_entity("master")
if data["master"]["enabled"]:
async_dispatcher_send(
self.hass, "alarmo_register_master", data["master"]
)
else:
automations = self.hass.data[const.DOMAIN][
"automation_handler"
].get_automations_by_area(None)
if len(automations):
for el in automations:
self.store.async_delete_automation(el)
async_dispatcher_send(self.hass, "alarmo_automations_updated")
self.store.async_update_config(data)
async_dispatcher_send(self.hass, "alarmo_config_updated")
async def async_update_area_config(
self,
area_id: str | None = None,
data: dict = {},
):
"""Update area configuration."""
if const.ATTR_REMOVE in data:
# delete an area
res = self.store.async_get_area(area_id)
if not res:
return
sensors = self.store.async_get_sensors()
sensors = dict(filter(lambda el: el[1]["area"] == area_id, sensors.items()))
if sensors:
for el in sensors.keys():
self.store.async_delete_sensor(el)
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
automations = self.hass.data[const.DOMAIN][
"automation_handler"
].get_automations_by_area(area_id)
if len(automations):
for el in automations:
self.store.async_delete_automation(el)
async_dispatcher_send(self.hass, "alarmo_automations_updated")
self.store.async_delete_area(area_id)
await self.async_remove_entity(area_id)
if (
len(self.store.async_get_areas()) == 1
and self.hass.data[const.DOMAIN]["master"]
):
await self.async_remove_entity("master")
elif self.store.async_get_area(area_id):
# modify an area
entry = self.store.async_update_area(area_id, data)
if "name" not in data:
async_dispatcher_send(self.hass, "alarmo_config_updated", area_id)
else:
await self.async_remove_entity(area_id)
async_dispatcher_send(self.hass, "alarmo_register_entity", entry)
else:
# create an area
entry = self.store.async_create_area(data)
async_dispatcher_send(self.hass, "alarmo_register_entity", entry)
config = self.store.async_get_config()
if len(self.store.async_get_areas()) == 2 and config["master"]["enabled"]:
async_dispatcher_send(
self.hass, "alarmo_register_master", config["master"]
)
def async_update_sensor_config(self, entity_id: str, data: dict):
"""Update sensor configuration."""
group = None
if ATTR_GROUP in data:
group = data[ATTR_GROUP]
del data[ATTR_GROUP]
if ATTR_NEW_ENTITY_ID in data:
# delete old sensor entry when changing the entity_id
new_entity_id = data[ATTR_NEW_ENTITY_ID]
del data[ATTR_NEW_ENTITY_ID]
self.store.async_delete_sensor(entity_id)
self.assign_sensor_to_group(new_entity_id, group)
self.assign_sensor_to_group(entity_id, None)
entity_id = new_entity_id
if const.ATTR_REMOVE in data:
self.store.async_delete_sensor(entity_id)
self.assign_sensor_to_group(entity_id, None)
elif self.store.async_get_sensor(entity_id):
self.store.async_update_sensor(entity_id, data)
self.assign_sensor_to_group(entity_id, group)
else:
self.store.async_create_sensor(entity_id, data)
self.assign_sensor_to_group(entity_id, group)
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
async def _validate_user_code(self, user_id: str, data: dict):
user_with_code = await self.async_authenticate_user(data[ATTR_CODE])
if user_id:
if const.ATTR_OLD_CODE not in data:
return "No code provided"
if not await self.async_authenticate_user(
data[const.ATTR_OLD_CODE], user_id
):
return "Wrong code provided"
if user_with_code and user_with_code[const.ATTR_USER_ID] != user_id:
return "User with same code already exists"
elif user_with_code:
return "User with same code already exists"
return
def _validate_user_name(self, user_id: str, data: dict):
if not data[ATTR_NAME]:
return "User name must not be empty"
for user in self.store.async_get_users().values():
if (
data[ATTR_NAME] == user[ATTR_NAME]
and user_id != user[const.ATTR_USER_ID]
):
return "User with same name already exists"
return
async def async_update_user_config(
self, user_id: str | None = None, data: dict = {}
):
"""Update user configuration."""
if const.ATTR_REMOVE in data:
self.store.async_delete_user(user_id)
return
if ATTR_NAME in data:
err = self._validate_user_name(user_id, data)
if err:
_LOGGER.error(err)
return err
if ATTR_CODE in data:
err = await self._validate_user_code(user_id, data)
if err:
_LOGGER.error(err)
return err
if data.get(ATTR_CODE):
data[const.ATTR_CODE_FORMAT] = (
"number" if data[ATTR_CODE].isdigit() else "text"
)
data[const.ATTR_CODE_LENGTH] = len(data[ATTR_CODE])
def _hash_code(code):
hashed = bcrypt.hashpw(
code.encode("utf-8"),
bcrypt.gensalt(rounds=BCRYPT_NUM_ROUNDS),
)
return base64.b64encode(hashed).decode()
data[ATTR_CODE] = await self.hass.async_add_executor_job(
_hash_code, data[ATTR_CODE]
)
if not user_id:
self.store.async_create_user(data)
return
else:
if ATTR_CODE in data:
del data[const.ATTR_OLD_CODE]
self.store.async_update_user(user_id, data)
return
async def async_authenticate_user(self, code, user_id=None):
"""Run user authentication in the executor.
This wrapper ensures that the blocking bcrypt-based authentication
is executed outside of the event loop, making it safe to call
from async context.
"""
def _authenticate_user_sync():
"""Perform user authentication using blocking bcrypt operations."""
def check_user_code(user, code):
"""Returns None or the supplied user object if the code matches."""
if not user[const.ATTR_ENABLED]:
return None
elif not user[ATTR_CODE] and not code:
return user
elif user[ATTR_CODE]:
try:
hash = base64.b64decode(user[ATTR_CODE])
except Exception:
return None
if bcrypt.checkpw((code or "").encode("utf-8"), hash):
return user
if user_id:
return check_user_code(self.store.async_get_user(user_id), code)
users = self.store.async_get_users()
with concurrent.futures.ThreadPoolExecutor(
max_workers=MAX_WORKERS
) as executor:
futures = [
executor.submit(check_user_code, user, code)
for user in users.values()
]
for future in concurrent.futures.as_completed(futures):
if future.result():
executor.shutdown(wait=False, cancel_futures=True)
return future.result()
return await self.hass.async_add_executor_job(_authenticate_user_sync)
def async_update_automation_config(
self,
automation_id: str | None = None,
data: dict = {},
):
"""Update automation configuration."""
if const.ATTR_REMOVE in data:
self.store.async_delete_automation(automation_id)
elif not automation_id:
self.store.async_create_automation(data)
else:
self.store.async_update_automation(automation_id, data)
async_dispatcher_send(self.hass, "alarmo_automations_updated")
def register_events(self):
"""Register event handlers."""
# handle push notifications with action buttons
@callback
async def async_handle_push_event(event):
if not event.data:
return
action = (
event.data.get("actionName")
if "actionName" in event.data
else event.data.get("action")
)
if action not in const.EVENT_ACTIONS:
return
if self.hass.data[const.DOMAIN]["master"]:
alarm_entity = self.hass.data[const.DOMAIN]["master"]
elif len(self.hass.data[const.DOMAIN]["areas"]) == 1:
alarm_entity = next(
iter(self.hass.data[const.DOMAIN]["areas"].values())
)
else:
_LOGGER.info(
"Cannot process the push action, since there are multiple areas."
)
return
arm_mode = (
alarm_entity._revert_state
if alarm_entity._revert_state in const.ARM_MODES
else alarm_entity._arm_mode
)
res = re.search(r"^ALARMO_ARM_", action)
if res:
arm_mode = action.replace("ALARMO_", "").lower().replace("arm", "armed")
if not arm_mode:
_LOGGER.info(
"Cannot process the push action, since the arm mode is not known."
)
return
if action == const.EVENT_ACTION_FORCE_ARM:
_LOGGER.info("Received request for force arming")
await alarm_entity.async_handle_arm_request(
arm_mode, skip_code=True, bypass_open_sensors=True
)
elif action == const.EVENT_ACTION_RETRY_ARM:
_LOGGER.info("Received request for retry arming")
await alarm_entity.async_handle_arm_request(arm_mode, skip_code=True)
elif action == const.EVENT_ACTION_DISARM:
_LOGGER.info("Received request for disarming")
await alarm_entity.async_alarm_disarm(None, skip_code=True)
else:
_LOGGER.info(
"Received request for arming with mode %s",
arm_mode,
)
await alarm_entity.async_handle_arm_request(arm_mode, skip_code=True)
self._subscriptions.append(
self.hass.bus.async_listen(const.PUSH_EVENT, async_handle_push_event)
)
async def async_remove_entity(self, area_id: str):
"""Remove an alarm_control_panel entity."""
entity_registry = er.async_get(self.hass)
if area_id == "master":
entity = self.hass.data[const.DOMAIN]["master"]
entity_registry.async_remove(entity.entity_id)
self.hass.data[const.DOMAIN]["master"] = None
else:
entity = self.hass.data[const.DOMAIN]["areas"][area_id]
entity_registry.async_remove(entity.entity_id)
self.hass.data[const.DOMAIN]["areas"].pop(area_id, None)
def async_get_sensor_groups(self):
"""Fetch a list of sensor groups (websocket API hook)."""
groups = self.store.async_get_sensor_groups()
return list(groups.values())
def async_get_group_for_sensor(self, entity_id: str):
"""Fetch the group ID for a given sensor."""
groups = self.async_get_sensor_groups()
result = next((el for el in groups if entity_id in el[ATTR_ENTITIES]), None)
return result["group_id"] if result else None
def assign_sensor_to_group(self, entity_id: str, group_id: str):
"""Assign a sensor to a group."""
updated = False
old_group = self.async_get_group_for_sensor(entity_id)
if old_group and group_id != old_group:
# remove sensor from group
el = self.store.async_get_sensor_group(old_group)
if len(el[ATTR_ENTITIES]) > 2:
self.store.async_update_sensor_group(
old_group,
{ATTR_ENTITIES: [x for x in el[ATTR_ENTITIES] if x != entity_id]},
)
else:
self.store.async_delete_sensor_group(old_group)
updated = True
if group_id:
# add sensor to group
group = self.store.async_get_sensor_group(group_id)
if not group:
_LOGGER.error(
"Failed to assign entity %s to group %s",
entity_id,
group_id,
)
elif entity_id not in group[ATTR_ENTITIES]:
self.store.async_update_sensor_group(
group_id, {ATTR_ENTITIES: group[ATTR_ENTITIES] + [entity_id]}
)
updated = True
if updated:
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
def async_update_sensor_group_config(
self,
group_id: str | None = None,
data: dict = {},
):
"""Update sensor group configuration."""
if const.ATTR_REMOVE in data:
self.store.async_delete_sensor_group(group_id)
elif not group_id:
self.store.async_create_sensor_group(data)
else:
self.store.async_update_sensor_group(group_id, data)
async_dispatcher_send(self.hass, "alarmo_sensors_updated")
async def async_unload(self):
"""Remove all alarmo objects."""
# remove alarm_control_panel entities
areas = list(self.hass.data[const.DOMAIN]["areas"].keys())
for area in areas:
await self.async_remove_entity(area)
if self.hass.data[const.DOMAIN]["master"]:
await self.async_remove_entity("master")
del self.hass.data[const.DOMAIN]["sensor_handler"]
del self.hass.data[const.DOMAIN]["automation_handler"]
del self.hass.data[const.DOMAIN]["mqtt_handler"]
del self.hass.data[const.DOMAIN]["event_handler"]
# remove subscriptions for coordinator
while len(self._subscriptions):
self._subscriptions.pop()()
async def async_delete_config(self):
"""Wipe alarmo storage."""
await self.store.async_delete()
@callback
def register_services(hass):
"""Register services used by alarmo component."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
async def async_srv_toggle_user(call):
"""Enable a user by service call."""
name = call.data.get(ATTR_NAME)
enable = True if call.service == const.SERVICE_ENABLE_USER else False
users = coordinator.store.async_get_users()
user = next(
(item for item in list(users.values()) if item[ATTR_NAME] == name), None
)
if user is None:
_LOGGER.warning(
"Failed to %s user, no match for name '%s'",
"enable" if enable else "disable",
name,
)
return
coordinator.store.async_update_user(
user[const.ATTR_USER_ID], {const.ATTR_ENABLED: enable}
)
_LOGGER.debug(
"User user '%s' was %s", name, "enabled" if enable else "disabled"
)
async_register_admin_service(
hass,
const.DOMAIN,
const.SERVICE_ENABLE_USER,
async_srv_toggle_user,
schema=const.SERVICE_TOGGLE_USER_SCHEMA,
)
async_register_admin_service(
hass,
const.DOMAIN,
const.SERVICE_DISABLE_USER,
async_srv_toggle_user,
schema=const.SERVICE_TOGGLE_USER_SCHEMA,
)
File diff suppressed because it is too large Load Diff
+409
View File
@@ -0,0 +1,409 @@
"""Automations."""
import re
import copy
import logging
from homeassistant.core import (
HomeAssistant,
callback,
)
from homeassistant.const import (
CONF_TYPE,
ATTR_SERVICE,
ATTR_ENTITY_ID,
CONF_SERVICE_DATA,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.template import Template, is_template_string
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.translation import async_get_translations
from homeassistant.components.binary_sensor.device_condition import (
ENTITY_CONDITIONS,
)
from . import const
from .helpers import (
friendly_name_for_entity_id,
)
from .sensors import (
STATE_OPEN,
STATE_CLOSED,
STATE_UNAVAILABLE,
)
from .alarm_control_panel import AlarmoBaseEntity
_LOGGER = logging.getLogger(__name__)
EVENT_ARM_FAILURE = "arm_failure"
def validate_area(trigger, area_id, hass):
"""Validate area for trigger."""
if const.ATTR_AREA not in trigger:
return False
elif trigger[const.ATTR_AREA]:
return trigger[const.ATTR_AREA] == area_id
elif len(hass.data[const.DOMAIN]["areas"]) == 1:
return True
else:
return area_id is None
def validate_modes(trigger, mode):
"""Validate modes for trigger."""
if const.ATTR_MODES not in trigger:
return False
elif not trigger[const.ATTR_MODES]:
return True
else:
return mode in trigger[const.ATTR_MODES]
def validate_trigger(trigger, to_state, from_state=None):
"""Validate trigger condition."""
if const.ATTR_EVENT not in trigger:
return False
elif trigger[const.ATTR_EVENT] == "untriggered" and from_state == "triggered":
return True
elif trigger[const.ATTR_EVENT] == to_state:
return True
else:
return False
class AutomationHandler:
"""Handle automations."""
def __init__(self, hass: HomeAssistant):
"""Initialize automation handler."""
self.hass = hass
self._config = None
self._subscriptions = []
self._sensorTranslationCache = {}
self._alarmTranslationCache = {}
self._sensorTranslationLang = None
self._alarmTranslationLang = None
def async_update_config():
"""Automation config updated, reload the configuration."""
self._config = self.hass.data[const.DOMAIN][
"coordinator"
].store.async_get_automations()
self._subscriptions.append(
async_dispatcher_connect(
hass, "alarmo_automations_updated", async_update_config
)
)
async_update_config()
@callback
async def async_alarm_state_changed(
area_id: str, old_state: str, new_state: str
):
if not old_state:
# ignore automations at startup/restoring
return
if area_id:
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
else:
alarm_entity = self.hass.data[const.DOMAIN]["master"]
if not alarm_entity:
return
_LOGGER.debug(
"state of %s is updated from %s to %s",
alarm_entity.entity_id,
old_state,
new_state,
)
if new_state in const.ARM_MODES:
# we don't distinguish between armed modes for automations
# they are handled separately
new_state = "armed"
for automation_id, config in self._config.items():
if not config[const.ATTR_ENABLED]:
continue
for trigger in config[const.ATTR_TRIGGERS]:
if (
validate_area(trigger, area_id, self.hass)
and validate_modes(trigger, alarm_entity._arm_mode)
and validate_trigger(trigger, new_state, old_state)
):
await self.async_execute_automation(automation_id, alarm_entity)
self._subscriptions.append(
async_dispatcher_connect(
self.hass, "alarmo_state_updated", async_alarm_state_changed
)
)
@callback
async def async_handle_event(event: str, area_id: str, args: dict = {}):
if event != const.EVENT_FAILED_TO_ARM:
return
if area_id:
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
else:
alarm_entity = self.hass.data[const.DOMAIN]["master"]
_LOGGER.debug(
"%s has failed to arm",
alarm_entity.entity_id,
)
for automation_id, config in self._config.items():
if not config[const.ATTR_ENABLED]:
continue
for trigger in config[const.ATTR_TRIGGERS]:
if (
validate_area(trigger, area_id, self.hass)
and validate_modes(trigger, alarm_entity._arm_mode)
and validate_trigger(trigger, EVENT_ARM_FAILURE)
):
await self.async_execute_automation(automation_id, alarm_entity)
self._subscriptions.append(
async_dispatcher_connect(self.hass, "alarmo_event", async_handle_event)
)
def __del__(self):
"""Prepare for removal."""
while len(self._subscriptions):
self._subscriptions.pop()()
async def async_execute_automation(
self, automation_id: str, alarm_entity: AlarmoBaseEntity
):
"""Execute the specified automation."""
# automation is a dict of AutomationEntry
_LOGGER.debug(
"Executing automation %s",
automation_id,
)
actions = self._config[automation_id][const.ATTR_ACTIONS]
for action in actions:
try:
service_data = copy.copy(action[CONF_SERVICE_DATA])
if action.get(ATTR_ENTITY_ID):
service_data[ATTR_ENTITY_ID] = action[ATTR_ENTITY_ID]
if self._config[automation_id][CONF_TYPE] == const.ATTR_NOTIFICATION:
# recursively process all service_data (supports deep nesting)
service_data = await self._process_service_data(
service_data,
alarm_entity,
)
domain, service = action[ATTR_SERVICE].split(".")
await self.hass.async_create_task(
self.hass.services.async_call(
domain,
service,
service_data,
blocking=False,
context={},
)
)
except HomeAssistantError as e:
_LOGGER.error(
"Execution of action %s failed, reason: %s",
automation_id,
e,
)
async def _process_service_data(
self, obj, alarm_entity, depth: int = 0, max_depth: int = 10
):
"""Recursively process service_data replacing wildcards and templates safely."""
if depth > max_depth:
return obj
# Handle strings
if isinstance(obj, str):
return await self.replace_wildcards_in_string(obj, alarm_entity)
# Handle dictionaries
if isinstance(obj, dict):
processed = {}
for k, v in obj.items():
processed[k] = await self._process_service_data(
v,
alarm_entity,
depth + 1,
max_depth,
)
return processed
# Handle lists
if isinstance(obj, list):
return [
await self._process_service_data(
item,
alarm_entity,
depth + 1,
max_depth,
)
for item in obj
]
# Other types (int, float, bool, None, etc.)
return obj
def get_automations_by_area(self, area_id: str):
"""Get automations for specified area."""
result = []
for automation_id, config in self._config.items():
if any(
el[const.ATTR_AREA] == area_id for el in config[const.ATTR_TRIGGERS]
):
result.append(automation_id)
return result
async def replace_wildcards_in_string(
self, input: str, alarm_entity: AlarmoBaseEntity
):
"""Look for wildcards in string and replace them with content."""
# process wildcard '{{open_sensors}}'
res = re.search(r"{{open_sensors(\|lang=([^}]+))?(\|format=short)?}}", input)
if res:
lang = res.group(2) if res.group(2) else "en"
names_only = True if res.group(3) else False
open_sensors = ""
if alarm_entity.open_sensors:
parts = []
for entity_id, status in alarm_entity.open_sensors.items():
if names_only:
parts.append(friendly_name_for_entity_id(entity_id, self.hass))
else:
parts.append(
await self.async_get_open_sensor_string(
entity_id, status, lang
)
)
open_sensors = ", ".join(parts)
input = input.replace(res.group(0), open_sensors)
# process wildcard '{{bypassed_sensors}}'
if "{{bypassed_sensors}}" in input:
bypassed_sensors = ""
if alarm_entity.bypassed_sensors and len(alarm_entity.bypassed_sensors):
parts = []
for entity_id in alarm_entity.bypassed_sensors:
name = friendly_name_for_entity_id(entity_id, self.hass)
parts.append(name)
bypassed_sensors = ", ".join(parts)
input = input.replace("{{bypassed_sensors}}", bypassed_sensors)
# process wildcard '{{arm_mode}}'
res = re.search(r"{{arm_mode(\|lang=([^}]+))?}}", input)
if res:
lang = res.group(2) if res.group(2) else "en"
arm_mode = await self.async_get_arm_mode_string(alarm_entity.arm_mode, lang)
input = input.replace(res.group(0), arm_mode)
# process wildcard '{{changed_by}}'
if "{{changed_by}}" in input:
changed_by = alarm_entity.changed_by if alarm_entity.changed_by else ""
input = input.replace("{{changed_by}}", changed_by)
# process wildcard '{{delay}}'
if "{{delay}}" in input:
delay = str(alarm_entity.delay) if alarm_entity.delay else ""
input = input.replace("{{delay}}", delay)
# process HA templates
if is_template_string(input):
input = Template(input, self.hass).async_render()
_LOGGER.debug("Processed HA template, result:(%s) %s", type(input), input)
return input
async def async_get_open_sensor_string(
self, entity_id: str, state: str, language: str
):
"""Get translation for sensor states."""
if self._sensorTranslationCache and self._sensorTranslationLang == language:
translations = self._sensorTranslationCache
else:
translations = await async_get_translations(
self.hass, language, "device_automation", ["binary_sensor"]
)
self._sensorTranslationCache = translations
self._sensorTranslationLang = language
entity = self.hass.states.get(entity_id)
device_type = (
entity.attributes["device_class"]
if entity and "device_class" in entity.attributes
else None
)
if state == STATE_OPEN:
translation_key = (
f"component.binary_sensor.device_automation.condition_type.{ENTITY_CONDITIONS[device_type][0]['type']}"
if device_type in ENTITY_CONDITIONS
else None
)
if translation_key and translation_key in translations:
string = translations[translation_key]
else:
string = "{entity_name} is open"
elif state == STATE_CLOSED:
translation_key = (
f"component.binary_sensor.device_automation.condition_type.{ENTITY_CONDITIONS[device_type][1]['type']}"
if device_type in ENTITY_CONDITIONS
else None
)
if translation_key and translation_key in translations:
string = translations[translation_key]
else:
string = "{entity_name} is closed"
elif state == STATE_UNAVAILABLE:
string = "{entity_name} is unavailable"
else:
string = "{entity_name} is unknown"
name = friendly_name_for_entity_id(entity_id, self.hass)
string = string.replace("{entity_name}", name)
return string
async def async_get_arm_mode_string(self, arm_mode: str, language: str):
"""Get translation for alarm arm mode."""
if self._alarmTranslationCache and self._alarmTranslationLang == language:
translations = self._alarmTranslationCache
else:
translations = await async_get_translations(
self.hass, language, "entity_component", ["alarm_control_panel"]
)
self._alarmTranslationCache = translations
self._alarmTranslationLang = language
translation_key = (
f"component.alarm_control_panel.entity_component._.state.{arm_mode}"
if arm_mode
else None
)
if translation_key and translation_key in translations:
return translations[translation_key]
elif arm_mode:
return " ".join(w.capitalize() for w in arm_mode.split("_"))
else:
return ""
+34
View File
@@ -0,0 +1,34 @@
"""WebSocket handler and registration for Alarmo card update events."""
import voluptuous as vol
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.components.websocket_api import decorators, async_register_command
@decorators.websocket_command(
{
vol.Required("type"): "alarmo_updated",
}
)
@decorators.async_response
async def handle_subscribe_updates(hass, connection, msg):
"""Handle subscribe updates."""
@callback
def handle_event(event: str, area_id: str, args: dict = {}):
"""Forward events to websocket."""
data = dict(**args, **{"event": event, "area_id": area_id})
connection.send_message(
{"id": msg["id"], "type": "event", "event": {"data": data}}
)
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
hass, "alarmo_event", handle_event
)
connection.send_result(msg["id"])
async def async_register_card(hass):
"""Publish event to lovelace when alarm changes."""
async_register_command(hass, handle_subscribe_updates)

Some files were not shown because too many files have changed in this diff Show More