Updated apps
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -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}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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],
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
@@ -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={})
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||