Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
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: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+1 -1
View File
@@ -1 +1 @@
{"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784294980.0427423}
{"pid": 72, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784592292.815142}
+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)
+771 -334
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
+6
View File
@@ -4,6 +4,12 @@ default_config:
# Core Utility Meter Engine (Required for Hilo to generate compatible energy dashboard metrics)
utility_meter:
# Load ffmpeg
ffmpeg:
# Enable Streams
stream:
# Load frontend themes from the themes folder
frontend:
themes: !include_dir_merge_named themes
+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)
+30
View File
@@ -0,0 +1,30 @@
"""Config flow for the Alarmo component."""
import secrets
from homeassistant import config_entries
from .const import (
NAME,
DOMAIN,
)
class AlarmoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Alarmo."""
VERSION = "1.0.0"
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
# Only a single instance of the integration
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
id = secrets.token_hex(6)
await self.async_set_unique_id(id)
self._abort_if_unique_id_configured(updates=user_input)
return self.async_create_entry(title=NAME, data={})
+234
View File
@@ -0,0 +1,234 @@
"""Store constants."""
import datetime
import voluptuous as vol
from homeassistant.const import (
ATTR_NAME,
CONF_CODE,
CONF_MODE,
ATTR_ENTITY_ID,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
AlarmControlPanelEntityFeature,
)
VERSION = "1.10.18"
NAME = "Alarmo"
MANUFACTURER = "@nielsfaber"
DOMAIN = "alarmo"
CUSTOM_COMPONENTS = "custom_components"
INTEGRATION_FOLDER = DOMAIN
PANEL_FOLDER = "frontend"
PANEL_FILENAME = "dist/alarm-panel.js"
PANEL_URL = "/api/panel_custom/alarmo"
PANEL_TITLE = NAME
PANEL_ICON = "mdi:shield-home"
PANEL_NAME = "alarm-panel"
INITIALIZATION_TIME = datetime.timedelta(seconds=60)
SENSOR_ARM_TIME = datetime.timedelta(seconds=5)
STATES = [
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelState.DISARMED,
AlarmControlPanelState.TRIGGERED,
AlarmControlPanelState.PENDING,
AlarmControlPanelState.ARMING,
]
ARM_MODES = [
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_VACATION,
]
ARM_MODE_TO_STATE = {
"away": AlarmControlPanelState.ARMED_AWAY,
"home": AlarmControlPanelState.ARMED_HOME,
"night": AlarmControlPanelState.ARMED_NIGHT,
"custom": AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
"vacation": AlarmControlPanelState.ARMED_VACATION,
}
STATE_TO_ARM_MODE = {
AlarmControlPanelState.ARMED_AWAY: "away",
AlarmControlPanelState.ARMED_HOME: "home",
AlarmControlPanelState.ARMED_NIGHT: "night",
AlarmControlPanelState.ARMED_CUSTOM_BYPASS: "custom",
AlarmControlPanelState.ARMED_VACATION: "vacation",
}
COMMAND_ARM_NIGHT = "arm_night"
COMMAND_ARM_AWAY = "arm_away"
COMMAND_ARM_HOME = "arm_home"
COMMAND_ARM_CUSTOM_BYPASS = "arm_custom_bypass"
COMMAND_ARM_VACATION = "arm_vacation"
COMMAND_DISARM = "disarm"
COMMANDS = [
COMMAND_DISARM,
COMMAND_ARM_AWAY,
COMMAND_ARM_NIGHT,
COMMAND_ARM_HOME,
COMMAND_ARM_CUSTOM_BYPASS,
COMMAND_ARM_VACATION,
]
EVENT_DISARM = "disarm"
EVENT_LEAVE = "leave"
EVENT_ARM = "arm"
EVENT_ENTRY = "entry"
EVENT_TRIGGER = "trigger"
EVENT_FAILED_TO_ARM = "failed_to_arm"
EVENT_COMMAND_NOT_ALLOWED = "command_not_allowed"
EVENT_INVALID_CODE_PROVIDED = "invalid_code_provided"
EVENT_NO_CODE_PROVIDED = "no_code_provided"
EVENT_TRIGGER_TIME_EXPIRED = "trigger_time_expired"
EVENT_READY_TO_ARM_MODES_CHANGED = "ready_to_arm_modes_changed"
ATTR_MODES = "modes"
ATTR_ARM_MODE = "arm_mode"
ATTR_CODE_DISARM_REQUIRED = "code_disarm_required"
ATTR_CODE_MODE_CHANGE_REQUIRED = "code_mode_change_required"
ATTR_REMOVE = "remove"
ATTR_OLD_CODE = "old_code"
ATTR_TRIGGER_TIME = "trigger_time"
ATTR_EXIT_TIME = "exit_time"
ATTR_ENTRY_TIME = "entry_time"
ATTR_ENABLED = "enabled"
ATTR_USER_ID = "user_id"
ATTR_CAN_ARM = "can_arm"
ATTR_CAN_DISARM = "can_disarm"
ATTR_DISARM_AFTER_TRIGGER = "disarm_after_trigger"
ATTR_IGNORE_BLOCKING_SENSORS_AFTER_TRIGGER = "ignore_blocking_sensors_after_trigger"
ATTR_REMOVE = "remove"
ATTR_IS_OVERRIDE_CODE = "is_override_code"
ATTR_AREA_LIMIT = "area_limit"
ATTR_CODE_FORMAT = "code_format"
ATTR_CODE_LENGTH = "code_length"
ATTR_AUTOMATION_ID = "automation_id"
ATTR_TYPE = "type"
ATTR_AREA = "area"
ATTR_MASTER = "master"
ATTR_TRIGGERS = "triggers"
ATTR_ACTIONS = "actions"
ATTR_EVENT = "event"
ATTR_REQUIRE_CODE = "require_code"
ATTR_NOTIFICATION = "notification"
ATTR_VERSION = "version"
ATTR_STATE_PAYLOAD = "state_payload"
ATTR_COMMAND_PAYLOAD = "command_payload"
ATTR_FORCE = "force"
ATTR_SKIP_DELAY = "skip_delay"
ATTR_CONTEXT_ID = "context_id"
PUSH_EVENT = "mobile_app_notification_action"
EVENT_ACTION_FORCE_ARM = "ALARMO_FORCE_ARM"
EVENT_ACTION_RETRY_ARM = "ALARMO_RETRY_ARM"
EVENT_ACTION_DISARM = "ALARMO_DISARM"
EVENT_ACTION_ARM_AWAY = "ALARMO_ARM_AWAY"
EVENT_ACTION_ARM_HOME = "ALARMO_ARM_HOME"
EVENT_ACTION_ARM_NIGHT = "ALARMO_ARM_NIGHT"
EVENT_ACTION_ARM_VACATION = "ALARMO_ARM_VACATION"
EVENT_ACTION_ARM_CUSTOM_BYPASS = "ALARMO_ARM_CUSTOM_BYPASS"
EVENT_ACTIONS = [
EVENT_ACTION_FORCE_ARM,
EVENT_ACTION_RETRY_ARM,
EVENT_ACTION_DISARM,
EVENT_ACTION_ARM_AWAY,
EVENT_ACTION_ARM_HOME,
EVENT_ACTION_ARM_NIGHT,
EVENT_ACTION_ARM_VACATION,
EVENT_ACTION_ARM_CUSTOM_BYPASS,
]
MODES_TO_SUPPORTED_FEATURES = {
AlarmControlPanelState.ARMED_AWAY: AlarmControlPanelEntityFeature.ARM_AWAY,
AlarmControlPanelState.ARMED_HOME: AlarmControlPanelEntityFeature.ARM_HOME,
AlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelEntityFeature.ARM_NIGHT,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, # noqa: E501
AlarmControlPanelState.ARMED_VACATION: AlarmControlPanelEntityFeature.ARM_VACATION,
}
SERVICE_ARM = "arm"
SERVICE_DISARM = "disarm"
SERVICE_SKIP_DELAY = "skip_delay"
CONF_ALARM_ARMED_AWAY = "armed_away"
CONF_ALARM_ARMED_CUSTOM_BYPASS = "armed_custom_bypass"
CONF_ALARM_ARMED_HOME = "armed_home"
CONF_ALARM_ARMED_NIGHT = "armed_night"
CONF_ALARM_ARMED_VACATION = "armed_vacation"
CONF_ALARM_ARMING = "arming"
CONF_ALARM_DISARMED = "disarmed"
CONF_ALARM_PENDING = "pending"
CONF_ALARM_TRIGGERED = "triggered"
SERVICE_ARM_SCHEMA = cv.make_entity_service_schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_CODE, default=""): cv.string,
vol.Optional(CONF_MODE, default=AlarmControlPanelState.ARMED_AWAY): vol.In(
[
"away",
"home",
"night",
"custom",
"vacation",
CONF_ALARM_ARMED_AWAY,
CONF_ALARM_ARMED_HOME,
CONF_ALARM_ARMED_NIGHT,
CONF_ALARM_ARMED_CUSTOM_BYPASS,
CONF_ALARM_ARMED_VACATION,
]
),
vol.Optional(ATTR_SKIP_DELAY, default=False): cv.boolean,
vol.Optional(ATTR_FORCE, default=False): cv.boolean,
vol.Optional(ATTR_CONTEXT_ID): int,
}
)
SERVICE_DISARM_SCHEMA = cv.make_entity_service_schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_CODE, default=""): cv.string,
vol.Optional(ATTR_CONTEXT_ID): int,
}
)
SERVICE_SKIP_DELAY_SCHEMA = cv.make_entity_service_schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
}
)
SERVICE_ENABLE_USER = "enable_user"
SERVICE_DISABLE_USER = "disable_user"
SERVICE_TOGGLE_USER_SCHEMA = vol.Schema(
{
vol.Required(ATTR_NAME, default=""): cv.string,
}
)
+101
View File
@@ -0,0 +1,101 @@
"""fire events in HA for use with automations."""
import logging
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import const
_LOGGER = logging.getLogger(__name__)
class EventHandler:
"""Class to handle events from Alarmo and fire HA events."""
def __init__(self, hass):
"""Class constructor."""
self.hass = hass
self._subscription = async_dispatcher_connect(
self.hass, "alarmo_event", self.async_handle_event
)
def __del__(self):
"""Class destructor."""
self._subscription()
@callback
def async_handle_event(self, event: str, area_id: str, args: dict = {}):
"""Handle event."""
if area_id:
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
else:
alarm_entity = self.hass.data[const.DOMAIN]["master"]
if alarm_entity is None:
_LOGGER.warning(
"Cannot resolve alarm_entity for area_id: %s (event: %s)",
area_id,
event,
)
return
if event in [
const.EVENT_FAILED_TO_ARM,
const.EVENT_COMMAND_NOT_ALLOWED,
const.EVENT_INVALID_CODE_PROVIDED,
const.EVENT_NO_CODE_PROVIDED,
]:
reasons = {
const.EVENT_FAILED_TO_ARM: "open_sensors",
const.EVENT_COMMAND_NOT_ALLOWED: "not_allowed",
const.EVENT_INVALID_CODE_PROVIDED: "invalid_code",
const.EVENT_NO_CODE_PROVIDED: "invalid_code",
}
data = dict(
**args,
**{
"area_id": area_id,
"entity_id": alarm_entity.entity_id,
"reason": reasons[event],
},
)
if "open_sensors" in data:
data["sensors"] = list(data["open_sensors"].keys())
del data["open_sensors"]
self.hass.bus.async_fire("alarmo_failed_to_arm", data)
elif event in [const.EVENT_ARM, const.EVENT_DISARM]:
data = dict(
**args,
**{
"area_id": area_id,
"entity_id": alarm_entity.entity_id,
"action": event,
},
)
if "arm_mode" in data:
data["mode"] = const.STATE_TO_ARM_MODE[data["arm_mode"]]
del data["arm_mode"]
self.hass.bus.async_fire("alarmo_command_success", data)
elif event == const.EVENT_READY_TO_ARM_MODES_CHANGED:
supported_modes = dict(
filter(
lambda el: el[1] & alarm_entity.supported_features,
const.MODES_TO_SUPPORTED_FEATURES.items(),
)
)
modes = {
k.value: (k.value in args["modes"]) for k in supported_modes.keys()
}
data = {
"area_id": area_id,
"entity_id": alarm_entity.entity_id,
**modes,
}
self.hass.bus.async_fire("alarmo_ready_to_arm_modes_updated", data)
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
"""Helper functions for Alarmo integration."""
from homeassistant.core import (
HomeAssistant,
)
def friendly_name_for_entity_id(entity_id: str, hass: HomeAssistant):
"""Helper to get friendly name for entity."""
state = hass.states.get(entity_id)
if state and state.attributes.get("friendly_name"):
return state.attributes["friendly_name"]
return entity_id
def omit(obj: dict, blacklisted_keys: list):
"""Helper to omit blacklisted keys from a dict."""
return {key: val for key, val in obj.items() if key not in blacklisted_keys}
+8
View File
@@ -0,0 +1,8 @@
{
"services": {
"arm": "mdi:shield-lock",
"disarm": "mdi:shield-off",
"enable_user": "mdi:account-lock-open",
"disable_user": "mdi:account-lock-closed"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"domain": "alarmo",
"name": "Alarmo",
"after_dependencies": [
"mqtt",
"notify"
],
"codeowners": [
"@nielsfaber"
],
"config_flow": true,
"dependencies": [
"http",
"panel_custom"
],
"documentation": "https://github.com/nielsfaber/alarmo",
"iot_class": "local_push",
"issue_tracker": "https://github.com/nielsfaber/alarmo/issues",
"requirements": [],
"version": "1.10.18"
}
+319
View File
@@ -0,0 +1,319 @@
"""Class to handle MQTT integration."""
import json
import logging
from homeassistant.core import (
HomeAssistant,
callback,
)
from homeassistant.util import slugify
from homeassistant.components import mqtt
from homeassistant.helpers.json import JSONEncoder
from homeassistant.components.mqtt import (
DOMAIN as ATTR_MQTT,
)
from homeassistant.components.mqtt import (
CONF_STATE_TOPIC,
CONF_COMMAND_TOPIC,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from . import const
from .helpers import (
friendly_name_for_entity_id,
)
_LOGGER = logging.getLogger(__name__)
CONF_EVENT_TOPIC = "event_topic"
class MqttHandler:
"""Class to handle MQTT integration."""
def __init__(self, hass: HomeAssistant): # noqa: PLR0915
"""Class constructor."""
self.hass = hass
self._config = None
self._subscribed_topics = []
self._subscriptions = []
@callback
def async_update_config(_args=None):
"""Mqtt config updated, reload the configuration."""
old_config = self._config
new_config = self.hass.data[const.DOMAIN][
"coordinator"
].store.async_get_config()
if old_config and old_config[ATTR_MQTT] == new_config[ATTR_MQTT]:
# only update MQTT config if some parameters are changed
return
self._config = new_config
if (
not old_config
or old_config[ATTR_MQTT][CONF_COMMAND_TOPIC]
!= new_config[ATTR_MQTT][CONF_COMMAND_TOPIC]
):
# re-subscribing is only needed if the command topic has changed
self.hass.add_job(self._async_subscribe_topics())
_LOGGER.debug("MQTT config was (re)loaded")
self._subscriptions.append(
async_dispatcher_connect(hass, "alarmo_config_updated", async_update_config)
)
async_update_config()
@callback
def async_alarm_state_changed(area_id: str, old_state: str, new_state: str):
if not self._config[ATTR_MQTT][const.ATTR_ENABLED]:
return
topic = self._config[ATTR_MQTT][CONF_STATE_TOPIC]
if not topic: # do not publish if no topic is provided
return
if area_id and len(self.hass.data[const.DOMAIN]["areas"]) > 1:
# handle the sending of a state update for a specific area
area = self.hass.data[const.DOMAIN]["areas"][area_id]
topic = topic.rsplit("/", 1)
topic.insert(1, slugify(area.name))
topic = "/".join(topic)
payload_config = self._config[ATTR_MQTT][const.ATTR_STATE_PAYLOAD]
if payload_config.get(new_state):
message = payload_config[new_state]
else:
message = new_state
hass.async_create_task(
mqtt.async_publish(self.hass, topic, message, retain=True)
)
_LOGGER.debug(
"Published state '%s' on topic '%s'",
message,
topic,
)
self._subscriptions.append(
async_dispatcher_connect(
self.hass, "alarmo_state_updated", async_alarm_state_changed
)
)
@callback
def async_handle_event(event: str, area_id: str, args: dict = {}):
if not self._config[ATTR_MQTT][const.ATTR_ENABLED]:
return
topic = self._config[ATTR_MQTT][CONF_EVENT_TOPIC]
if not topic: # do not publish if no topic is provided
return
if area_id and len(self.hass.data[const.DOMAIN]["areas"]) > 1:
# handle the sending of a state update for a specific area
area = self.hass.data[const.DOMAIN]["areas"][area_id]
topic = topic.rsplit("/", 1)
topic.insert(1, slugify(area.name))
topic = "/".join(topic)
if event == const.EVENT_ARM:
payload = {
"event": f"{event.upper()}_{args['arm_mode'].split('_', 1).pop(1).upper()}", # noqa: E501
"delay": args["delay"],
}
elif event == const.EVENT_TRIGGER:
payload = {
"event": event.upper(),
"delay": args["delay"],
"sensors": [
{
"entity_id": entity,
"name": friendly_name_for_entity_id(entity, self.hass),
}
for (entity, state) in args["open_sensors"].items()
],
}
elif event == const.EVENT_FAILED_TO_ARM:
payload = {
"event": event.upper(),
"sensors": [
{
"entity_id": entity,
"name": friendly_name_for_entity_id(entity, self.hass),
}
for (entity, state) in args["open_sensors"].items()
],
}
elif event == const.EVENT_COMMAND_NOT_ALLOWED:
payload = {
"event": event.upper(),
"state": args["state"],
"command": args["command"].upper(),
}
elif event in [
const.EVENT_INVALID_CODE_PROVIDED,
const.EVENT_NO_CODE_PROVIDED,
]:
payload = {"event": event.upper()}
else:
return
payload = json.dumps(payload, cls=JSONEncoder)
hass.async_create_task(mqtt.async_publish(self.hass, topic, payload))
self._subscriptions.append(
async_dispatcher_connect(self.hass, "alarmo_event", async_handle_event)
)
def __del__(self):
"""Prepare for removal."""
while len(self._subscribed_topics):
self._subscribed_topics.pop()()
while len(self._subscriptions):
self._subscriptions.pop()()
async def _async_subscribe_topics(self):
"""Install a listener for the command topic."""
if len(self._subscribed_topics):
while len(self._subscribed_topics):
self._subscribed_topics.pop()()
_LOGGER.debug("Removed subscribed topics")
if not self._config[ATTR_MQTT][const.ATTR_ENABLED]:
return
self._subscribed_topics.append(
await mqtt.async_subscribe(
self.hass,
self._config[ATTR_MQTT][CONF_COMMAND_TOPIC],
self.async_message_received,
)
)
_LOGGER.debug(
"Subscribed to topic %s",
self._config[ATTR_MQTT][CONF_COMMAND_TOPIC],
)
@callback
async def async_message_received(self, msg): # noqa: PLR0915, PLR0912
"""Handle new MQTT messages."""
command = None
code = None
area = None
bypass_open_sensors = False
skip_delay = False
try:
payload = json.loads(msg.payload)
payload = {k.lower(): v for k, v in payload.items()}
if "command" in payload:
command = payload["command"]
elif "cmd" in payload:
command = payload["cmd"]
elif "action" in payload:
command = payload["action"]
elif "state" in payload:
command = payload["state"]
if "code" in payload:
code = payload["code"]
elif "pin" in payload:
code = payload["pin"]
elif "password" in payload:
code = payload["password"]
elif "pincode" in payload:
code = payload["pincode"]
if payload.get("area"):
area = payload["area"]
if (payload.get("bypass_open_sensors")) or (payload.get("force")):
bypass_open_sensors = payload["bypass_open_sensors"]
if payload.get(const.ATTR_SKIP_DELAY):
skip_delay = payload[const.ATTR_SKIP_DELAY]
except ValueError:
# no JSON structure found
command = msg.payload
code = None
if type(command) is str:
command = command.lower()
else:
_LOGGER.warning("Received unexpected command")
return
payload_config = self._config[ATTR_MQTT][const.ATTR_COMMAND_PAYLOAD]
skip_code = not self._config[ATTR_MQTT][const.ATTR_REQUIRE_CODE]
command_payloads = {}
for item in const.COMMANDS:
if payload_config.get(item):
command_payloads[item] = payload_config[item].lower()
else:
command_payloads[item] = item.lower()
if command not in list(command_payloads.values()):
_LOGGER.warning("Received unexpected command: %s", command)
return
if area:
res = list(
filter(
lambda el: slugify(el.name) == area,
self.hass.data[const.DOMAIN]["areas"].values(),
)
)
if not res:
_LOGGER.warning(
"Area %s does not exist",
area,
)
return
entity = res[0]
elif (
self._config[const.ATTR_MASTER][const.ATTR_ENABLED]
and len(self.hass.data[const.DOMAIN]["areas"]) > 1
):
entity = self.hass.data[const.DOMAIN]["master"]
elif len(self.hass.data[const.DOMAIN]["areas"]) == 1:
entity = next(iter(self.hass.data[const.DOMAIN]["areas"].values()))
else:
_LOGGER.warning("No area specified")
return
_LOGGER.debug(
"Received command %s",
command,
)
if command == command_payloads[const.COMMAND_DISARM]:
entity.alarm_disarm(code, skip_code=skip_code)
elif command == command_payloads[const.COMMAND_ARM_AWAY]:
await entity.async_alarm_arm_away(
code, skip_code, bypass_open_sensors, skip_delay
)
elif command == command_payloads[const.COMMAND_ARM_NIGHT]:
await entity.async_alarm_arm_night(
code, skip_code, bypass_open_sensors, skip_delay
)
elif command == command_payloads[const.COMMAND_ARM_HOME]:
await entity.async_alarm_arm_home(
code, skip_code, bypass_open_sensors, skip_delay
)
elif command == command_payloads[const.COMMAND_ARM_CUSTOM_BYPASS]:
await entity.async_alarm_arm_custom_bypass(
code, skip_code, bypass_open_sensors, skip_delay
)
elif command == command_payloads[const.COMMAND_ARM_VACATION]:
await entity.async_alarm_arm_vacation(
code, skip_code, bypass_open_sensors, skip_delay
)
+56
View File
@@ -0,0 +1,56 @@
"""Panel registration for Alarmo integration."""
import os
import logging
from homeassistant.components import frontend, panel_custom
from homeassistant.components.http import StaticPathConfig
from .const import (
DOMAIN,
VERSION,
PANEL_URL,
PANEL_ICON,
PANEL_NAME,
PANEL_TITLE,
PANEL_FOLDER,
PANEL_FILENAME,
CUSTOM_COMPONENTS,
INTEGRATION_FOLDER,
)
_LOGGER = logging.getLogger(__name__)
async def async_register_panel(hass):
"""Register the panel."""
root_dir = os.path.join(hass.config.path(CUSTOM_COMPONENTS), INTEGRATION_FOLDER)
panel_dir = os.path.join(root_dir, PANEL_FOLDER)
view_url = os.path.join(panel_dir, PANEL_FILENAME)
try:
cache_bust = int(os.path.getmtime(view_url))
except OSError:
cache_bust = 0
await hass.http.async_register_static_paths(
[StaticPathConfig(PANEL_URL, view_url, cache_headers=False)]
)
await panel_custom.async_register_panel(
hass,
webcomponent_name=PANEL_NAME,
frontend_url_path=DOMAIN,
module_url=f"{PANEL_URL}?v={VERSION}&m={cache_bust}",
sidebar_title=PANEL_TITLE,
sidebar_icon=PANEL_ICON,
require_admin=True,
config={},
config_panel_domain=DOMAIN,
)
def async_unregister_panel(hass):
"""Unregister the panel."""
frontend.async_remove_panel(hass, DOMAIN)
_LOGGER.debug("Removing panel")
+774
View File
@@ -0,0 +1,774 @@
"""Sensor handling for Alarmo integration."""
import logging
import datetime
from types import SimpleNamespace
import homeassistant.util.dt as dt_util
from homeassistant.core import (
CoreState,
HomeAssistant,
callback,
)
from homeassistant.const import (
STATE_ON,
ATTR_NAME,
STATE_OFF,
ATTR_STATE,
STATE_OPEN,
STATE_CLOSED,
STATE_UNKNOWN,
STATE_UNAVAILABLE,
ATTR_LAST_TRIP_TIME,
EVENT_HOMEASSISTANT_STARTED,
)
from homeassistant.helpers.event import (
async_track_point_in_time,
async_track_state_change_event,
)
from homeassistant.components.lock import LockState
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
)
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
from . import const
ATTR_USE_EXIT_DELAY = "use_exit_delay"
ATTR_USE_ENTRY_DELAY = "use_entry_delay"
ATTR_ALWAYS_ON = "always_on"
ATTR_ARM_ON_CLOSE = "arm_on_close"
ATTR_ALLOW_OPEN = "allow_open"
ATTR_TRIGGER_UNAVAILABLE = "trigger_unavailable"
ATTR_AUTO_BYPASS = "auto_bypass"
ATTR_AUTO_BYPASS_MODES = "auto_bypass_modes"
ATTR_GROUP = "group"
ATTR_GROUP_ID = "group_id"
ATTR_TIMEOUT = "timeout"
ATTR_EVENT_COUNT = "event_count"
ATTR_ENTITIES = "entities"
ATTR_NEW_ENTITY_ID = "new_entity_id"
ATTR_ENTRY_DELAY = "entry_delay"
ATTR_DELAY_ON = "delay_on"
SENSOR_STATES_OPEN = [STATE_ON, STATE_OPEN, LockState.UNLOCKED]
SENSOR_STATES_CLOSED = [STATE_OFF, STATE_CLOSED, LockState.LOCKED]
SENSOR_TYPE_DOOR = "door"
SENSOR_TYPE_WINDOW = "window"
SENSOR_TYPE_MOTION = "motion"
SENSOR_TYPE_TAMPER = "tamper"
SENSOR_TYPE_ENVIRONMENTAL = "environmental"
SENSOR_TYPE_OTHER = "other"
SENSOR_TYPES = [
SENSOR_TYPE_DOOR,
SENSOR_TYPE_WINDOW,
SENSOR_TYPE_MOTION,
SENSOR_TYPE_TAMPER,
SENSOR_TYPE_ENVIRONMENTAL,
SENSOR_TYPE_OTHER,
]
_LOGGER = logging.getLogger(__name__)
def parse_sensor_state(state):
"""Parse the state of a sensor into open/closed/unavailable/unknown."""
if not state or not state.state:
return STATE_UNAVAILABLE
elif state.state == STATE_UNAVAILABLE:
return STATE_UNAVAILABLE
elif state.state in SENSOR_STATES_OPEN:
return STATE_OPEN
elif state.state in SENSOR_STATES_CLOSED:
return STATE_CLOSED
else:
return STATE_UNKNOWN
def sensor_state_allowed(state, sensor_config, alarm_state): # noqa: PLR0911
"""Return whether the sensor state is permitted or a state change should occur."""
if state != STATE_OPEN and (
state != STATE_UNAVAILABLE or not sensor_config[ATTR_TRIGGER_UNAVAILABLE]
):
# sensor has the safe state
return True
elif alarm_state == AlarmControlPanelState.TRIGGERED:
# alarm is already triggered
return True
elif sensor_config[ATTR_ALWAYS_ON]:
# alarm should always be triggered by always-on sensor
return False
elif (
alarm_state == AlarmControlPanelState.ARMING
and not sensor_config[ATTR_USE_EXIT_DELAY]
):
# arming should be aborted if sensor without exit delay is active
return False
elif alarm_state in const.ARM_MODES:
# normal triggering case
return False
elif alarm_state == AlarmControlPanelState.PENDING:
# Allow both immediate and delayed sensors
# during pending for timer shortening/immediate trigger
# This enables per-sensor entry delay logic
# to process subsequent triggers during countdown
return False
else:
return True
class SensorHandler:
"""Class to handle sensors for Alarmo."""
def __init__(self, hass: HomeAssistant):
"""Initialize the sensor handler."""
self._config = None
self.hass = hass
self._state_listener = None
self._subscriptions = []
self._arm_timers = {}
self._delay_on_timers = {}
self._groups = {}
self._group_events = {}
self._startup_complete = False
self._unavailable_state_mem = {}
@callback
def async_update_sensor_config():
"""Sensor config updated, reload the configuration."""
self._config = self.hass.data[const.DOMAIN][
"coordinator"
].store.async_get_sensors()
self._groups = self.hass.data[const.DOMAIN][
"coordinator"
].store.async_get_sensor_groups()
self._group_events = {}
self.async_watch_sensor_states()
# Store the callback for later registration
self._async_update_sensor_config = async_update_sensor_config
@callback
def _setup_sensor_listeners():
"""Register sensor listeners and perform initial setup."""
self._subscriptions.append(
async_dispatcher_connect(
hass, "alarmo_state_updated", self.async_watch_sensor_states
)
)
self._subscriptions.append(
async_dispatcher_connect(
hass, "alarmo_sensors_updated", self._async_update_sensor_config
)
)
# Do the initial sensor setup now that HA is running
self._async_update_sensor_config()
# Evaluate initial sensor states for all areas on startup
for area_id in self.hass.data[const.DOMAIN]["areas"].keys():
self.update_ready_to_arm_status(area_id)
# If area is armed, validate sensors and trigger if needed
# Schedule this to run in the event loop since it may call async methods
hass.async_create_task(
self._async_evaluate_armed_state_on_startup(area_id)
)
def handle_startup(_event):
self._startup_complete = True
# Schedule the setup to run in the event loop (from thread pool executor)
hass.loop.call_soon_threadsafe(_setup_sensor_listeners)
if hass.state == CoreState.running:
self._startup_complete = True
# Schedule in event loop since we're in __init__ (sync context)
hass.loop.call_soon_threadsafe(_setup_sensor_listeners)
else:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, handle_startup)
def __del__(self):
"""Prepare for removal."""
if self._state_listener:
self._state_listener()
self._state_listener = None
while len(self._subscriptions):
self._subscriptions.pop()()
def async_watch_sensor_states(
self,
area_id: str | None = None,
old_state: str | None = None,
state: str | None = None,
):
"""Watch sensors based on the state of the alarm entities."""
watched_sensors_list = []
for area in self.hass.data[const.DOMAIN]["areas"].keys():
watched_sensors_list.extend(
self.active_sensors_for_alarm_state(area, None, True)
)
if self._state_listener:
self._state_listener()
if watched_sensors_list:
self._state_listener = async_track_state_change_event(
self.hass, watched_sensors_list, self.async_sensor_state_changed
)
else:
self._state_listener = None
# clear previous sensor group events that are not active for current alarm state
if self._group_events:
active_sensors_list = []
for area in self.hass.data[const.DOMAIN]["areas"].keys():
active_sensors_list.extend(
self.active_sensors_for_alarm_state(area, None, False)
)
for group_id in self._group_events.keys():
self._group_events[group_id] = dict(
filter(
lambda el: el[0] in active_sensors_list,
self._group_events[group_id].items(),
)
)
# handle initial sensor states
if area_id and old_state is None:
sensors_list = self.active_sensors_for_alarm_state(area_id)
for entity in sensors_list:
state = self.hass.states.get(entity)
sensor_state = parse_sensor_state(state)
if state and state.state and sensor_state != STATE_UNKNOWN:
_LOGGER.debug(
"Initial state for %s is %s",
entity,
parse_sensor_state(state),
)
if area_id:
self.update_ready_to_arm_status(area_id)
def active_sensors_for_alarm_state(
self,
area_id: str,
to_state: str | None = None,
watch_ready_to_arm: bool = False,
):
"""Compose a list of sensors that are active for the state."""
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
if to_state:
state = to_state
else:
state = (
alarm_entity.arm_mode if alarm_entity.arm_mode else alarm_entity.state
)
entities = []
for entity, config in self._config.items():
if config["area"] != area_id or not config["enabled"]:
continue
elif (
alarm_entity.bypassed_sensors
and entity in alarm_entity.bypassed_sensors
):
continue
elif state in config[const.ATTR_MODES] or config[ATTR_ALWAYS_ON]:
entities.append(entity)
elif (
not to_state
and config["type"] != SENSOR_TYPE_MOTION
and watch_ready_to_arm
):
# always watch all sensors other than motion sensors,
# to indicate readiness for arming
entities.append(entity)
return entities
def validate_arming_event(
self, area_id: str, target_state: str | None = None, **kwargs
):
"""Check whether all sensors have the correct state prior to arming."""
use_delay = kwargs.get("use_delay", False)
bypass_open_sensors = kwargs.get("bypass_open_sensors", False)
sensors_list = self.active_sensors_for_alarm_state(area_id, target_state)
open_sensors = {}
bypassed_sensors = []
alarm_state = target_state
if use_delay and alarm_state in const.ARM_MODES:
alarm_state = AlarmControlPanelState.ARMING
elif use_delay and alarm_state == AlarmControlPanelState.TRIGGERED:
alarm_state = AlarmControlPanelState.PENDING
for entity in sensors_list:
sensor_config = self._config[entity]
state = self.hass.states.get(entity)
sensor_state = parse_sensor_state(state)
if not state or not state.state:
# entity does not exist in HA
res = False
else:
res = sensor_state_allowed(sensor_state, sensor_config, alarm_state)
if not res and target_state in const.ARM_MODES:
# sensor is active while arming
if bypass_open_sensors or (
sensor_config[ATTR_AUTO_BYPASS]
and target_state in sensor_config[ATTR_AUTO_BYPASS_MODES]
):
# sensor may be bypassed
bypassed_sensors.append(entity)
elif sensor_config[ATTR_ALLOW_OPEN] and sensor_state == STATE_OPEN:
# sensor is permitted to be open during/after arming
continue
else:
open_sensors[entity] = sensor_state
return (open_sensors, bypassed_sensors)
def get_entry_delay_for_trigger(
self, open_sensors: dict[str, str], area_id: str, arm_mode: str
) -> int | None:
"""Calculate entry delay based on type of sensor trigger."""
# Check if this is a group trigger
if ATTR_GROUP_ID in open_sensors:
# For groups: only check for immediate triggers, otherwise use area default
for entity_id in open_sensors:
if entity_id != ATTR_GROUP_ID and entity_id in self._config:
sensor_config = self._config[entity_id]
if not sensor_config[ATTR_USE_ENTRY_DELAY]:
return 0
# Groups always use area default (maintainer's preference)
return None
else:
# Individual sensor trigger
entity_id = next(iter(open_sensors.keys()))
sensor_config = self._config[entity_id]
if not sensor_config[ATTR_USE_ENTRY_DELAY]:
return 0
# Use sensor's entry delay if set
if (
ATTR_ENTRY_DELAY in sensor_config
and sensor_config[ATTR_ENTRY_DELAY] is not None
):
return sensor_config[ATTR_ENTRY_DELAY]
# Fall back to area default (None means use area default)
return None
@callback
def async_sensor_state_changed(self, event): # noqa: PLR0912
"""Callback fired when a sensor state has changed."""
entity = event.data["entity_id"]
old_state = parse_sensor_state(event.data["old_state"])
new_state = parse_sensor_state(event.data["new_state"])
sensor_config = self._config[entity]
if old_state == STATE_UNKNOWN:
# sensor is unknown at startup,
# state which comes after is considered as initial state
_LOGGER.debug(
"Initial state for %s is %s",
entity,
new_state,
)
self.update_ready_to_arm_status(sensor_config["area"])
return
if old_state == new_state:
# not a state change - ignore
return
_LOGGER.debug(
"entity %s changed: old_state=%s, new_state=%s",
entity,
old_state,
new_state,
)
# Cancel any pending delay_on timer when sensor turns off
if new_state == STATE_CLOSED and entity in self._delay_on_timers:
self._stop_entity_timer(self._delay_on_timers, entity)
if (
new_state == STATE_UNAVAILABLE
and not sensor_config[ATTR_TRIGGER_UNAVAILABLE]
):
# temporarily store the prior state until the sensor becomes available again
self._unavailable_state_mem[entity] = old_state
elif entity in self._unavailable_state_mem:
# if sensor was unavailable, check the state before that,
# do not act if the sensor reverted to its prior state.
prior_state = self._unavailable_state_mem.pop(entity)
if old_state == STATE_UNAVAILABLE and prior_state == new_state:
_LOGGER.debug(
"state transition from %s to %s to %s detected, ignoring.",
prior_state,
old_state,
new_state,
)
return
alarm_entity = self.hass.data[const.DOMAIN]["areas"][sensor_config["area"]]
alarm_state = alarm_entity.state
if (
alarm_entity.arm_mode
and alarm_entity.arm_mode not in sensor_config[const.ATTR_MODES]
and not sensor_config[ATTR_ALWAYS_ON]
):
# sensor is not active in this arm mode, ignore
self.update_ready_to_arm_status(sensor_config["area"])
return
res = sensor_state_allowed(new_state, sensor_config, alarm_state)
if (
sensor_config[ATTR_ARM_ON_CLOSE]
and alarm_state == AlarmControlPanelState.ARMING
):
# we are arming and sensor is configured to arm on closing
if new_state == STATE_CLOSED:
self.start_arm_timer(entity)
else:
self.stop_arm_timer(entity)
if res:
# nothing to do here, sensor state is OK
self.update_ready_to_arm_status(sensor_config["area"])
return
# Check if delay_on is configured
delay_on = sensor_config.get(ATTR_DELAY_ON) or 0
if delay_on > 0:
# Start delay_on timer instead of immediate trigger
if self._start_delay_on_timer(entity, new_state):
self.update_ready_to_arm_status(sensor_config["area"])
return
# No trigger delay or timer failed to start - execute immediately
self._execute_sensor_trigger(entity, new_state)
def _start_entity_timer(
self,
timers: dict,
entity: str,
delay: datetime.timedelta,
timer_callback,
) -> None:
"""Start a timer for an entity, cancelling any existing timer first."""
self._stop_entity_timer(timers, entity)
@callback
def timer_finished(_now):
timers.pop(entity, None)
timer_callback()
timers[entity] = async_track_point_in_time(
self.hass, timer_finished, dt_util.utcnow() + delay
)
def _stop_entity_timer(self, timers: dict, entity: str | None = None) -> None:
"""Cancel timer(s) for entities."""
if entity:
if cancel := timers.pop(entity, None):
cancel()
else:
for cancel in timers.values():
cancel()
timers.clear()
def start_arm_timer(self, entity):
"""Start timer for automatical arming."""
def on_arm_timer_finished():
_LOGGER.debug("arm timer finished")
sensor_config = self._config[entity]
alarm_entity = self.hass.data[const.DOMAIN]["areas"][sensor_config["area"]]
if alarm_entity.state == AlarmControlPanelState.ARMING:
alarm_entity.async_arm(alarm_entity.arm_mode, skip_delay=True)
self._start_entity_timer(
self._arm_timers, entity, const.SENSOR_ARM_TIME, on_arm_timer_finished
)
def stop_arm_timer(self, entity=None):
"""Cancel timer(s) for automatical arming."""
self._stop_entity_timer(self._arm_timers, entity)
def _start_delay_on_timer(self, entity: str, new_state: str):
"""Start timer for delayed sensor trigger."""
sensor_config = self._config[entity]
delay = sensor_config.get(ATTR_DELAY_ON) or 0
if delay <= 0:
return False
def on_delay_on_finished():
"""Handle delay_on timer expiration."""
# Re-check if sensor is still in violation state
current_state = self.hass.states.get(entity)
current_sensor_state = parse_sensor_state(current_state)
if current_sensor_state != STATE_OPEN and not (
current_sensor_state == STATE_UNAVAILABLE
and sensor_config[ATTR_TRIGGER_UNAVAILABLE]
):
_LOGGER.debug(
"delay_on finished for %s but sensor no longer in violation",
entity,
)
return
_LOGGER.debug(
"delay_on finished for %s, proceeding with trigger",
entity,
)
self._execute_sensor_trigger(entity, new_state)
self._start_entity_timer(
self._delay_on_timers,
entity,
datetime.timedelta(seconds=delay),
on_delay_on_finished,
)
_LOGGER.debug(
"Started delay_on timer for %s (%s seconds)",
entity,
delay,
)
return True
def _execute_sensor_trigger(self, entity: str, new_state: str):
"""Execute sensor trigger logic (called directly or after trigger delay)."""
sensor_config = self._config[entity]
alarm_entity = self.hass.data[const.DOMAIN]["areas"][sensor_config["area"]]
alarm_state = alarm_entity.state
open_sensors = self.process_group_event(entity, new_state)
if not open_sensors:
# triggered sensor is part of a group and should be ignored
self.update_ready_to_arm_status(sensor_config["area"])
return
if sensor_config[ATTR_ALWAYS_ON]:
# immediate trigger due to always on sensor
_LOGGER.info(
"Alarm is triggered due to an always-on sensor: %s",
entity,
)
alarm_entity.async_trigger(entry_delay=0, open_sensors=open_sensors)
elif alarm_state == AlarmControlPanelState.ARMING:
# sensor triggered while arming, abort arming
_LOGGER.debug(
"Arming was aborted due to a sensor being active: %s",
entity,
)
alarm_entity.async_arm_failure(open_sensors)
elif alarm_state in const.ARM_MODES:
# standard alarm trigger - calculate entry delay override
_LOGGER.info(
"Alarm is triggered due to sensor: %s",
entity,
)
entry_delay = self.get_entry_delay_for_trigger(
open_sensors, sensor_config["area"], alarm_entity.arm_mode
)
# remove group_id from open_sensors (only used for entry delay calculation)
open_sensors.pop(ATTR_GROUP_ID, None)
if entry_delay == 0:
# immediate trigger (no entry delay)
alarm_entity.async_trigger(entry_delay=0, open_sensors=open_sensors)
else:
# use calculated delay (could be None for area default)
alarm_entity.async_trigger(
entry_delay=entry_delay, open_sensors=open_sensors
)
elif alarm_state == AlarmControlPanelState.PENDING:
# trigger while in pending state
# calculate entry delay for possible timer shortening
_LOGGER.info(
"Alarm is triggered due to sensor: %s",
entity,
)
entry_delay = self.get_entry_delay_for_trigger(
open_sensors, sensor_config["area"], alarm_entity.arm_mode
)
# remove group_id from open_sensors (only used for entry delay calculation)
open_sensors.pop(ATTR_GROUP_ID, None)
if entry_delay == 0:
# immediate trigger
alarm_entity.async_trigger(entry_delay=0, open_sensors=open_sensors)
else:
# use calculated delay for possible timer shortening
alarm_entity.async_trigger(
entry_delay=entry_delay, open_sensors=open_sensors
)
self.update_ready_to_arm_status(sensor_config["area"])
def process_group_event(self, entity: str, state: str) -> dict:
"""Check if sensor entity is member of a group to evaluate trigger."""
group_id = None
for group in self._groups.values():
if entity in group[ATTR_ENTITIES]:
group_id = group[ATTR_GROUP_ID]
break
open_sensors = {entity: state}
if group_id is None:
return open_sensors
group = self._groups[group_id]
group_events = (
self._group_events[group_id]
if group_id in self._group_events.keys()
else {}
)
now = dt_util.now()
group_events[entity] = {ATTR_STATE: state, ATTR_LAST_TRIP_TIME: now}
self._group_events[group_id] = group_events
recent_events = {
entity: (now - event[ATTR_LAST_TRIP_TIME]).total_seconds()
for (entity, event) in group_events.items()
}
recent_events = dict(
filter(lambda el: el[1] <= group[ATTR_TIMEOUT], recent_events.items())
)
if len(recent_events.keys()) < group[ATTR_EVENT_COUNT]:
_LOGGER.debug(
"tripped sensor %s was ignored since it belongs to group %s",
entity,
group[ATTR_NAME],
)
return {}
else:
# add all (recently) triggered sensors to open_sensors
for entity_id in recent_events.keys():
open_sensors[entity_id] = group_events[entity_id][ATTR_STATE]
# Add group info for override delay calculation
open_sensors[ATTR_GROUP_ID] = group_id
_LOGGER.debug(
"tripped sensor %s caused the triggering of group %s",
entity,
group[ATTR_NAME],
)
return open_sensors
def update_ready_to_arm_status(self, area_id):
"""Calculate whether the system is ready for arming."""
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
arm_modes = [
mode
for (mode, config) in alarm_entity._config[const.ATTR_MODES].items()
if config[const.ATTR_ENABLED]
]
if alarm_entity.state in const.ARM_MODES or (
alarm_entity.state == AlarmControlPanelState.ARMING
and alarm_entity.arm_mode
):
arm_modes.remove(alarm_entity.arm_mode)
def arm_mode_is_ready(mode):
(blocking_sensors, _bypassed_sensors) = self.validate_arming_event(
area_id, mode
)
if alarm_entity.state == AlarmControlPanelState.DISARMED:
# exclude motion sensors when determining readiness
blocking_sensors = dict(
filter(
lambda el: self._config[el[0]]["type"] != SENSOR_TYPE_MOTION,
blocking_sensors.items(),
)
)
result = not (blocking_sensors)
return result
arm_modes = list(filter(arm_mode_is_ready, arm_modes))
prev_arm_modes = alarm_entity._ready_to_arm_modes
if arm_modes != prev_arm_modes:
alarm_entity.update_ready_to_arm_modes(arm_modes)
async def _async_evaluate_armed_state_on_startup(self, area_id):
"""Evaluate sensors when alarm is armed on startup and trigger if necessary.
On startup, we don't know the actual previous state of sensors
(they might have changed while HA was down).
This method simulates state changes for all sensors currently in violation,
allowing the standard async_sensor_state_changed logic to re-evaluate them
with full group logic, entry delays, etc.
"""
alarm_entity = self.hass.data[const.DOMAIN]["areas"][area_id]
# Only evaluate if the alarm is in an armed state
if alarm_entity.state not in const.ARM_MODES:
return
_LOGGER.debug(
"Evaluating sensors on startup for area %s (state: %s)",
area_id,
alarm_entity.state,
)
# Get all active sensors for the current armed mode
sensors_list = self.active_sensors_for_alarm_state(area_id)
for entity_id in sensors_list:
sensor_config = self._config[entity_id]
state = self.hass.states.get(entity_id)
sensor_state = parse_sensor_state(state)
if sensor_state == STATE_UNKNOWN:
# Skip unknown sensors - they'll be handled when they become known
continue
# Check if sensor state is allowed in current alarm state
res = sensor_state_allowed(sensor_state, sensor_config, alarm_entity.state)
if not res:
# Sensor is in a violation state
# (open or unavailable when it shouldn't be)
# Simulate a state change to trigger standard processing
_LOGGER.info(
"Sensor %s is %s on startup while alarm is %s - simulating state change for evaluation", # noqa: E501
entity_id,
sensor_state,
alarm_entity.state,
)
# Create a synthetic event that mimics
# a state change from closed to current state
# We use STATE_CLOSED as old state
# (not STATE_UNKNOWN which would trigger early return)
old_state = SimpleNamespace(state=STATE_CLOSED)
# Create event with the structure expected by async_sensor_state_changed
event = SimpleNamespace(
data={
"entity_id": entity_id,
"old_state": old_state,
"new_state": state,
}
)
# Process through the standard sensor state change handler
# This will handle groups, entry delays, always-on sensors, etc.
self.async_sensor_state_changed(event)
+77
View File
@@ -0,0 +1,77 @@
arm:
fields:
entity_id:
example: "alarm_control_panel.alarm"
required: true
selector:
entity:
integration: alarmo
domain: alarm_control_panel
code:
example: "1234"
required: false
selector:
text:
mode:
example: "away"
required: false
default: away
selector:
select:
translation_key: "arm_mode"
options:
- away
- night
- home
- vacation
- custom
skip_delay:
example: false
required: false
default: false
selector:
boolean:
force:
example: false
required: false
default: false
selector:
boolean:
disarm:
fields:
entity_id:
example: "alarm_control_panel.alarm"
required: true
selector:
entity:
integration: alarmo
domain: alarm_control_panel
code:
example: "1234"
required: false
selector:
text:
skip_delay:
fields:
entity_id:
example: "alarm_control_panel.alarm"
required: true
selector:
entity:
integration: alarmo
domain: alarm_control_panel
enable_user:
fields:
name:
example: "Frank"
required: true
selector:
text:
disable_user:
fields:
name:
example: "Frank"
required: true
selector:
text:
+722
View File
@@ -0,0 +1,722 @@
"""Storage handler for Alarmo integration."""
import time
import logging
from typing import cast
from collections import OrderedDict
from collections.abc import MutableMapping
import attr
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
from homeassistant.components.alarm_control_panel import CodeFormat
from . import const
from .helpers import omit
from .sensors import (
SENSOR_TYPE_OTHER,
)
_LOGGER = logging.getLogger(__name__)
DATA_REGISTRY = f"{const.DOMAIN}_storage"
STORAGE_KEY = f"{const.DOMAIN}.storage"
STORAGE_VERSION_MAJOR = 6
STORAGE_VERSION_MINOR = 3
SAVE_DELAY = 10
@attr.s(slots=True, frozen=True)
class ModeEntry:
"""Mode storage Entry."""
enabled = attr.ib(type=bool, default=False)
exit_time = attr.ib(type=int, default=None)
entry_time = attr.ib(type=int, default=None)
trigger_time = attr.ib(type=int, default=None)
@attr.s(slots=True, frozen=True)
class MqttConfig:
"""MQTT storage Entry."""
enabled = attr.ib(type=bool, default=False)
state_topic = attr.ib(type=str, default="alarmo/state")
state_payload = attr.ib(type=dict, default={})
command_topic = attr.ib(type=str, default="alarmo/command")
command_payload = attr.ib(type=dict, default={})
require_code = attr.ib(type=bool, default=True)
event_topic = attr.ib(type=str, default="alarmo/event")
@attr.s(slots=True, frozen=True)
class MasterConfig:
"""Master storage Entry."""
enabled = attr.ib(type=bool, default=True)
name = attr.ib(type=str, default="master")
@attr.s(slots=True, frozen=True)
class AreaEntry:
"""Area storage Entry."""
area_id = attr.ib(type=str, default=None)
name = attr.ib(type=str, default=None)
modes = attr.ib(
type=[str, ModeEntry],
default={
const.CONF_ALARM_ARMED_AWAY: ModeEntry(),
const.CONF_ALARM_ARMED_HOME: ModeEntry(),
const.CONF_ALARM_ARMED_NIGHT: ModeEntry(),
const.CONF_ALARM_ARMED_CUSTOM_BYPASS: ModeEntry(),
const.CONF_ALARM_ARMED_VACATION: ModeEntry(),
},
)
@attr.s(slots=True, frozen=True)
class Config:
"""(General) Config storage Entry."""
code_arm_required = attr.ib(type=bool, default=False)
code_mode_change_required = attr.ib(type=bool, default=False)
code_disarm_required = attr.ib(type=bool, default=False)
code_format = attr.ib(type=str, default=CodeFormat.NUMBER)
disarm_after_trigger = attr.ib(type=bool, default=False)
ignore_blocking_sensors_after_trigger = attr.ib(type=bool, default=False)
master = attr.ib(type=MasterConfig, default=MasterConfig())
mqtt = attr.ib(type=MqttConfig, default=MqttConfig())
@attr.s(slots=True, frozen=True)
class SensorEntry:
"""Sensor storage Entry."""
entity_id = attr.ib(type=str, default=None)
type = attr.ib(type=str, default=SENSOR_TYPE_OTHER)
modes = attr.ib(type=list, default=[])
use_exit_delay = attr.ib(type=bool, default=True)
use_entry_delay = attr.ib(type=bool, default=True)
always_on = attr.ib(type=bool, default=False)
arm_on_close = attr.ib(type=bool, default=False)
allow_open = attr.ib(type=bool, default=False)
trigger_unavailable = attr.ib(type=bool, default=False)
auto_bypass = attr.ib(type=bool, default=False)
auto_bypass_modes = attr.ib(type=list, default=[])
area = attr.ib(type=str, default=None)
enabled = attr.ib(type=bool, default=True)
entry_delay = attr.ib(type=int, default=None)
delay_on = attr.ib(type=int, default=None)
@attr.s(slots=True, frozen=True)
class UserEntry:
"""User storage Entry."""
user_id = attr.ib(type=str, default=None)
name = attr.ib(type=str, default="")
enabled = attr.ib(type=bool, default=True)
code = attr.ib(type=str, default="")
can_arm = attr.ib(type=bool, default=False)
can_disarm = attr.ib(type=bool, default=False)
is_override_code = attr.ib(type=bool, default=False)
code_format = attr.ib(type=str, default="")
code_length = attr.ib(type=int, default=0)
area_limit = attr.ib(type=list, default=[])
@attr.s(slots=True, frozen=True)
class AlarmoTriggerEntry:
"""Trigger storage Entry."""
event = attr.ib(type=str, default="")
area = attr.ib(type=str, default=None)
modes = attr.ib(type=list, default=[])
@attr.s(slots=True, frozen=True)
class EntityTriggerEntry:
"""Trigger storage Entry."""
entity_id = attr.ib(type=str, default=None)
state = attr.ib(type=str, default=None)
@attr.s(slots=True, frozen=True)
class ActionEntry:
"""Action storage Entry."""
service = attr.ib(type=str, default="")
entity_id = attr.ib(type=str, default=None)
data = attr.ib(type=dict, default={})
@attr.s(slots=True, frozen=True)
class AutomationEntry:
"""Automation storage Entry."""
automation_id = attr.ib(type=str, default=None)
type = attr.ib(type=str, default=None)
name = attr.ib(type=str, default="")
triggers = attr.ib(type=[AlarmoTriggerEntry], default=[])
actions = attr.ib(type=[ActionEntry], default=[])
enabled = attr.ib(type=bool, default=True)
@attr.s(slots=True, frozen=True)
class SensorGroupEntry:
"""Sensor group storage Entry."""
group_id = attr.ib(type=str, default=None)
name = attr.ib(type=str, default="")
entities = attr.ib(type=list, default=[])
timeout = attr.ib(type=int, default=0)
event_count = attr.ib(type=int, default=2)
def parse_automation_entry(data: dict):
"""Parse automation entry from dict to proper types."""
def create_trigger_entity(config: dict):
if "event" in config:
return AlarmoTriggerEntry(**config)
else:
return EntityTriggerEntry(**config)
output = {}
if "triggers" in data:
output["triggers"] = list(map(create_trigger_entity, data["triggers"]))
if "actions" in data:
output["actions"] = list(map(lambda el: ActionEntry(**el), data["actions"]))
if "automation_id" in data:
output["automation_id"] = data["automation_id"]
if "name" in data:
output["name"] = data["name"]
if "type" in data:
output["type"] = data["type"]
if "enabled" in data:
output["enabled"] = data["enabled"]
return output
class MigratableStore(Store):
"""Storage class that can migrate data between versions."""
async def _async_migrate_func(
self, old_major_version: int, old_minor_version: int, data: dict
):
def migrate_automation(data):
if old_major_version <= 2:
data["triggers"] = [
{
"event": el["state"] if "state" in el else el["event"],
"area": el.get("area"),
"modes": data["modes"],
}
for el in data["triggers"]
]
data["type"] = (
"notification" if data.get("is_notification") else "action"
)
if old_major_version <= 5:
data["actions"] = [
{
"service": el.get("service"),
"entity_id": el.get("entity_id"),
"data": el.get("service_data"),
}
for el in data["actions"]
]
return attr.asdict(AutomationEntry(**parse_automation_entry(data)))
if old_major_version == 1:
area_id = str(int(time.time()))
data["areas"] = [
attr.asdict(
AreaEntry(
**{
"name": "Alarmo",
"modes": {
mode: attr.asdict(
ModeEntry(
enabled=bool(config["enabled"]),
exit_time=int(config["leave_time"] or 0),
entry_time=int(config["entry_time"] or 0),
trigger_time=int(
data["config"]["trigger_time"] or 0
),
)
)
for (mode, config) in data["config"]["modes"].items()
},
},
area_id=area_id,
)
)
]
if "sensors" in data:
for sensor in data["sensors"]:
sensor["area"] = area_id
if old_major_version <= 3:
data["sensors"] = [
attr.asdict(
SensorEntry(
**{
**omit(sensor, ["immediate", "name"]),
"use_exit_delay": not sensor["immediate"]
and not sensor["always_on"],
"use_entry_delay": not sensor["immediate"]
and not sensor["always_on"],
"auto_bypass_modes": sensor["modes"]
if sensor.get("auto_bypass")
else [],
}
)
)
for sensor in data["sensors"]
]
if old_major_version <= 4:
data["sensors"] = [
attr.asdict(
SensorEntry(
**omit(sensor, ["name"]),
)
)
for sensor in data["sensors"]
]
data["automations"] = [
migrate_automation(automation) for automation in data["automations"]
]
if old_major_version <= 5 or (old_major_version == 6 and old_minor_version < 2):
data["config"] = attr.asdict(
Config(
**omit(data["config"], ["code_mode_change_required"]),
code_mode_change_required=data["config"]["code_arm_required"],
)
)
if old_major_version <= 5 or (old_major_version == 6 and old_minor_version < 3):
data["sensor_groups"] = [
attr.asdict(
SensorGroupEntry(
**{
**omit(sensorGroup, ["entities"]),
"entities": list(set(sensorGroup["entities"])),
}
)
)
for sensorGroup in data["sensor_groups"]
]
return data
class AlarmoStorage:
"""Class to hold alarmo configuration data."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the storage."""
self.hass = hass
self.config: Config = Config()
self.areas: MutableMapping[str, AreaEntry] = {}
self.sensors: MutableMapping[str, SensorEntry] = {}
self.users: MutableMapping[str, UserEntry] = {}
self.automations: MutableMapping[str, AutomationEntry] = {}
self.sensor_groups: MutableMapping[str, SensorGroupEntry] = {}
self._store = MigratableStore(
hass,
STORAGE_VERSION_MAJOR,
STORAGE_KEY,
minor_version=STORAGE_VERSION_MINOR,
)
async def async_load(self) -> None: # noqa: PLR0912
"""Load the registry of schedule entries."""
data = await self._store.async_load()
config: Config = Config()
areas: OrderedDict[str, AreaEntry] = OrderedDict()
sensors: OrderedDict[str, SensorEntry] = OrderedDict()
users: OrderedDict[str, UserEntry] = OrderedDict()
automations: OrderedDict[str, AutomationEntry] = OrderedDict()
sensor_groups: OrderedDict[str, SensorGroupEntry] = OrderedDict()
if data is not None:
config = Config(
code_arm_required=data["config"]["code_arm_required"],
code_mode_change_required=data["config"]["code_mode_change_required"],
code_disarm_required=data["config"]["code_disarm_required"],
code_format=data["config"]["code_format"],
disarm_after_trigger=data["config"]["disarm_after_trigger"],
ignore_blocking_sensors_after_trigger=data["config"].get(
"ignore_blocking_sensors_after_trigger", False
),
)
if "mqtt" in data["config"]:
config = attr.evolve(
config,
**{
"mqtt": MqttConfig(**data["config"]["mqtt"]),
},
)
if "master" in data["config"]:
config = attr.evolve(
config,
**{
"master": MasterConfig(**data["config"]["master"]),
},
)
if "areas" in data:
for area in data["areas"]:
modes = {
mode: ModeEntry(
enabled=config["enabled"],
exit_time=config["exit_time"],
entry_time=config["entry_time"],
trigger_time=config["trigger_time"],
)
for (mode, config) in area["modes"].items()
}
areas[area["area_id"]] = AreaEntry(
area_id=area["area_id"], name=area["name"], modes=modes
)
if "sensors" in data:
for sensor in data["sensors"]:
sensors[sensor["entity_id"]] = SensorEntry(**sensor)
if "users" in data:
for user in data["users"]:
users[user["user_id"]] = UserEntry(**omit(user, ["is_admin"]))
if "automations" in data:
for automation in data["automations"]:
automations[automation["automation_id"]] = AutomationEntry(
**parse_automation_entry(automation)
)
if "sensor_groups" in data:
for group in data["sensor_groups"]:
sensor_groups[group["group_id"]] = SensorGroupEntry(**group)
self.config = config
self.areas = areas
self.sensors = sensors
self.automations = automations
self.users = users
self.sensor_groups = sensor_groups
if not areas:
await self.async_factory_default()
async def async_factory_default(self):
"""Reset to factory default configuration."""
self.async_create_area(
{
"name": "Alarmo",
"modes": {
const.CONF_ALARM_ARMED_AWAY: attr.asdict(
ModeEntry(
enabled=True, exit_time=60, entry_time=60, trigger_time=1800
)
),
const.CONF_ALARM_ARMED_HOME: attr.asdict(
ModeEntry(enabled=True, trigger_time=1800)
),
},
}
)
@callback
def async_schedule_save(self) -> None:
"""Schedule saving the registry of alarmo."""
self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
async def async_save(self) -> None:
"""Save the registry of alarmo."""
await self._store.async_save(self._data_to_save())
@callback
def _data_to_save(self) -> dict:
"""Return data for the registry for alarmo to store in a file."""
store_data = {
"config": attr.asdict(self.config),
}
store_data["areas"] = [attr.asdict(entry) for entry in self.areas.values()]
store_data["sensors"] = [attr.asdict(entry) for entry in self.sensors.values()]
store_data["users"] = [attr.asdict(entry) for entry in self.users.values()]
store_data["automations"] = [
attr.asdict(entry) for entry in self.automations.values()
]
store_data["sensor_groups"] = [
attr.asdict(entry) for entry in self.sensor_groups.values()
]
return store_data
async def async_delete(self):
"""Delete config."""
_LOGGER.warning("Removing alarmo configuration data!")
await self._store.async_remove()
self.config = Config()
self.areas = {}
self.sensors = {}
self.users = {}
self.automations = {}
self.sensor_groups = {}
await self.async_factory_default()
@callback
def async_get_config(self):
"""Get current config."""
return attr.asdict(self.config)
@callback
def async_update_config(self, changes: dict):
"""Update existing config."""
old = self.config
new = self.config = attr.evolve(old, **changes)
self.async_schedule_save()
return attr.asdict(new)
@callback
def async_update_mode_config(self, mode: str, changes: dict):
"""Update existing config."""
modes = self.config.modes
old = self.config.modes[mode] if mode in self.config.modes else ModeEntry()
new = attr.evolve(old, **changes)
modes[mode] = new
self.config = attr.evolve(self.config, **{"modes": modes})
self.async_schedule_save()
return new
@callback
def async_get_area(self, area_id) -> AreaEntry:
"""Get an existing AreaEntry by id."""
res = self.areas.get(area_id)
return attr.asdict(res) if res else None
@callback
def async_get_areas(self):
"""Get an existing AreaEntry by id."""
res = {}
for key, val in self.areas.items():
res[key] = attr.asdict(val)
return res
@callback
def async_create_area(self, data: dict) -> AreaEntry:
"""Create a new AreaEntry."""
area_id = str(int(time.time()))
new_area = AreaEntry(**data, area_id=area_id)
self.areas[area_id] = new_area
self.async_schedule_save()
return attr.asdict(new_area)
@callback
def async_delete_area(self, area_id: str) -> None:
"""Delete AreaEntry."""
if area_id in self.areas:
del self.areas[area_id]
self.async_schedule_save()
return True
return False
@callback
def async_update_area(self, area_id: str, changes: dict) -> AreaEntry:
"""Update existing self."""
old = self.areas[area_id]
new = self.areas[area_id] = attr.evolve(old, **changes)
self.async_schedule_save()
return attr.asdict(new)
@callback
def async_get_sensor(self, entity_id) -> SensorEntry:
"""Get an existing SensorEntry by id."""
res = self.sensors.get(entity_id)
return attr.asdict(res) if res else None
@callback
def async_get_sensors(self):
"""Get an existing SensorEntry by id."""
res = {}
for key, val in self.sensors.items():
res[key] = attr.asdict(val)
return res
@callback
def async_create_sensor(self, entity_id: str, data: dict) -> SensorEntry:
"""Create a new SensorEntry."""
if entity_id in self.sensors:
return False
new_sensor = SensorEntry(**data, entity_id=entity_id)
self.sensors[entity_id] = new_sensor
self.async_schedule_save()
return new_sensor
@callback
def async_delete_sensor(self, entity_id: str) -> None:
"""Delete SensorEntry."""
if entity_id in self.sensors:
del self.sensors[entity_id]
self.async_schedule_save()
return True
return False
@callback
def async_update_sensor(self, entity_id: str, changes: dict) -> SensorEntry:
"""Update existing SensorEntry."""
old = self.sensors[entity_id]
new = self.sensors[entity_id] = attr.evolve(old, **changes)
self.async_schedule_save()
return new
@callback
def async_get_user(self, user_id) -> UserEntry:
"""Get an existing UserEntry by id."""
res = self.users.get(user_id)
return attr.asdict(res) if res else None
@callback
def async_get_users(self):
"""Get an existing UserEntry by id."""
res = {}
for key, val in self.users.items():
res[key] = attr.asdict(val)
return res
@callback
def async_create_user(self, data: dict) -> UserEntry:
"""Create a new UserEntry."""
user_id = str(int(time.time()))
new_user = UserEntry(**data, user_id=user_id)
self.users[user_id] = new_user
self.async_schedule_save()
return new_user
@callback
def async_delete_user(self, user_id: str) -> None:
"""Delete UserEntry."""
if user_id in self.users:
del self.users[user_id]
self.async_schedule_save()
return True
return False
@callback
def async_update_user(self, user_id: str, changes: dict) -> UserEntry:
"""Update existing UserEntry."""
old = self.users[user_id]
new = self.users[user_id] = attr.evolve(old, **changes)
self.async_schedule_save()
return new
@callback
def async_get_automations(self):
"""Get an existing AutomationEntry by id."""
res = {}
for key, val in self.automations.items():
res[key] = attr.asdict(val)
return res
@callback
def async_create_automation(self, data: dict) -> AutomationEntry:
"""Create a new AutomationEntry."""
automation_id = str(int(time.time()))
new_automation = AutomationEntry(
**parse_automation_entry(data), automation_id=automation_id
)
self.automations[automation_id] = new_automation
self.async_schedule_save()
return new_automation
@callback
def async_delete_automation(self, automation_id: str) -> None:
"""Delete AutomationEntry."""
if automation_id in self.automations:
del self.automations[automation_id]
self.async_schedule_save()
return True
return False
@callback
def async_update_automation(
self, automation_id: str, changes: dict
) -> AutomationEntry:
"""Update existing AutomationEntry."""
old = self.automations[automation_id]
new = self.automations[automation_id] = attr.evolve(
old, **parse_automation_entry(changes)
)
self.async_schedule_save()
return new
@callback
def async_get_sensor_group(self, group_id) -> SensorGroupEntry:
"""Get an existing SensorGroupEntry by id."""
res = self.sensor_groups.get(group_id)
return attr.asdict(res) if res else None
@callback
def async_get_sensor_groups(self):
"""Get an existing SensorGroupEntry by id."""
res = {}
for key, val in self.sensor_groups.items():
res[key] = attr.asdict(val)
return res
@callback
def async_create_sensor_group(self, data: dict) -> SensorGroupEntry:
"""Create a new SensorGroupEntry."""
group_id = str(int(time.time()))
new_group = SensorGroupEntry(**data, group_id=group_id)
self.sensor_groups[group_id] = new_group
self.async_schedule_save()
return group_id
@callback
def async_delete_sensor_group(self, group_id: str) -> None:
"""Delete SensorGroupEntry."""
if group_id in self.sensor_groups:
del self.sensor_groups[group_id]
self.async_schedule_save()
return True
return False
@callback
def async_update_sensor_group(
self, group_id: str, changes: dict
) -> SensorGroupEntry:
"""Update existing SensorGroupEntry."""
old = self.sensor_groups[group_id]
new = self.sensor_groups[group_id] = attr.evolve(old, **changes)
self.async_schedule_save()
return new
async def async_get_registry(hass: HomeAssistant) -> AlarmoStorage:
"""Return alarmo storage instance."""
task = hass.data.get(DATA_REGISTRY)
if task is None:
async def _load_reg() -> AlarmoStorage:
registry = AlarmoStorage(hass)
await registry.async_load()
return registry
task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg())
return cast(AlarmoStorage, await task)
+596
View File
@@ -0,0 +1,596 @@
"""WebSocket handler and registration for Alarmo configuration management."""
import voluptuous as vol
import homeassistant.util.dt as dt_util
from homeassistant.core import callback
from homeassistant.const import (
ATTR_CODE,
ATTR_NAME,
ATTR_STATE,
ATTR_SERVICE,
ATTR_ENTITY_ID,
ATTR_CODE_FORMAT,
CONF_SERVICE_DATA,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.components import websocket_api
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.mqtt import (
DOMAIN as ATTR_MQTT,
)
from homeassistant.components.mqtt import (
CONF_STATE_TOPIC,
CONF_COMMAND_TOPIC,
)
from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
async_dispatcher_connect,
)
from homeassistant.components.websocket_api import decorators, async_register_command
from homeassistant.components.alarm_control_panel import (
ATTR_CODE_ARM_REQUIRED,
CodeFormat,
)
from homeassistant.components.http.data_validator import RequestDataValidator
from . import const
from .mqtt import (
CONF_EVENT_TOPIC,
)
from .sensors import (
ATTR_GROUP,
ATTR_TIMEOUT,
SENSOR_TYPES,
ATTR_DELAY_ON,
ATTR_ENTITIES,
ATTR_GROUP_ID,
ATTR_ALWAYS_ON,
ATTR_ALLOW_OPEN,
ATTR_AUTO_BYPASS,
ATTR_ENTRY_DELAY,
ATTR_EVENT_COUNT,
ATTR_ARM_ON_CLOSE,
ATTR_NEW_ENTITY_ID,
ATTR_USE_EXIT_DELAY,
ATTR_USE_ENTRY_DELAY,
ATTR_AUTO_BYPASS_MODES,
ATTR_TRIGGER_UNAVAILABLE,
)
@callback
@decorators.websocket_command(
{
vol.Required("type"): "alarmo_config_updated",
}
)
@decorators.async_response
async def handle_subscribe_updates(hass, connection, msg):
"""Handle subscribe updates."""
@callback
def async_handle_event():
"""Forward events to websocket."""
connection.send_message(
{
"id": msg["id"],
"type": "event",
}
)
connection.subscriptions[msg["id"]] = async_dispatcher_connect(
hass, "alarmo_update_frontend", async_handle_event
)
connection.send_result(msg["id"])
class AlarmoConfigView(HomeAssistantView):
"""Login to Home Assistant cloud."""
url = "/api/alarmo/config"
name = "api:alarmo:config"
@RequestDataValidator(
vol.Schema(
{
vol.Optional(ATTR_CODE_ARM_REQUIRED): cv.boolean,
vol.Optional(const.ATTR_CODE_DISARM_REQUIRED): cv.boolean,
vol.Optional(
const.ATTR_IGNORE_BLOCKING_SENSORS_AFTER_TRIGGER
): cv.boolean,
vol.Optional(const.ATTR_CODE_MODE_CHANGE_REQUIRED): cv.boolean,
vol.Optional(ATTR_CODE_FORMAT): vol.In(
[CodeFormat.NUMBER, CodeFormat.TEXT]
),
vol.Optional(const.ATTR_TRIGGER_TIME): cv.positive_int,
vol.Optional(const.ATTR_DISARM_AFTER_TRIGGER): cv.boolean,
vol.Optional(ATTR_MQTT): vol.Schema(
{
vol.Required(const.ATTR_ENABLED): cv.boolean,
vol.Required(CONF_STATE_TOPIC): cv.string,
vol.Optional(const.ATTR_STATE_PAYLOAD): vol.Schema(
{
vol.Optional(const.CONF_ALARM_DISARMED): cv.string,
vol.Optional(const.CONF_ALARM_ARMED_HOME): cv.string,
vol.Optional(const.CONF_ALARM_ARMED_AWAY): cv.string,
vol.Optional(const.CONF_ALARM_ARMED_NIGHT): cv.string,
vol.Optional(
const.CONF_ALARM_ARMED_CUSTOM_BYPASS
): cv.string,
vol.Optional(
const.CONF_ALARM_ARMED_VACATION
): cv.string,
vol.Optional(const.CONF_ALARM_PENDING): cv.string,
vol.Optional(const.CONF_ALARM_ARMING): cv.string,
vol.Optional(const.CONF_ALARM_TRIGGERED): cv.string,
}
),
vol.Required(CONF_COMMAND_TOPIC): cv.string,
vol.Optional(const.ATTR_COMMAND_PAYLOAD): vol.Schema(
{
vol.Optional(const.COMMAND_ARM_AWAY): cv.string,
vol.Optional(const.COMMAND_ARM_HOME): cv.string,
vol.Optional(const.COMMAND_ARM_NIGHT): cv.string,
vol.Optional(
const.COMMAND_ARM_CUSTOM_BYPASS
): cv.string,
vol.Optional(const.COMMAND_ARM_VACATION): cv.string,
vol.Optional(const.COMMAND_DISARM): cv.string,
}
),
vol.Required(const.ATTR_REQUIRE_CODE): cv.boolean,
vol.Required(CONF_EVENT_TOPIC): cv.string,
}
),
vol.Optional(const.ATTR_MASTER): vol.Schema(
{
vol.Required(const.ATTR_ENABLED): cv.boolean,
vol.Optional(ATTR_NAME): cv.string,
}
),
}
)
)
async def post(self, request, data):
"""Handle config update request."""
hass = request.app["hass"]
coordinator = hass.data[const.DOMAIN]["coordinator"]
await coordinator.async_update_config(data)
async_dispatcher_send(hass, "alarmo_update_frontend")
return self.json({"success": True})
class AlarmoAreaView(HomeAssistantView):
"""Login to Home Assistant cloud."""
url = "/api/alarmo/area"
name = "api:alarmo:area"
mode_schema = vol.Schema(
{
vol.Required(const.ATTR_ENABLED): cv.boolean,
vol.Required(const.ATTR_EXIT_TIME): vol.Any(cv.positive_int, None),
vol.Required(const.ATTR_ENTRY_TIME): vol.Any(cv.positive_int, None),
vol.Optional(const.ATTR_TRIGGER_TIME): vol.Any(cv.positive_int, None),
}
)
@RequestDataValidator(
vol.Schema(
{
vol.Optional("area_id"): cv.string,
vol.Optional(ATTR_NAME): cv.string,
vol.Optional(const.ATTR_REMOVE): cv.boolean,
vol.Optional(const.ATTR_MODES): vol.Schema(
{
vol.Optional(const.CONF_ALARM_ARMED_AWAY): mode_schema,
vol.Optional(const.CONF_ALARM_ARMED_HOME): mode_schema,
vol.Optional(const.CONF_ALARM_ARMED_NIGHT): mode_schema,
vol.Optional(const.CONF_ALARM_ARMED_CUSTOM_BYPASS): mode_schema,
vol.Optional(const.CONF_ALARM_ARMED_VACATION): mode_schema,
}
),
}
)
)
async def post(self, request, data):
"""Handle config update request."""
hass = request.app["hass"]
coordinator = hass.data[const.DOMAIN]["coordinator"]
if "area_id" in data:
area = data["area_id"]
del data["area_id"]
else:
area = None
await coordinator.async_update_area_config(area, data)
async_dispatcher_send(hass, "alarmo_update_frontend")
return self.json({"success": True})
class AlarmoSensorView(HomeAssistantView):
"""Login to Home Assistant cloud."""
url = "/api/alarmo/sensors"
name = "api:alarmo:sensors"
@RequestDataValidator(
vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Optional(const.ATTR_REMOVE): cv.boolean,
vol.Optional(const.ATTR_TYPE): vol.In(SENSOR_TYPES),
vol.Optional(const.ATTR_MODES): vol.All(
cv.ensure_list, [vol.In(const.ARM_MODES)]
),
vol.Optional(ATTR_USE_EXIT_DELAY): cv.boolean,
vol.Optional(ATTR_USE_ENTRY_DELAY): cv.boolean,
vol.Optional(ATTR_ARM_ON_CLOSE): cv.boolean,
vol.Optional(ATTR_ALLOW_OPEN): cv.boolean,
vol.Optional(ATTR_ALWAYS_ON): cv.boolean,
vol.Optional(ATTR_TRIGGER_UNAVAILABLE): cv.boolean,
vol.Optional(ATTR_AUTO_BYPASS): cv.boolean,
vol.Optional(ATTR_AUTO_BYPASS_MODES): vol.All(
cv.ensure_list, [vol.In(const.ARM_MODES)]
),
vol.Optional(const.ATTR_AREA): cv.string,
vol.Optional(const.ATTR_ENABLED): cv.boolean,
vol.Optional(ATTR_GROUP): vol.Any(cv.string, None),
vol.Optional(ATTR_ENTRY_DELAY): vol.Any(cv.positive_int, None),
vol.Optional(ATTR_DELAY_ON): vol.Any(cv.positive_int, None),
vol.Optional(ATTR_NEW_ENTITY_ID): cv.string,
}
)
)
async def post(self, request, data):
"""Handle config update request."""
hass = request.app["hass"]
coordinator = hass.data[const.DOMAIN]["coordinator"]
entity = data[ATTR_ENTITY_ID]
del data[ATTR_ENTITY_ID]
coordinator.async_update_sensor_config(entity, data)
async_dispatcher_send(hass, "alarmo_update_frontend")
return self.json({"success": True})
class AlarmoUserView(HomeAssistantView):
"""Login to Home Assistant cloud."""
url = "/api/alarmo/users"
name = "api:alarmo:users"
@RequestDataValidator(
vol.Schema(
{
vol.Optional(const.ATTR_USER_ID): cv.string,
vol.Optional(const.ATTR_REMOVE): cv.boolean,
vol.Optional(ATTR_NAME): cv.string,
vol.Optional(const.ATTR_ENABLED): cv.boolean,
vol.Optional(ATTR_CODE): cv.string,
vol.Optional(const.ATTR_OLD_CODE): cv.string,
vol.Optional(const.ATTR_CAN_ARM): cv.boolean,
vol.Optional(const.ATTR_CAN_DISARM): cv.boolean,
vol.Optional(const.ATTR_IS_OVERRIDE_CODE): cv.boolean,
vol.Optional(const.ATTR_AREA_LIMIT): vol.All(
cv.ensure_list, [cv.string]
),
}
)
)
async def post(self, request, data):
"""Handle config update request."""
hass = request.app["hass"]
coordinator = hass.data[const.DOMAIN]["coordinator"]
user_id = None
if const.ATTR_USER_ID in data:
user_id = data[const.ATTR_USER_ID]
del data[const.ATTR_USER_ID]
err = await coordinator.async_update_user_config(user_id, data)
async_dispatcher_send(hass, "alarmo_update_frontend")
return self.json({"success": not isinstance(err, str), "error": err})
class AlarmoAutomationView(HomeAssistantView):
"""Login to Home Assistant cloud."""
url = "/api/alarmo/automations"
name = "api:alarmo:automations"
@RequestDataValidator(
vol.Schema(
{
vol.Optional(const.ATTR_AUTOMATION_ID): cv.string,
vol.Optional(ATTR_NAME): cv.string,
vol.Optional(const.ATTR_TYPE): cv.string,
vol.Optional(const.ATTR_TRIGGERS): vol.All(
cv.ensure_list,
[
vol.Any(
vol.Schema(
{
vol.Required(const.ATTR_EVENT): cv.string,
vol.Optional(const.ATTR_AREA): vol.Any(
int,
cv.string,
),
vol.Optional(const.ATTR_MODES): vol.All(
cv.ensure_list, [vol.In(const.ARM_MODES)]
),
}
),
vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.string,
vol.Required(ATTR_STATE): cv.string,
}
),
)
],
),
vol.Optional(const.ATTR_ACTIONS): vol.All(
cv.ensure_list,
[
vol.Schema(
{
vol.Optional(ATTR_ENTITY_ID): cv.string,
vol.Required(ATTR_SERVICE): cv.string,
vol.Optional(CONF_SERVICE_DATA): dict,
}
)
],
),
vol.Optional(const.ATTR_ENABLED): cv.boolean,
vol.Optional(const.ATTR_REMOVE): cv.boolean,
}
)
)
async def post(self, request, data):
"""Handle config update request."""
hass = request.app["hass"]
coordinator = hass.data[const.DOMAIN]["coordinator"]
automation_id = None
if const.ATTR_AUTOMATION_ID in data:
automation_id = data[const.ATTR_AUTOMATION_ID]
del data[const.ATTR_AUTOMATION_ID]
coordinator.async_update_automation_config(automation_id, data)
async_dispatcher_send(hass, "alarmo_update_frontend")
return self.json({"success": True})
class AlarmoSensorGroupView(HomeAssistantView):
"""Login to Home Assistant cloud."""
url = "/api/alarmo/sensor_groups"
name = "api:alarmo:sensor_groups"
@RequestDataValidator(
vol.Schema(
{
vol.Optional(ATTR_GROUP_ID): cv.string,
vol.Optional(ATTR_NAME): cv.string,
vol.Optional(ATTR_ENTITIES): vol.All(
cv.ensure_list, vol.Unique(), [cv.string]
),
vol.Optional(ATTR_TIMEOUT): cv.positive_int,
vol.Optional(ATTR_EVENT_COUNT): cv.positive_int,
vol.Optional(const.ATTR_REMOVE): cv.boolean,
}
)
)
async def post(self, request, data):
"""Handle config update request."""
hass = request.app["hass"]
coordinator = hass.data[const.DOMAIN]["coordinator"]
group_id = None
if ATTR_GROUP_ID in data:
group_id = data[ATTR_GROUP_ID]
del data[ATTR_GROUP_ID]
coordinator.async_update_sensor_group_config(group_id, data)
async_dispatcher_send(hass, "alarmo_update_frontend")
return self.json({"success": True})
@callback
def websocket_get_config(hass, connection, msg):
"""Publish config data."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
config = coordinator.store.async_get_config()
connection.send_result(msg["id"], config)
@callback
def websocket_get_areas(hass, connection, msg):
"""Publish area data."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
areas = coordinator.store.async_get_areas()
connection.send_result(msg["id"], areas)
@callback
def websocket_get_sensors(hass, connection, msg):
"""Publish sensor data."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
sensors = coordinator.store.async_get_sensors()
for entity_id in sensors.keys():
group = coordinator.async_get_group_for_sensor(entity_id)
sensors[entity_id]["group"] = group
connection.send_result(msg["id"], sensors)
@callback
def websocket_get_users(hass, connection, msg):
"""Publish user data."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
users = coordinator.store.async_get_users()
connection.send_result(msg["id"], users)
@callback
def websocket_get_automations(hass, connection, msg):
"""Publish automations data."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
automations = coordinator.store.async_get_automations()
connection.send_result(msg["id"], automations)
@callback
def websocket_get_alarm_entities(hass, connection, msg):
"""Publish alarm entity data."""
result = [
{"entity_id": entity.entity_id, "area_id": area_id}
for (area_id, entity) in hass.data[const.DOMAIN]["areas"].items()
]
if hass.data[const.DOMAIN]["master"]:
result.append(
{"entity_id": hass.data[const.DOMAIN]["master"].entity_id, "area_id": 0}
)
connection.send_result(msg["id"], result)
@callback
def websocket_get_sensor_groups(hass, connection, msg):
"""Publish sensor_group data."""
coordinator = hass.data[const.DOMAIN]["coordinator"]
groups = coordinator.store.async_get_sensor_groups()
connection.send_result(msg["id"], groups)
@callback
def websocket_get_countdown(hass, connection, msg):
"""Publish countdown time for alarm entity."""
entity_id = msg["entity_id"]
item = next(
(
entity
for entity in hass.data[const.DOMAIN]["areas"].values()
if entity.entity_id == entity_id
),
None,
)
if (
hass.data[const.DOMAIN]["master"]
and not item
and hass.data[const.DOMAIN]["master"].entity_id == entity_id
):
item = hass.data[const.DOMAIN]["master"]
data = {
"delay": item.delay if item else 0,
"remaining": round((item.expiration - dt_util.utcnow()).total_seconds(), 2)
if item and item.expiration
else 0,
}
connection.send_result(msg["id"], data)
@callback
def websocket_get_ready_to_arm_modes(hass, connection, msg):
"""Publish ready_to_arm_modes for alarm entity."""
entity_id = msg["entity_id"]
item = next(
(
entity
for entity in hass.data[const.DOMAIN]["areas"].values()
if entity.entity_id == entity_id
),
None,
)
if (
hass.data[const.DOMAIN]["master"]
and not item
and hass.data[const.DOMAIN]["master"].entity_id == entity_id
):
item = hass.data[const.DOMAIN]["master"]
data = {"modes": item._ready_to_arm_modes if item else None}
connection.send_result(msg["id"], data)
async def async_register_websockets(hass):
"""Register websocket handlers."""
hass.http.register_view(AlarmoConfigView)
hass.http.register_view(AlarmoSensorView)
hass.http.register_view(AlarmoUserView)
hass.http.register_view(AlarmoAutomationView)
hass.http.register_view(AlarmoAreaView)
hass.http.register_view(AlarmoSensorGroupView)
async_register_command(hass, handle_subscribe_updates)
async_register_command(
hass,
"alarmo/config",
websocket_get_config,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): "alarmo/config"}
),
)
async_register_command(
hass,
"alarmo/areas",
websocket_get_areas,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): "alarmo/areas"}
),
)
async_register_command(
hass,
"alarmo/sensors",
websocket_get_sensors,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): "alarmo/sensors"}
),
)
async_register_command(
hass,
"alarmo/users",
websocket_get_users,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): "alarmo/users"}
),
)
async_register_command(
hass,
"alarmo/automations",
websocket_get_automations,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): "alarmo/automations"}
),
)
async_register_command(
hass,
"alarmo/entities",
websocket_get_alarm_entities,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): "alarmo/entities"}
),
)
async_register_command(
hass,
"alarmo/sensor_groups",
websocket_get_sensor_groups,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): "alarmo/sensor_groups"}
),
)
async_register_command(
hass,
"alarmo/countdown",
websocket_get_countdown,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): "alarmo/countdown",
vol.Required("entity_id"): cv.entity_id,
}
),
)
async_register_command(
hass,
"alarmo/ready_to_arm_modes",
websocket_get_ready_to_arm_modes,
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): "alarmo/ready_to_arm_modes",
vol.Required("entity_id"): cv.entity_id,
}
),
)
@@ -32,11 +32,28 @@ from .const import (
DEFAULT_IMMICH_IMAGE_SIZE,
IMMICH_IMAGE_SIZE_OPTIONS,
IMMICH_SELECTION_COMPOSITE,
CONF_PHOTOPRISM_URL,
CONF_PHOTOPRISM_AUTH_METHOD,
CONF_PHOTOPRISM_TOKEN,
CONF_PHOTOPRISM_USERNAME,
CONF_PHOTOPRISM_PASSWORD,
CONF_PHOTOPRISM_SELECTION_TYPE,
CONF_PHOTOPRISM_SELECTION_ID,
CONF_PHOTOPRISM_IMAGE_SIZE,
CONF_PHOTOPRISM_FILTER,
DEFAULT_PHOTOPRISM_IMAGE_SIZE,
PHOTOPRISM_IMAGE_PREVIEW,
PHOTOPRISM_IMAGE_FULLSIZE,
PHOTOPRISM_IMAGE_ORIGINAL,
PHOTOPRISM_AUTH_APP_PASSWORD,
PHOTOPRISM_AUTH_USER_PASSWORD,
PHOTOPRISM_SELECTION_COMPOSITE,
DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE,
PROVIDER_IMMICH,
PROVIDER_PHOTOPRISM,
DEFAULT_RECURSIVE,
)
@@ -76,6 +93,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# id -> name maps for the Albums and People multi-selects.
self._immich_albums: dict[str, str] = {}
self._immich_people: dict[str, str] = {}
# PhotoPrism flow state carried between steps.
self._pp_url: str | None = None
self._pp_auth_method: str | None = None
self._pp_token: str | None = None
self._pp_username: str | None = None
self._pp_password: str | None = None
self._pp_albums: dict[str, str] = {}
self._pp_people: dict[str, str] = {}
@staticmethod
@callback
@@ -107,6 +132,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self.async_step_media_source()
if self._provider == PROVIDER_IMMICH:
return await self.async_step_immich()
if self._provider == PROVIDER_PHOTOPRISM:
return await self.async_step_photoprism()
return await self.async_step_google_shared()
schema = vol.Schema(
@@ -115,6 +142,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
PROVIDER_GOOGLE_SHARED: "Google Photos",
PROVIDER_LOCAL_FOLDER: "Local Folder",
PROVIDER_IMMICH: "Immich (direct API, full metadata)",
PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)",
PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)",
})
}
@@ -369,6 +397,195 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
step_id="immich_select", data_schema=schema, errors=errors
)
async def async_step_photoprism(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Collect the PhotoPrism URL + credentials and validate them."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_PHOTOPRISM_URL].strip()
method = user_input[CONF_PHOTOPRISM_AUTH_METHOD]
token = (user_input.get(CONF_PHOTOPRISM_TOKEN) or "").strip()
username = (user_input.get(CONF_PHOTOPRISM_USERNAME) or "").strip()
password = user_input.get(CONF_PHOTOPRISM_PASSWORD) or ""
if method == PHOTOPRISM_AUTH_APP_PASSWORD and not token:
errors[CONF_PHOTOPRISM_TOKEN] = "photoprism_token_required"
elif method == PHOTOPRISM_AUTH_USER_PASSWORD and not (username and password):
errors[CONF_PHOTOPRISM_USERNAME] = "photoprism_user_required"
if not errors:
from . import photoprism as pp_api
client = pp_api.PhotoprismClient(
self.hass,
url,
auth_method=method,
token=token or None,
username=username or None,
password=password or None,
)
try:
await client.async_validate()
albums = await client.async_list_albums()
people = await client.async_list_people()
except Exception: # noqa: BLE001 - any failure means bad URL/creds
errors["base"] = "photoprism_cannot_connect"
else:
self._pp_url = client.base_url
self._pp_auth_method = method
self._pp_token = token or None
self._pp_username = username or None
self._pp_password = password or None
self._pp_albums = {
a["UID"]: (a.get("Title") or a["UID"])
for a in albums
if a.get("UID")
}
self._pp_people = {
p["UID"]: p["Name"]
for p in people
if p.get("UID") and (p.get("Name") or "").strip()
}
return await self.async_step_photoprism_select()
schema = vol.Schema(
{
vol.Required(CONF_PHOTOPRISM_URL): str,
vol.Required(
CONF_PHOTOPRISM_AUTH_METHOD,
default=PHOTOPRISM_AUTH_APP_PASSWORD,
): vol.In(
{
PHOTOPRISM_AUTH_APP_PASSWORD: "App password (recommended)",
PHOTOPRISM_AUTH_USER_PASSWORD: "Username + password",
}
),
vol.Optional(CONF_PHOTOPRISM_TOKEN): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Optional(CONF_PHOTOPRISM_USERNAME): str,
vol.Optional(CONF_PHOTOPRISM_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
}
)
return self.async_show_form(
step_id="photoprism", data_schema=schema, errors=errors
)
async def async_step_photoprism_select(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Build a composite PhotoPrism selection and finish the entry.
Same combined picker as Immich: tick any mix of albums, people and
favorites (optionally a custom search query); an empty selection means
the whole library.
"""
errors: dict[str, str] = {}
if user_input is not None:
name = user_input[CONF_ALBUM_NAME].strip()
size = user_input.get(
CONF_PHOTOPRISM_IMAGE_SIZE, DEFAULT_PHOTOPRISM_IMAGE_SIZE
)
raw_filter = (user_input.get(CONF_PHOTOPRISM_FILTER) or "").strip()
favorites = bool(user_input.get("favorites"))
chosen_albums = [a for a in user_input.get("albums", []) if a]
if _ALL_ALBUMS in chosen_albums:
chosen_albums = list(self._pp_albums.keys())
else:
chosen_albums = [a for a in chosen_albums if a in self._pp_albums]
chosen_people = [p for p in user_input.get("people", []) if p]
if _ALL_PEOPLE in chosen_people:
chosen_people = list(self._pp_people.keys())
else:
chosen_people = [p for p in chosen_people if p in self._pp_people]
selection = {
"albums": chosen_albums,
"people": chosen_people,
"favorites": favorites,
}
sel_id = json.dumps(selection, sort_keys=True)
unique = (
f"{DOMAIN}:{PROVIDER_PHOTOPRISM}:{self._pp_url}:"
f"composite:{sel_id}:{raw_filter}"
)
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
data = {
CONF_PROVIDER: PROVIDER_PHOTOPRISM,
CONF_PHOTOPRISM_URL: self._pp_url,
CONF_PHOTOPRISM_AUTH_METHOD: self._pp_auth_method,
CONF_PHOTOPRISM_SELECTION_TYPE: PHOTOPRISM_SELECTION_COMPOSITE,
CONF_PHOTOPRISM_SELECTION_ID: sel_id,
CONF_PHOTOPRISM_IMAGE_SIZE: size,
CONF_ALBUM_NAME: name,
}
if self._pp_token:
data[CONF_PHOTOPRISM_TOKEN] = self._pp_token
if self._pp_username:
data[CONF_PHOTOPRISM_USERNAME] = self._pp_username
if self._pp_password:
data[CONF_PHOTOPRISM_PASSWORD] = self._pp_password
if raw_filter:
data[CONF_PHOTOPRISM_FILTER] = raw_filter
return self.async_create_entry(title=name, data=data)
fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str}
if self._pp_albums:
album_options = [
selector.SelectOptionDict(value=_ALL_ALBUMS, label="Select all albums")
] + [
selector.SelectOptionDict(value=uid, label=name)
for uid, name in self._pp_albums.items()
]
fields[vol.Optional("albums")] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=album_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
if self._pp_people:
people_options = [
selector.SelectOptionDict(value=_ALL_PEOPLE, label="Select all people")
] + [
selector.SelectOptionDict(value=uid, label=name)
for uid, name in self._pp_people.items()
]
fields[vol.Optional("people")] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=people_options,
multiple=True,
mode=selector.SelectSelectorMode.DROPDOWN,
custom_value=False,
)
)
fields[vol.Optional("favorites", default=False)] = selector.BooleanSelector()
fields[vol.Optional(CONF_PHOTOPRISM_FILTER)] = str
fields[
vol.Optional(
CONF_PHOTOPRISM_IMAGE_SIZE, default=DEFAULT_PHOTOPRISM_IMAGE_SIZE
)
] = vol.In(
{
PHOTOPRISM_IMAGE_PREVIEW: "Preview (1280px)",
PHOTOPRISM_IMAGE_FULLSIZE: "Full size (1920px)",
PHOTOPRISM_IMAGE_ORIGINAL: "High detail (2560px)",
}
)
schema = vol.Schema(fields)
return self.async_show_form(
step_id="photoprism_select", data_schema=schema, errors=errors
)
class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries.
@@ -55,6 +55,40 @@ PROVIDER_GOOGLE_SHARED = "google_shared"
PROVIDER_LOCAL_FOLDER = "local_folder"
PROVIDER_MEDIA_SOURCE = "media_source"
PROVIDER_IMMICH = "immich"
PROVIDER_PHOTOPRISM = "photoprism"
# PhotoPrism (direct API) provider.
CONF_PHOTOPRISM_URL = "photoprism_url"
CONF_PHOTOPRISM_AUTH_METHOD = "photoprism_auth_method"
CONF_PHOTOPRISM_TOKEN = "photoprism_token"
CONF_PHOTOPRISM_USERNAME = "photoprism_username"
CONF_PHOTOPRISM_PASSWORD = "photoprism_password"
CONF_PHOTOPRISM_SELECTION_TYPE = "photoprism_selection_type"
CONF_PHOTOPRISM_SELECTION_ID = "photoprism_selection_id"
CONF_PHOTOPRISM_IMAGE_SIZE = "photoprism_image_size"
CONF_PHOTOPRISM_FILTER = "photoprism_filter"
PHOTOPRISM_AUTH_APP_PASSWORD = "app_password"
PHOTOPRISM_AUTH_USER_PASSWORD = "user_password"
# PhotoPrism thumbnail sizes (from its Thumbnail Image API). ``preview`` is a
# good slideshow default; the larger sizes trade bandwidth for detail.
PHOTOPRISM_IMAGE_PREVIEW = "fit_1280"
PHOTOPRISM_IMAGE_FULLSIZE = "fit_1920"
PHOTOPRISM_IMAGE_ORIGINAL = "fit_2560"
PHOTOPRISM_IMAGE_SIZE_OPTIONS = [
PHOTOPRISM_IMAGE_PREVIEW,
PHOTOPRISM_IMAGE_FULLSIZE,
PHOTOPRISM_IMAGE_ORIGINAL,
]
DEFAULT_PHOTOPRISM_IMAGE_SIZE = PHOTOPRISM_IMAGE_PREVIEW
# Composite: client-side union of albums + people + favorites (+ optional
# search query). PhotoPrism has no OR across filters, so each member is
# queried separately and merged. Selection id is a JSON object
# ``{"albums": [...], "people": [...], "favorites": bool}``; empty means all.
PHOTOPRISM_SELECTION_COMPOSITE = "composite"
FILL_COVER = "cover"
FILL_CONTAIN = "contain"
@@ -31,12 +31,23 @@ from .const import (
CONF_IMMICH_IMAGE_SIZE,
CONF_IMMICH_FILTER,
DEFAULT_IMMICH_IMAGE_SIZE,
CONF_PHOTOPRISM_URL,
CONF_PHOTOPRISM_AUTH_METHOD,
CONF_PHOTOPRISM_TOKEN,
CONF_PHOTOPRISM_USERNAME,
CONF_PHOTOPRISM_PASSWORD,
CONF_PHOTOPRISM_SELECTION_TYPE,
CONF_PHOTOPRISM_SELECTION_ID,
CONF_PHOTOPRISM_IMAGE_SIZE,
CONF_PHOTOPRISM_FILTER,
DEFAULT_PHOTOPRISM_IMAGE_SIZE,
DEFAULT_REVERSE_GEOCODE,
DOMAIN,
PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE,
PROVIDER_IMMICH,
PROVIDER_PHOTOPRISM,
)
from .store import SlideshowStore
@@ -920,6 +931,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_media_source()
elif self.provider == PROVIDER_IMMICH:
data = await self._update_immich()
elif self.provider == PROVIDER_PHOTOPRISM:
data = await self._update_photoprism()
else:
raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed:
@@ -1359,6 +1372,79 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items,
}
async def _update_photoprism(self) -> dict[str, Any]:
"""Fetch photos from PhotoPrism via its REST API.
Unlike Immich, PhotoPrism returns per-photo metadata inline in the
search response, so every ``MediaItem`` is built fully here - there is
no background enrichment pass. Thumbnails carry a preview token in the
URL, so no auth header is needed to fetch the image bytes.
"""
from . import photoprism as pp_api
url = self.entry.data.get(CONF_PHOTOPRISM_URL)
auth_method = self.entry.data.get(CONF_PHOTOPRISM_AUTH_METHOD)
sel_type = self.entry.data.get(CONF_PHOTOPRISM_SELECTION_TYPE)
sel_id = self.entry.data.get(CONF_PHOTOPRISM_SELECTION_ID)
size = self.entry.data.get(
CONF_PHOTOPRISM_IMAGE_SIZE, DEFAULT_PHOTOPRISM_IMAGE_SIZE
)
filter_query = self.entry.data.get(CONF_PHOTOPRISM_FILTER)
if not url or not auth_method or not sel_type:
raise UpdateFailed("PhotoPrism provider is missing URL, auth, or selection")
client = pp_api.PhotoprismClient(
self.hass,
url,
auth_method=auth_method,
token=self.entry.data.get(CONF_PHOTOPRISM_TOKEN),
username=self.entry.data.get(CONF_PHOTOPRISM_USERNAME),
password=self.entry.data.get(CONF_PHOTOPRISM_PASSWORD),
)
try:
photos = await client.async_collect_assets(sel_type, sel_id, filter_query)
except Exception as err:
raise UpdateFailed(f"Error querying PhotoPrism: {err}") from err
if not photos:
raise UpdateFailed("No images found for the selected PhotoPrism source")
token = client.preview_token
if not token:
raise UpdateFailed("PhotoPrism did not return a preview token")
items: list[MediaItem] = []
for p in photos:
uid = p.get("UID")
file_hash = p.get("Hash")
if not uid or not file_hash:
continue
meta = pp_api.parse_photo_meta(p)
w = p.get("Width")
h = p.get("Height")
items.append(
MediaItem(
url=pp_api.build_image_url(client.base_url, file_hash, token, size),
width=w if isinstance(w, int) else None,
height=h if isinstance(h, int) else None,
mime_type=None,
filename=p.get("FileName") or p.get("Name"),
captured_at=meta.get("captured_at"),
latitude=meta.get("latitude"),
longitude=meta.get("longitude"),
location=meta.get("location"),
description=meta.get("description"),
source_id=uid,
exif_scanned=True,
)
)
return {
"title": self.entry.title,
"items": items,
}
async def _enrich_immich_item(self, item: MediaItem) -> None:
"""Fetch one Immich asset's detail and fill location/description."""
from . import immich as immich_api
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"],
"version": "1.2.2"
"version": "1.3.0"
}
@@ -0,0 +1,356 @@
"""PhotoPrism (direct API) client and pure parsing helpers.
Talks to a PhotoPrism server using either an app password (used directly as a
Bearer token) or a username + password (exchanged for a session access token).
HTTP lives in ``PhotoprismClient``; the parsing/URL helpers are pure functions
so they can be unit-tested without a live server or aiohttp.
API shape (PhotoPrism ``/api/v1``, Bearer auth):
- ``POST /api/v1/session`` ``{username, password}`` -> ``{access_token, ...}``
(only needed for the username + password auth method).
- ``GET /api/v1/photos`` ``?count&offset&order=newest&primary=true`` plus a
filter (``s=<album_uid>``, ``q=subject:<uid>``, ``q=favorite:true``, or a
custom ``q=``) -> a JSON list of photos. Metadata is inline (``TakenAt``,
``Lat``/``Lng``, ``PlaceLabel``, ``Title``, ``Description``, ``Portrait``,
``Width``/``Height``), so there is no per-asset enrichment call. The
response headers carry ``X-Preview-Token`` (for thumbnail URLs) and
``X-Count`` (number of items returned, for pagination).
- ``GET /api/v1/albums`` / ``GET /api/v1/subjects`` -> albums / people.
- Image bytes: ``/api/v1/t/<sha1_hash>/<preview_token>/<size>`` - the preview
token in the URL is enough (cookie-free access by design), so no auth
header is needed to fetch thumbnails.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
import async_timeout
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import PHOTOPRISM_SELECTION_COMPOSITE
_TIMEOUT = 30
_PAGE_SIZE = 1000
_MAX_ASSETS = 20_000
# PhotoPrism media types that render as a still image (videos are skipped).
_IMAGE_TYPES = {"image", "raw", "live", "animated"}
def normalize_base_url(url: str) -> str:
"""Strip trailing slashes and a trailing ``/api``/``/api/v1`` from a URL."""
u = (url or "").strip().rstrip("/")
for suffix in ("/api/v1", "/api"):
if u.endswith(suffix):
u = u[: -len(suffix)]
return u.rstrip("/")
def build_image_url(base_url: str, file_hash: str, token: str, size: str) -> str:
"""Build the thumbnail URL for a file hash at the requested size.
The preview ``token`` is included in the URL on purpose: PhotoPrism serves
thumbnails cookie-free and gates them with this rotatable token, so no auth
header is required to fetch the bytes.
"""
base = normalize_base_url(base_url)
return f"{base}/api/v1/t/{file_hash}/{token}/{size}"
def _to_epoch_ms(value: Any) -> int | None:
"""Parse an ISO-8601 timestamp to epoch milliseconds, or ``None``."""
if not isinstance(value, str) or not value:
return None
try:
iso = value.replace("Z", "+00:00")
dt = datetime.fromisoformat(iso)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
try:
return int(dt.timestamp() * 1000)
except (OverflowError, OSError, ValueError):
return None
def location_label(city: Any, state: Any, country: Any) -> str | None:
"""Build a short ``"City, Country"`` label from place fields.
Prefers ``city`` for the locality, falling back to ``state``. Appends the
country when present. Used only when PhotoPrism's own ``PlaceLabel`` is
unavailable.
"""
parts: list[str] = []
locality = None
for candidate in (city, state):
if isinstance(candidate, str) and candidate.strip() and candidate.strip().lower() != "unknown":
locality = candidate.strip()
break
if locality:
parts.append(locality)
if isinstance(country, str) and country.strip() and country.strip().lower() not in ("unknown", "zz"):
parts.append(country.strip())
return ", ".join(parts) if parts else None
def _is_image(item: Any) -> bool:
"""True for photo items that render as a still image (skip videos)."""
if not isinstance(item, dict):
return False
if not item.get("Hash") or not item.get("UID"):
return False
t = str(item.get("Type", "image")).lower()
return t in _IMAGE_TYPES
def parse_photo_meta(item: dict[str, Any]) -> dict[str, Any]:
"""Extract the metadata we surface from a search photo item."""
out: dict[str, Any] = {}
captured = _to_epoch_ms(item.get("TakenAt")) or _to_epoch_ms(item.get("TakenAtLocal"))
if captured is not None:
out["captured_at"] = captured
lat = item.get("Lat")
lng = item.get("Lng")
if isinstance(lat, (int, float)) and isinstance(lng, (int, float)):
# 0/0 means "no fix" in PhotoPrism; treat it as no location.
if not (abs(lat) < 1e-6 and abs(lng) < 1e-6):
out["latitude"] = float(lat)
out["longitude"] = float(lng)
# PhotoPrism builds a human-readable ``PlaceLabel`` (e.g. "San Diego,
# California, USA"); prefer it, falling back to city/country parts.
label = item.get("PlaceLabel")
if not (isinstance(label, str) and label.strip() and label.strip().lower() != "unknown"):
label = location_label(
item.get("PlaceCity"), item.get("PlaceState"), item.get("PlaceCountry")
)
if isinstance(label, str) and label.strip() and label.strip().lower() != "unknown":
out["location"] = label.strip()
# Only surface a real caption. PhotoPrism auto-generates ``Title`` from
# place + date for every photo, so it would be noise as a caption.
desc = item.get("Description")
if isinstance(desc, str) and desc.strip():
out["description"] = desc.strip()
return out
def build_query_params(
selection_type: str, selection_id: str | None
) -> dict[str, str]:
"""Build the search query params for a single (non-composite) member.
``album`` uses the ``s`` scope; ``person`` and ``favorites`` use ``q``
filters; ``search`` passes the user's raw query through; ``all`` adds
nothing (whole library).
"""
if selection_type == "album" and selection_id:
return {"s": selection_id}
if selection_type == "person" and selection_id:
return {"q": f"subject:{selection_id}"}
if selection_type == "favorites":
return {"q": "favorite:true"}
if selection_type == "search" and selection_id:
return {"q": selection_id}
return {}
def parse_composite_selection(selection_id: str | None) -> dict[str, Any]:
"""Parse a composite selection id into ``{albums, people, favorites}``."""
albums: list[str] = []
people: list[str] = []
favorites = False
if selection_id:
try:
data = json.loads(selection_id)
except (ValueError, TypeError):
data = None
if isinstance(data, dict):
albums = [a for a in data.get("albums", []) if isinstance(a, str) and a]
people = [p for p in data.get("people", []) if isinstance(p, str) and p]
favorites = bool(data.get("favorites"))
return {"albums": albums, "people": people, "favorites": favorites}
def build_composite_queries(
selection_id: str | None, filter_query: str | None = None
) -> list[dict[str, str]]:
"""Build one search-param dict per composite union member.
PhotoPrism has no OR across filters, so each album, person, the favorites
flag and any custom query becomes its own request; the caller unions the
results. An empty composite yields a single unfiltered query (the whole
library).
"""
sel = parse_composite_selection(selection_id)
queries: list[dict[str, str]] = []
for uid in sel["albums"]:
queries.append({"s": uid})
for uid in sel["people"]:
queries.append({"q": f"subject:{uid}"})
if sel["favorites"]:
queries.append({"q": "favorite:true"})
if isinstance(filter_query, str) and filter_query.strip():
queries.append({"q": filter_query.strip()})
if not queries:
queries.append({})
return queries
class PhotoprismAuthError(Exception):
"""Raised when PhotoPrism authentication fails."""
class PhotoprismClient:
"""Thin async wrapper over the PhotoPrism REST API."""
def __init__(
self,
hass,
base_url: str,
*,
auth_method: str,
token: str | None = None,
username: str | None = None,
password: str | None = None,
) -> None:
self.hass = hass
self.base_url = normalize_base_url(base_url)
self.auth_method = auth_method
self._token = token
self._username = username
self._password = password
# Bearer token used for API calls: the app password directly, or the
# session access token obtained from username + password.
self._bearer: str | None = token if auth_method == "app_password" else None
# Preview token captured from the most recent search response, used to
# build thumbnail URLs.
self.preview_token: str | None = None
@property
def _headers(self) -> dict[str, str]:
h = {"Accept": "application/json"}
if self._bearer:
h["Authorization"] = "Bearer " + self._bearer
return h
async def _login(self) -> None:
"""Exchange username + password for a session access token."""
session = async_get_clientsession(self.hass)
async with async_timeout.timeout(_TIMEOUT):
async with session.post(
self.base_url + "/api/v1/session",
json={"username": self._username, "password": self._password},
headers={"Accept": "application/json"},
) as resp:
if resp.status != 200:
raise PhotoprismAuthError(f"session login failed: {resp.status}")
data = await resp.json()
token = data.get("access_token") if isinstance(data, dict) else None
if not token:
raise PhotoprismAuthError("session login returned no access_token")
self._bearer = token
async def async_authenticate(self) -> None:
"""Ensure a usable Bearer token is available."""
if self.auth_method == "user_password":
await self._login()
elif not self._bearer:
raise PhotoprismAuthError("no app password configured")
async def _get(self, path: str, params: dict[str, str] | None = None) -> tuple[Any, dict[str, str]]:
"""GET returning ``(json, headers)``, re-authenticating once on 401."""
if self._bearer is None:
await self.async_authenticate()
session = async_get_clientsession(self.hass)
for attempt in (1, 2):
async with async_timeout.timeout(_TIMEOUT):
async with session.get(
self.base_url + path, params=params, headers=self._headers
) as resp:
if resp.status == 401 and attempt == 1 and self.auth_method == "user_password":
# Session token likely expired; re-login and retry once.
await self._login()
continue
resp.raise_for_status()
return await resp.json(), dict(resp.headers)
raise PhotoprismAuthError("unauthorized after re-authentication")
async def async_validate(self) -> None:
"""Authenticate and confirm the search endpoint responds."""
await self.async_authenticate()
await self._get("/api/v1/photos", {"count": "1", "public": "false"})
async def async_list_albums(self) -> list[dict[str, Any]]:
data, _ = await self._get(
"/api/v1/albums", {"count": "1000", "offset": "0", "type": "album"}
)
return data if isinstance(data, list) else []
async def async_list_people(self) -> list[dict[str, Any]]:
data, _ = await self._get(
"/api/v1/subjects", {"count": "1000", "type": "person"}
)
return data if isinstance(data, list) else []
async def async_collect_assets(
self,
selection_type: str,
selection_id: str | None = None,
filter_query: str | None = None,
) -> list[dict[str, Any]]:
"""Collect image photo items for a selection.
``composite`` unions albums + people + favorites (+ optional query);
everything else is a single filtered search. The preview token from the
responses is captured on ``self.preview_token`` for URL building.
"""
if selection_type == PHOTOPRISM_SELECTION_COMPOSITE:
queries = build_composite_queries(selection_id, filter_query)
else:
queries = [build_query_params(selection_type, selection_id)]
return await self._collect_union(queries)
async def _collect_union(
self, queries: list[dict[str, str]]
) -> list[dict[str, Any]]:
"""Union several searches (OR), deduped by photo UID."""
seen: set[str] = set()
out: list[dict[str, Any]] = []
for query in queries:
if len(out) >= _MAX_ASSETS:
break
for item in await self._search(query):
uid = item.get("UID")
if uid and uid not in seen:
seen.add(uid)
out.append(item)
return out
async def _search(self, query: dict[str, str]) -> list[dict[str, Any]]:
"""Page through ``/api/v1/photos`` for one filter, image items only."""
collected: list[dict[str, Any]] = []
offset = 0
while len(collected) < _MAX_ASSETS:
params = {
"count": str(_PAGE_SIZE),
"offset": str(offset),
"order": "newest",
"primary": "true",
"public": "false",
"merged": "false",
}
params.update(query)
data, headers = await self._get("/api/v1/photos", params)
token = headers.get("X-Preview-Token")
if token:
self.preview_token = token
items = data if isinstance(data, list) else []
for it in items:
if _is_image(it):
collected.append(it)
if len(items) < _PAGE_SIZE:
break
offset += _PAGE_SIZE
return collected
+27 -1
View File
@@ -52,6 +52,29 @@
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
}
},
"photoprism": {
"title": "PhotoPrism",
"description": "Connect directly to your PhotoPrism server for full photo metadata (date, location, description). Use an app password (Settings > Account > Apps and Devices) or your username and password. The password is kept on the server side and never reaches the browser.",
"data": {
"photoprism_url": "PhotoPrism URL (e.g. http://192.168.1.10:2342)",
"photoprism_auth_method": "Authentication",
"photoprism_token": "App password (for App password)",
"photoprism_username": "Username (for Username + password)",
"photoprism_password": "Password (for Username + password)"
}
},
"photoprism_select": {
"title": "PhotoPrism source",
"description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add a PhotoPrism search query to include those results too - see the README for examples.",
"data": {
"album_name": "Name",
"albums": "Albums",
"people": "People",
"favorites": "Include favorites",
"photoprism_filter": "Extra search query (optional)",
"photoprism_image_size": "Image quality"
}
}
},
"error": {
@@ -63,7 +86,10 @@
"immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
"immich_people_required": "Pick at least one person for the People source.",
"immich_albums_required": "Pick at least one album for the Albums source."
"immich_albums_required": "Pick at least one album for the Albums source.",
"photoprism_cannot_connect": "Could not connect to PhotoPrism. Check the URL and credentials.",
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
"photoprism_user_required": "Enter both a username and password, or switch to App password."
}
},
"options": {
@@ -52,6 +52,29 @@
"immich_filter": "Extra search filter (JSON, optional)",
"immich_image_size": "Image quality"
}
},
"photoprism": {
"title": "PhotoPrism",
"description": "Connect directly to your PhotoPrism server for full photo metadata (date, location, description). Use an app password (Settings > Account > Apps and Devices) or your username and password. The password is kept on the server side and never reaches the browser.",
"data": {
"photoprism_url": "PhotoPrism URL (e.g. http://192.168.1.10:2342)",
"photoprism_auth_method": "Authentication",
"photoprism_token": "App password (for App password)",
"photoprism_username": "Username (for Username + password)",
"photoprism_password": "Password (for Username + password)"
}
},
"photoprism_select": {
"title": "PhotoPrism source",
"description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add a PhotoPrism search query to include those results too - see the README for examples.",
"data": {
"album_name": "Name",
"albums": "Albums",
"people": "People",
"favorites": "Include favorites",
"photoprism_filter": "Extra search query (optional)",
"photoprism_image_size": "Image quality"
}
}
},
"error": {
@@ -63,7 +86,10 @@
"immich_filter_required": "Custom search needs a JSON filter.",
"immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.",
"immich_people_required": "Pick at least one person for the People source.",
"immich_albums_required": "Pick at least one album for the Albums source."
"immich_albums_required": "Pick at least one album for the Albums source.",
"photoprism_cannot_connect": "Could not connect to PhotoPrism. Check the URL and credentials.",
"photoprism_token_required": "Enter an app password, or switch to Username + password.",
"photoprism_user_required": "Enter both a username and password, or switch to App password."
}
},
"options": {
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info
*/
const VERSION = "1.2.2";
const VERSION = "1.3.0";
const ANIMATED_TRANSITIONS = [
"fade",
File diff suppressed because it is too large Load Diff

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