diff --git a/.cache/brands/integrations/alarmo/logo.png b/.cache/brands/integrations/alarmo/logo.png new file mode 100644 index 0000000..605905c Binary files /dev/null and b/.cache/brands/integrations/alarmo/logo.png differ diff --git a/.cache/brands/integrations/alexa_media/dark_logo.png b/.cache/brands/integrations/alexa_media/dark_logo.png new file mode 100644 index 0000000..de3a926 Binary files /dev/null and b/.cache/brands/integrations/alexa_media/dark_logo.png differ diff --git a/.cache/brands/integrations/ipp/dark_logo.png b/.cache/brands/integrations/ipp/dark_logo.png new file mode 100644 index 0000000..27877b6 Binary files /dev/null and b/.cache/brands/integrations/ipp/dark_logo.png differ diff --git a/.cache/brands/integrations/ring/dark_logo.png b/.cache/brands/integrations/ring/dark_logo.png new file mode 100644 index 0000000..70ae835 Binary files /dev/null and b/.cache/brands/integrations/ring/dark_logo.png differ diff --git a/.cache/brands/integrations/spook/logo.png b/.cache/brands/integrations/spook/logo.png new file mode 100644 index 0000000..469bc8a Binary files /dev/null and b/.cache/brands/integrations/spook/logo.png differ diff --git a/.cache/brands/integrations/uptime_kuma/logo.png b/.cache/brands/integrations/uptime_kuma/logo.png new file mode 100644 index 0000000..2ae3386 Binary files /dev/null and b/.cache/brands/integrations/uptime_kuma/logo.png differ diff --git a/.cache/brands/integrations/watchman/logo.png b/.cache/brands/integrations/watchman/logo.png new file mode 100644 index 0000000..893c06e Binary files /dev/null and b/.cache/brands/integrations/watchman/logo.png differ diff --git a/.ha_run.lock b/.ha_run.lock index 091dc1b..507d246 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784294980.0427423} \ No newline at end of file +{"pid": 72, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784592292.815142} \ No newline at end of file diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..bba3950 --- /dev/null +++ b/.prettierrc.yaml @@ -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" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..55482c3 --- /dev/null +++ b/AGENTS.md @@ -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 `/llm.py` and registered LLM APIs. New Home Assistant builds may also expose those APIs over native MCP endpoints such as `/api/mcp/`; 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 `/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 ` +- **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 --help` for full usage details. + + +``` +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. +``` + + +**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`. + + +``` + + 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. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +``` + + +**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 `/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 diff --git a/ai_agent_ha_debug/failed_response_20260719_015459.txt b/ai_agent_ha_debug/failed_response_20260719_015459.txt new file mode 100644 index 0000000..e088745 --- /dev/null +++ b/ai_agent_ha_debug/failed_response_20260719_015459.txt @@ -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. diff --git a/ai_agent_ha_debug/failed_response_20260719_015535.txt b/ai_agent_ha_debug/failed_response_20260719_015535.txt new file mode 100644 index 0000000..8d0a620 --- /dev/null +++ b/ai_agent_ha_debug/failed_response_20260719_015535.txt @@ -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) diff --git a/automations.yaml b/automations.yaml index a8780ff..21d332e 100644 --- a/automations.yaml +++ b/automations.yaml @@ -1,22 +1,3 @@ -- id: '1780072464609' - alias: 'Camera: Send Motion Notifications' - description: Sends a push notification to your primary mobile device when Nest cameras - detect activity. - triggers: - - entity_id: - - camera.entryway_camera - - camera.front_door - - camera.hallway_camera - - camera.living_room_camera - trigger: state - conditions: [] - actions: - - action: notify.notify - data: - title: Motion Detected! - message: Activity detected on one of your Nest security cameras. - mode: parallel - max: 10 - id: '1780125759253' alias: 'Tablet: Dim Screen at Night' description: Drops tablet brightness to zero at night @@ -41,64 +22,6 @@ message: command_screen_brightness_level data: command: 180 -- id: '1780343863793' - alias: Front Door Notification - description: '' - triggers: - - trigger: state - entity_id: - - binary_sensor.front_door - from: - - 'off' - to: - - 'on' - - trigger: state - entity_id: - - binary_sensor.patio_door - from: - - 'off' - to: - - 'on' - conditions: [] - actions: - - target: - entity_id: - - light.kitchen_home_kitchen - action: light.toggle - - delay: 00:00:15 - - target: - entity_id: - - light.kitchen_home_kitchen - action: light.toggle - - data: - message: There is someone at the front door - action: notify.notify - mode: single - variables: - _cafe_metadata: - version: 1 - strategy: native - nodes: - trigger_1780343037167_0: - x: -615 - y: -705 - action_1780343442574_1: - x: -360 - y: -630 - delay_1780343604675_2: - x: -120 - y: -360 - action_1780343739141_3: - x: 75 - y: -615 - action_1780343804458_4: - x: 330 - y: -615 - trigger_1780343965767_5: - x: -615 - y: -555 - graph_id: 7ad98446-4ce0-4b88-a400-430ac474a617 - graph_version: 1 - id: '1780441470361' alias: Reload Hilo Integrations on Reboot description: Reloads problematic integrations 2 minutes after Home Assistant starts @@ -281,6 +204,8 @@ alias: 'Office: Light Control Motion' description: Turns on office lights when the door opens, or via motion if turned off by inactivity within the last hour. Turns off lights after 1 hour of no occupancy. + Prevents motion from re-triggering if turned off manually/Alexa. Respects a master + override toggle helper. triggers: - trigger: state entity_id: binary_sensor.office_door_contact @@ -308,6 +233,9 @@ - condition: state entity_id: light.playroom_light state: 'off' + - condition: state + entity_id: input_boolean.office_motion_override + state: 'off' - condition: or conditions: - condition: trigger @@ -318,7 +246,7 @@ id: motion_detected - condition: template value_template: '{{ (now() - states.light.playroom_light.last_changed).total_seconds() - < 3600 }}' + < 3600 and states.light.playroom_light.context.parent_id == none }}' sequence: - action: light.turn_on target: @@ -958,18 +886,44 @@ ''person'', ''animal'', ''chime''] }}' enabled: false actions: - - action: blink.trigger_camera + - action: camera.record target: - entity_id: camera.backyard_shed + entity_id: + - camera.blink_backyard_shed + data: + duration: 10 + lookback: 0 + filename: /media/blinkshed.mp4 + enabled: false + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + enabled: false + - action: homeassistant.update_entity + target: + entity_id: camera.blink_backyard_shed data: {} - delay: hours: 0 minutes: 0 - seconds: 6 + seconds: 2 + milliseconds: 0 + - action: camera.snapshot + target: + entity_id: camera.blink_backyard_shed + data: + filename: /config/www/flush_cache.jpg + - delay: + hours: 0 + minutes: 0 + seconds: 3 milliseconds: 0 - action: homeassistant.update_entity target: - entity_id: camera.backyard_shed + entity_id: camera.blink_backyard_shed + data: {} - delay: hours: 0 minutes: 0 @@ -981,18 +935,18 @@ notification_id: blink_shed_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} - action: camera.snapshot target: - entity_id: camera.backyard_shed - data: - filename: /media/{{ snapshot_filename }} - - action: camera.snapshot - target: - entity_id: camera.backyard_shed + entity_id: camera.blink_backyard_shed data: filename: /config/www/{{ snapshot_filename }} + - action: camera.snapshot + target: + entity_id: camera.blink_backyard_shed + data: + filename: /media/{{ snapshot_filename }} - delay: hours: 0 minutes: 0 - seconds: 4 + seconds: 2 milliseconds: 0 - action: ai_task.generate_data continue_on_error: true @@ -1038,111 +992,6 @@ - binary_sensor.backyard_shed_motion - binary_sensor.front_door_motion mode: single -- id: '1783304677475' - alias: Motion Detection AI Tree - description: Takes a snapshot when the Blink camera detects motion, analyzes it - with AI, and sends a mobile and persistent notification. - triggers: - - trigger: state - entity_id: binary_sensor.backyard_tree_motion - to: 'on' - conditions: [] - actions: - - action: blink.trigger_camera - target: - entity_id: camera.backyard_tree - - delay: 00:00:02 - - action: camera.snapshot - target: - entity_id: camera.backyard_tree - data: - filename: /media/blinkTree.jpg - - action: camera.snapshot - target: - entity_id: camera.backyard_tree - data: - filename: /config/www/blinkTree.jpg - - delay: 00:00:02 - - action: ai_task.generate_data - continue_on_error: true - data: - task_name: Backyard Shed Camera Analysis - entity_id: ai_task.google_ai_task - instructions: Describe what you see in this image in brief. Focus on any people, - objects, animals or activities. Text needs to be max 240 characters. - attachments: - - media_content_id: media-source://media_source/local/blinkTree.jpg - media_content_type: image/jpeg - response_variable: ai_profile - - action: notify.notify - data: - title: Motion Detected - Backyard Tree - message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile - else ''Motion detected, but the AI Task integration failed to return a response.'' - }}' - - action: persistent_notification.create - data: - title: Motion Detected - Backyard Tree - notification_id: backyard_tree_motion - message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile - else ''Motion detected, but the AI Task integration failed to return a response.'' - }} - - ![Snapshot](/local/blinkTree.jpg)' - mode: queued - max: 10 -- id: '1783387792679' - description: Takes a snapshot when the Blink camera detects motion, analyzes it - with AI, and sends a mobile and persistent notification. - triggers: - - trigger: state - entity_id: binary_sensor.backyard_shed_motion - to: 'on' - conditions: [] - actions: - - action: blink.trigger_camera - target: - entity_id: camera.living_room_living_room_camera - - delay: 00:00:02 - - action: camera.snapshot - target: - entity_id: camera.living_room_living_room_camera - data: - filename: /media/blink.jpg - - action: camera.snapshot - target: - entity_id: camera.living_room_living_room_camera - data: - filename: /config/www/nest.jpg - - delay: 00:00:02 - - action: ai_task.generate_data - continue_on_error: true - data: - task_name: Backyard Shed Camera Analysis - entity_id: ai_task.google_ai_task - instructions: Describe what you see in this image in brief. Focus on any people, - objects, or activities. Text needs to be max 225 characters. - attachments: - - media_content_id: media-source://media_source/local/nest.jpg - media_content_type: image/jpeg - response_variable: ai_profile - - action: notify.notify - data: - title: Motion Detected - Backyard Shed - message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile - else ''Motion detected, but the AI Task integration failed to return a response.'' - }}' - - action: persistent_notification.create - data: - title: Motion Detected - Backyard Shed - notification_id: backyard_shed_motion2 - message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile - else ''Motion detected, but the AI Task integration failed to return a response.'' - }} - - ![Snapshot](/local/nest.jpg)' - mode: queued - max: 10 - id: '1783452320698' alias: Nest Test Motion description: Takes a snapshot when the Blink camera detects motion, analyzes it @@ -1253,34 +1102,41 @@ actions: - action: blink.trigger_camera target: - entity_id: camera.front + entity_id: camera.blink_front data: {} + enabled: false - delay: hours: 0 minutes: 0 - seconds: 3 + seconds: 10 milliseconds: 0 + enabled: false - action: homeassistant.update_entity target: - entity_id: camera.front + entity_id: camera.blink_front + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 - variables: snapshot_filename: blink_Front_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg - variables: notification_id: blink_front_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} - action: camera.snapshot target: - entity_id: camera.front + entity_id: camera.blink_front data: filename: /media/{{ snapshot_filename }} - action: camera.snapshot target: - entity_id: camera.front + entity_id: camera.blink_front data: filename: /config/www/{{ snapshot_filename }} - delay: hours: 0 minutes: 0 - seconds: 4 + seconds: 2 milliseconds: 0 - action: ai_task.generate_data continue_on_error: true @@ -1322,17 +1178,6 @@ notification_id: '{{ notification_id }}' mode: queued max: 10 -- id: '1783541174963' - alias: Daily Snapshot Purge - description: Triggers the snapshot purge script every night at midnight. - triggers: - - trigger: time - at: 00:00:00 - conditions: [] - actions: - - action: script.purge_old_blink_snapshot - data: {} - mode: single - id: '1783615071224' alias: 'TEST: AI Camera Motion Detection OLLAMA' description: Takes a snapshot when the Nest camera detects motion, a person, an @@ -1387,11 +1232,13 @@ target: entity_id: camera.front data: {} + enabled: false - delay: hours: 0 minutes: 0 seconds: 10 milliseconds: 0 + enabled: false - action: homeassistant.update_entity target: entity_id: camera.front @@ -1401,12 +1248,12 @@ notification_id: blink_front_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} - action: camera.snapshot target: - entity_id: camera.front + entity_id: camera.blink_front data: filename: /media/{{ snapshot_filename }} - action: camera.snapshot target: - entity_id: camera.front + entity_id: camera.blink_front data: filename: /config/www/{{ snapshot_filename }} - delay: @@ -1418,7 +1265,7 @@ continue_on_error: true data: task_name: Nest Front Camera Analysis - entity_id: ai_task.ollama_ai_task + entity_id: ai_task.ollama_ai_task_2 instructions: Describe what you see in this image in brief. Focus on any people, objects, animals or activities. Text needs to be max 240 characters. attachments: @@ -1562,34 +1409,41 @@ actions: - action: blink.trigger_camera target: - entity_id: camera.backyard + entity_id: camera.blink_backyard data: {} + enabled: false - delay: hours: 0 minutes: 0 - seconds: 3 + seconds: 10 milliseconds: 0 + enabled: false - action: homeassistant.update_entity target: - entity_id: camera.backyard + entity_id: camera.blink_backyard + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 - variables: snapshot_filename: blink_backyard_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg - variables: notification_id: blink_backyard_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} - action: camera.snapshot target: - entity_id: camera.backyard + entity_id: camera.blink_backyard data: filename: /media/{{ snapshot_filename }} - action: camera.snapshot target: - entity_id: camera.backyard + entity_id: camera.blink_backyard data: filename: /config/www/{{ snapshot_filename }} - delay: hours: 0 minutes: 0 - seconds: 4 + seconds: 2 milliseconds: 0 - action: ai_task.generate_data continue_on_error: true @@ -1680,34 +1534,41 @@ actions: - action: blink.trigger_camera target: - entity_id: camera.backyard_tree + entity_id: camera.blink_backyard_tree data: {} + enabled: false - delay: hours: 0 minutes: 0 - seconds: 3 + seconds: 10 milliseconds: 0 + enabled: false - action: homeassistant.update_entity target: - entity_id: camera.backyard_tree + entity_id: camera.blink_backyard_tree + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 - variables: snapshot_filename: blink_Inshed_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg - variables: notification_id: blink_Inshed_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} - action: camera.snapshot target: - entity_id: camera.backyard_tree + entity_id: camera.blink_backyard_tree data: filename: /media/{{ snapshot_filename }} - action: camera.snapshot target: - entity_id: camera.backyard_tree + entity_id: camera.blink_backyard_tree data: filename: /config/www/{{ snapshot_filename }} - delay: hours: 0 minutes: 0 - seconds: 4 + seconds: 2 milliseconds: 0 - action: ai_task.generate_data continue_on_error: true @@ -2170,10 +2031,9 @@ data: brightness_pct: 25 - id: '1783962246983' - alias: 'OFFICE: IKEA BILRESA E2489 Dual Button (Matter) - In Meeting and Playroom - Lights' - description: Ikea BILRESA E2489 Dual Button controls in Meeting toggle, and playroom - lights + alias: 'OFFICE: IKEA BILRESA E2489 Dual Button (Matter) - In Meeting and Away Mode' + description: Ikea BILRESA E2489 Dual Button controls in Meeting toggle, and enable + Away Mode use_blueprint: path: censay/ikea-bilresa-e2489-matter-smart-button.yaml input: @@ -2187,25 +2047,14 @@ entity_id: input_boolean.in_a_meeting data: {} button2_single: - - action: light.toggle - metadata: {} - target: - device_id: 2f8c5ab2ca0d4be31919fba6df365813 - data: - brightness_pct: 35 - button2_double: - - action: light.turn_on - metadata: {} - target: - device_id: 2f8c5ab2ca0d4be31919fba6df365813 - data: - brightness_pct: 80 - button1_long_press: - action: input_boolean.toggle metadata: {} target: - entity_id: input_boolean.meeting_lamp_override + entity_id: input_boolean.away_mode data: {} + button2_double: [] + button1_long_press: [] + button2_long_press: [] - id: '1783972932942' alias: 'Notification: Office Meeting Status' description: Sends a notification when entering or leaving a meeting @@ -2221,7 +2070,8 @@ conditions: [] actions: - choose: - - conditions: + - alias: Meeting Started Notification + conditions: - condition: trigger id: meeting_started sequence: @@ -2229,10 +2079,8 @@ data: title: Meeting Active message: "Your status is now set to: \U0001F534 DO NOT ENTER" - data: - push: - sound: critical - - conditions: + - alias: Meeting Ended Notification + conditions: - condition: trigger id: meeting_ended sequence: @@ -2340,9 +2188,10 @@ - id: '1784073759556' alias: 'Office: Desk Lamp Meeting Alert' description: Flashes the desk lamp color temperature for a few seconds when a meeting - status changes, unless the override helper is active. + status changes (on or off) to confirm the button press registered. triggers: - - entity_id: input_boolean.in_a_meeting + - id: meeting_changed + entity_id: input_boolean.in_a_meeting trigger: state conditions: - condition: state @@ -2360,20 +2209,30 @@ data: brightness_pct: 100 color_temp_kelvin: 2261 - - delay: 00:00:03 + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 - action: scene.turn_on target: entity_id: scene.desk_lamp_before_alert data: {} - mode: restart + mode: queued + max: 2 - id: '1784153893584' - alias: 'Office: Manual Meeting Status Busy Light - Working Hours v2' - description: 'Work hours: Toggle controls Red/Green (Motion ignored). After hours - OR Away Mode: Pure motion night light (Toggle ignored).' + alias: 'Office: Manual Meeting Status Busy Light (Night Light) - Working Hours + + Motion Detection - v2' + description: 'Work hours: Automatically turns Green or Red at 8 AM depending on + status. Toggle controls Red/Green. After hours OR Away Mode: Pure motion night + light. Instant transition when Away changes. Only triggers motion if dark.' triggers: - id: status_changed entity_id: input_boolean.in_a_meeting trigger: state + - id: away_changed + entity_id: input_boolean.away_mode + trigger: state - id: motion_detected entity_id: binary_sensor.night_light_occupancy trigger: state @@ -2382,111 +2241,91 @@ entity_id: binary_sensor.night_light_occupancy trigger: state to: 'off' + - id: work_day_started + trigger: time + at: 08:00:00 conditions: [] actions: + - variables: + is_work_hours: '{{ 8 <= now().hour < 18 and now().isoweekday() in [1,2,3,4,5] + }}' + is_away: '{{ is_state(''input_boolean.away_mode'', ''on'') }}' + is_dark: '{{ states(''sensor.night_light_illuminance'') | float(0) < 15 }}' - choose: - - conditions: - - condition: trigger - id: status_changed - - condition: state - entity_id: input_boolean.in_a_meeting - state: 'on' - - condition: state - entity_id: input_boolean.away_mode - state: 'off' - - condition: time - after: 08:00:00 - before: '18:00:00' - weekday: - - mon - - tue - - wed - - thu - - fri + - alias: '--- WORKING HOURS MODE ---' + conditions: + - condition: template + value_template: '{{ is_work_hours and not is_away and (trigger.id in [''status_changed'', + ''work_day_started''] or (trigger.id == ''away_changed'' and not is_away)) + }}' sequence: - - action: light.turn_on - target: - entity_id: light.night_light - data: - brightness_pct: 100 - rgb_color: - - 255 - - 0 - - 0 - - conditions: - - condition: trigger - id: status_changed - - condition: state - entity_id: input_boolean.in_a_meeting - state: 'off' - - condition: state - entity_id: input_boolean.away_mode - state: 'off' - - condition: time - after: 08:00:00 - before: '18:00:00' - weekday: - - mon - - tue - - wed - - thu - - fri - sequence: - - action: light.turn_on - target: - entity_id: light.night_light - data: - brightness_pct: 20 - rgb_color: - - 5 - - 245 - - 45 - - conditions: - - condition: trigger - id: motion_detected - - condition: or - conditions: - - condition: state - entity_id: input_boolean.away_mode - state: 'on' - - condition: not + - choose: + - alias: 'Meeting is active: Set light to solid Red (100%)' conditions: - - condition: time - after: 08:00:00 - before: '18:00:00' - weekday: - - mon - - tue - - wed - - thu - - fri + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'on' + sequence: + - action: light.turn_on + target: + entity_id: light.night_light + data: + brightness_pct: 100 + rgb_color: + - 255 + - 0 + - 0 + - alias: 'Meeting is inactive: Set light to solid Green (20%)' + conditions: + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'off' + sequence: + - action: light.turn_on + target: + entity_id: light.night_light + data: + brightness_pct: 30 + rgb_color: + - 5 + - 245 + - 45 + - alias: '--- AFTER HOURS / AWAY: MOTION DETECTED ---' + conditions: + - condition: template + value_template: '{{ is_dark and (not is_work_hours or is_away) and trigger.id + == ''motion_detected'' }}' sequence: - action: light.turn_on target: entity_id: light.night_light data: - brightness_pct: 30 - - conditions: - - condition: trigger - id: motion_cleared - - condition: or - conditions: - - condition: state - entity_id: input_boolean.away_mode - state: 'on' - - condition: not - conditions: - - condition: time - after: 08:00:00 - before: '18:00:00' - weekday: - - mon - - tue - - wed - - thu - - fri + brightness_pct: 40 + color_temp_kelvin: 2700 + - alias: '--- AFTER HOURS / AWAY: MOTION CLEARED ---' + conditions: + - condition: template + value_template: '{{ (not is_work_hours or is_away) and trigger.id == ''motion_cleared'' + }}' + sequence: + - delay: 00:00:05 + - action: light.turn_off + target: + entity_id: light.night_light + - alias: '--- INSTANT AWAY TRANSITION ---' + conditions: + - condition: template + value_template: '{{ trigger.id == ''away_changed'' and is_away }}' + sequence: + - action: light.turn_off + target: + entity_id: light.night_light + - alias: '--- FALLBACK: MEETING ENDS AFTER HOURS ---' + conditions: + - condition: template + value_template: '{{ not is_work_hours and trigger.id == ''status_changed'' + and is_state(''input_boolean.in_a_meeting'', ''off'') }}' sequence: - - delay: 00:00:02 - action: light.turn_off target: entity_id: light.night_light @@ -2527,3 +2366,601 @@ 'volume_level') | float * 100) | round(0) }}\n{% else %}\n 20\n{% endif %}" mode: restart +- id: ai_agent_auto_1784596277269 + alias: Blink Shed Motion Notification + description: Sends a notification when motion is detected by the Blink Backyard + Shed camera. + triggers: + - entity_id: binary_sensor.blink_backyard_shed_motion + to: 'on' + trigger: state + - entity_id: + - binary_sensor.blink_backyard_tree_motion + to: + - 'on' + trigger: state + - entity_id: + - binary_sensor.blink_front_motion + to: + - 'on' + trigger: state + - entity_id: + - binary_sensor.blink_backyard_tree_motion + to: + - 'on' + trigger: state + conditions: [] + actions: + - data: + title: Motion Detected! + message: Motion detected in the Blink Backyard Cameras using HomeBridge. + action: persistent_notification.create + mode: single +- id: '1784597653614' + alias: New automation + description: '' + triggers: [] + conditions: [] + actions: + - action: webrtc.create_link + metadata: {} + data: + open_limit: 1 + time_to_live: 60 + entity: camera.blink_backyard_shed + mode: single +- id: '1784599010203' + alias: Motion Backyard - AI Description - Version 2 + description: Takes a snapshot when the Nest camera detects motion, a person, an + animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent + notification. + triggers: + - trigger: state + entity_id: + - input_boolean.blink_backyard_camera_motion + to: + - 'on' + enabled: true + - trigger: event + event_type: state_changed + event_data: + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: state + entity_id: event.living_room_living_room_camera_motion + enabled: false + - trigger: state + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: event.received + target: + entity_id: event.entryway_entryway_camera_motion + options: + event_type: + - camera_motion + - camera_person + - camera_sound + enabled: false + - trigger: event.received + target: + device_id: 290f56e5a655e3af82cbf3cf9fd22406 + options: + event_type: + - camera_motion + - camera_person + - camera_sound + - ring + enabled: false + - trigger: event.received + target: + device_id: 679d52e0d16cf48648d11dd024b961f8 + options: + event_type: + - camera_motion + - camera_sound + - camera_person + enabled: false + conditions: + - condition: template + value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', + ''person'', ''animal'', ''chime''] }}' + enabled: false + actions: + - action: switch.turn_on + target: + entity_id: switch.backyard_refresh_snapshot + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 15 + milliseconds: 0 + - action: homeassistant.update_entity + target: + entity_id: camera.blink_backyard + enabled: true + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 3 + milliseconds: 0 + - action: blink.trigger_camera + target: + entity_id: camera.blink_backyard + data: {} + enabled: false + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + enabled: false + - variables: + snapshot_filename: blink_backyard_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg + - variables: + notification_id: blink_backyard_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} + - action: camera.snapshot + target: + entity_id: camera.blink_backyard + data: + filename: /media/{{ snapshot_filename }} + - action: camera.snapshot + target: + entity_id: camera.blink_backyard + data: + filename: /config/www/{{ snapshot_filename }} + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - action: ai_task.generate_data + continue_on_error: true + data: + task_name: Blink Backyard Camera Analysis + entity_id: ai_task.google_ai_task + instructions: Describe what you see in this image in brief, but make it a little + humorous. Focus on any people, objects, animals or activities. Text needs + to be max 240 characters. + attachments: + - media_content_id: media-source://media_source/local/{{ snapshot_filename }} + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + continue_on_error: true + data: + title: Motion Detected - Backyard + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: persistent_notification.create + data: + title: Motion Detected - Backyard + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/{{ snapshot_filename }})' + notification_id: '{{ notification_id }}' + mode: queued + max: 10 +- id: '1784601034537' + alias: Motion Front Door - AI Description - Version 2 + description: Takes a snapshot when the Nest camera detects motion, a person, an + animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent + notification. + triggers: + - trigger: event + event_type: state_changed + event_data: + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: state + entity_id: event.living_room_living_room_camera_motion + enabled: false + - trigger: state + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: event.received + target: + entity_id: event.entryway_entryway_camera_motion + options: + event_type: + - camera_motion + - camera_person + - camera_sound + enabled: false + - trigger: event.received + target: + device_id: 290f56e5a655e3af82cbf3cf9fd22406 + options: + event_type: + - camera_motion + - camera_person + - camera_sound + - ring + - trigger: event.received + target: + device_id: 679d52e0d16cf48648d11dd024b961f8 + options: + event_type: + - camera_motion + - camera_sound + - camera_person + enabled: false + - trigger: state + entity_id: + - input_boolean.blink_front_camera_motion + to: + - 'on' + enabled: true + conditions: + - condition: template + value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', + ''person'', ''animal'', ''chime''] }}' + enabled: false + actions: + - action: switch.turn_on + target: + entity_id: switch.front_refresh_snapshot + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 15 + milliseconds: 0 + - action: homeassistant.update_entity + target: + entity_id: camera.front + enabled: true + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 3 + milliseconds: 0 + - action: blink.trigger_camera + target: + entity_id: camera.front + data: {} + enabled: false + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + enabled: false + - variables: + snapshot_filename: blink_Front_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg + - variables: + notification_id: blink_front_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} + - action: camera.snapshot + target: + entity_id: camera.front + data: + filename: /media/{{ snapshot_filename }} + - action: camera.snapshot + target: + entity_id: camera.front + data: + filename: /config/www/{{ snapshot_filename }} + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - action: ai_task.generate_data + continue_on_error: true + data: + task_name: Nest Front Camera Analysis + entity_id: ai_task.google_ai_task + instructions: Describe what you see in this image in brief, but make it a little + humorous. Focus on any people, objects, animals or activities. Text needs + to be max 240 characters. + attachments: + - media_content_id: media-source://media_source/local/{{ snapshot_filename }} + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + continue_on_error: true + data: + title: Motion Detected - Front Door + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: notify.persistent_notification + data: + title: Motion Detected - Front Door + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/{{ snapshot_filename }})' + data: + notification_id: nest_front_motion + enabled: false + - action: persistent_notification.create + metadata: {} + data: + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} ![Snapshot](/local/{{ snapshot_filename }})' + title: Motion Detected - Front Door + notification_id: '{{ notification_id }}' + mode: queued + max: 10 +- id: '1784601274723' + alias: Motion Shed - AI Description - Version 2 + description: Takes a snapshot when the Nest camera detects motion, a person, an + animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent + notification. + triggers: + - trigger: state + entity_id: input_boolean.blink_shed_camera_motion + to: 'on' + enabled: true + - trigger: event + event_type: state_changed + event_data: + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: state + entity_id: event.living_room_living_room_camera_motion + enabled: false + - trigger: state + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: event.received + target: + entity_id: event.entryway_entryway_camera_motion + options: + event_type: + - camera_motion + - camera_person + - camera_sound + enabled: false + - trigger: event.received + target: + device_id: 290f56e5a655e3af82cbf3cf9fd22406 + options: + event_type: + - camera_motion + - camera_person + - camera_sound + - ring + enabled: false + - trigger: event.received + target: + device_id: 679d52e0d16cf48648d11dd024b961f8 + options: + event_type: + - camera_motion + - camera_sound + - camera_person + enabled: false + conditions: + - condition: template + value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', + ''person'', ''animal'', ''chime''] }}' + enabled: false + actions: + - action: switch.turn_on + target: + entity_id: switch.backyard_shed_refresh_snapshot + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 15 + milliseconds: 0 + - action: homeassistant.update_entity + target: + entity_id: camera.backyard_shed + enabled: true + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 3 + milliseconds: 0 + - action: camera.record + target: + entity_id: + - camera.backyard_shed + data: + duration: 10 + lookback: 0 + filename: /media/blinkshed.mp4 + enabled: false + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + enabled: false + - variables: + snapshot_filename: blink_shed_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg + - variables: + notification_id: blink_shed_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} + - action: camera.snapshot + target: + entity_id: camera.backyard_shed + data: + filename: /config/www/{{ snapshot_filename }} + - action: camera.snapshot + target: + entity_id: camera.backyard_shed + data: + filename: /media/{{ snapshot_filename }} + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - action: ai_task.generate_data + continue_on_error: true + data: + task_name: Blink Shed Camera Analysis + entity_id: ai_task.google_ai_task + instructions: Describe what you see in this image in brief, but make it a little + humorous. Focus on any people, objects, animals or activities. Text needs + to be max 240 characters. + attachments: + - media_content_id: media-source://media_source/local/{{ snapshot_filename }} + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + continue_on_error: true + data: + title: Motion Detected - Backyard Shed + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: persistent_notification.create + data: + title: Motion Detected - Backyard Shed + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/{{ snapshot_filename }})' + notification_id: '{{ notification_id }}' + mode: queued + max: 10 +- id: '1784601377055' + alias: Motion Garage - AI Description - Version 2 + description: Takes a snapshot when the Nest camera detects motion, a person, an + animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent + notification. + triggers: + - trigger: state + entity_id: + - input_boolean.blink_inside_shed_camera_motion + to: + - 'on' + enabled: true + - trigger: event + event_type: state_changed + event_data: + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: state + entity_id: event.living_room_living_room_camera_motion + enabled: false + - trigger: state + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: event.received + target: + entity_id: event.entryway_entryway_camera_motion + options: + event_type: + - camera_motion + - camera_person + - camera_sound + enabled: false + - trigger: event.received + target: + device_id: 290f56e5a655e3af82cbf3cf9fd22406 + options: + event_type: + - camera_motion + - camera_person + - camera_sound + - ring + enabled: false + - trigger: event.received + target: + device_id: 679d52e0d16cf48648d11dd024b961f8 + options: + event_type: + - camera_motion + - camera_sound + - camera_person + enabled: false + conditions: + - condition: template + value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', + ''person'', ''animal'', ''chime''] }}' + enabled: false + actions: + - action: switch.turn_on + target: + entity_id: switch.backyard_tree_refresh_snapshot + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 15 + milliseconds: 0 + - action: homeassistant.update_entity + target: + entity_id: camera.backyard_tree + enabled: true + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 3 + milliseconds: 0 + - action: blink.trigger_camera + target: + entity_id: camera.backyard_tree + data: {} + enabled: false + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + enabled: false + - variables: + snapshot_filename: blink_Inshed_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg + - variables: + notification_id: blink_Inshed_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} + - action: camera.snapshot + target: + entity_id: camera.backyard_tree + data: + filename: /media/{{ snapshot_filename }} + - action: camera.snapshot + target: + entity_id: camera.backyard_tree + data: + filename: /config/www/{{ snapshot_filename }} + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - action: ai_task.generate_data + continue_on_error: true + data: + task_name: Blink InShed Camera Analysis + entity_id: ai_task.google_ai_task + instructions: Describe what you see in this image in brief, but make it a little + humorous. Focus on any people, objects, animals or activities. Text needs + to be max 240 characters. + attachments: + - media_content_id: media-source://media_source/local/{{ snapshot_filename }} + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + continue_on_error: true + data: + title: Motion Detected - Backyard Inside Shed + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: persistent_notification.create + data: + title: Motion Detected - Backyard Inside Shed + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/{{ snapshot_filename }})' + notification_id: '{{ notification_id }}' + mode: queued + max: 10 diff --git a/automations.yaml.bak b/automations.yaml.bak new file mode 100644 index 0000000..a528d5f --- /dev/null +++ b/automations.yaml.bak @@ -0,0 +1,2348 @@ +- id: '1780125759253' + alias: 'Tablet: Dim Screen at Night' + description: Drops tablet brightness to zero at night + triggers: + - at: 01:00:00 + trigger: time + actions: + - action: notify.mobile_app_sm_t387w + data: + message: command_screen_brightness_level + data: + command: 0 +- id: '1780125803824' + alias: 'Tablet: Restore Screen in Morning' + description: Restores tablet brightness to normal in the morning + triggers: + - at: 07:00:00 + trigger: time + actions: + - action: notify.mobile_app_sm_t387w + data: + message: command_screen_brightness_level + data: + command: 180 +- id: '1780441470361' + alias: Reload Hilo Integrations on Reboot + description: Reloads problematic integrations 2 minutes after Home Assistant starts + up. + triggers: + - trigger: homeassistant + event: start + conditions: [] + actions: + - delay: + hours: 0 + minutes: 3 + seconds: 0 + milliseconds: 0 + - action: homeassistant.reload_config_entry + metadata: {} + target: + device_id: + - 3e67afd88a4b4d17552c8f370aaf40b5 + - baad176ac2f415f3642e2d075bb6a977 + data: {} + mode: single +- id: '1780509027967' + alias: Notify When Ring Console Battery Low + description: '' + triggers: + - trigger: numeric_state + entity_id: + - sensor.entryway_keypad_battery + below: 25 + conditions: [] + actions: + - data: + message: Keypad Battery Low. Please Charge ASAP + title: Ring Console Battery + action: notify.notify + mode: single + variables: + _cafe_metadata: + version: 1 + strategy: native + nodes: + trigger_1780508927611_4: + x: 15 + y: -105 + action_1780508992783_5: + x: 285 + y: 45 + graph_id: 816af3b7-3eac-47c3-8da3-8797d1774c0c + graph_version: 1 +- id: '1780516946807' + alias: Notify When Front Door Sensor Battery Low + description: '' + triggers: + - trigger: numeric_state + entity_id: + - sensor.front_door_battery + below: 25 + conditions: [] + actions: + - data: + message: Front Door Sensor Battery Low. Please Change Batteries ASAP. + title: Ring Console Battery + action: notify.notify + mode: single + variables: + _cafe_metadata: + version: 1 + strategy: native + nodes: + trigger_1780508927611_4: + x: 15 + y: -105 + action_1780508992783_5: + x: 285 + y: 45 + graph_id: 816af3b7-3eac-47c3-8da3-8797d1774c0c + graph_version: 1 +- id: '1780813227139' + alias: 'Alert: Fridge or Freezer Power Outage' + description: Triggers an alert if the fridge or freezer smart plug power consumption + drops to 0W for too long. + triggers: + - trigger: numeric_state + entity_id: + - sensor.freezer_power + for: + hours: 0 + minutes: 45 + seconds: 0 + below: 0.2 + id: freezer_down + - trigger: numeric_state + entity_id: + - sensor.fridge_power + for: + hours: 0 + minutes: 20 + seconds: 0 + below: 0.2 + id: fridge_down + conditions: [] + actions: + - if: + - condition: trigger + id: freezer_down + then: + - data: + title: "\U0001F6A8 URGENT: Freezer Power Failure" + message: The Freezer smart plug has drawn 0W of power for over 40 minutes! + Check the physical appliance immediately. + action: notify.notify + else: + - if: + - condition: trigger + id: fridge_down + then: + - data: + title: "\U0001F6A8 URGENT: Fridge Power Failure" + message: The Fridge smart plug has drawn 0W of power for over 20 minutes! + Check the physical appliance immediately. + action: notify.notify + else: [] + mode: single + variables: + _cafe_metadata: + version: 1 + strategy: native + nodes: + trigger_1780846348490_0_0: + x: 12 + y: 182 + trigger_1780846348490_1_1: + x: 12 + y: 22 + condition_1780846348491_2_2: + x: 312 + y: 92 + action_1780846348491_3_3: + x: 632 + y: 12 + condition_1780846348491_4_4: + x: 632 + y: 172 + action_1780846348491_5_5: + x: 952 + y: 102 + graph_id: 5948b900-b45d-4c2e-ae73-c880acc3a1bf + graph_version: 1 +- id: '1780989340205' + alias: 'Kitchen: Coffee Maker Active Notification' + description: Sends a mobile application push notification when the coffee maker + power exceeds 1000W for 5 seconds. + triggers: + - entity_id: sensor.coffee_maker_power + above: 1000 + for: + hours: 0 + minutes: 0 + seconds: 5 + trigger: numeric_state + conditions: [] + actions: + - data: + title: ☕ Coffee Time + message: Someone is Making Coffee + action: notify.notify + - action: notify.persistent_notification + metadata: {} + data: + title: ☕ Coffee Time + message: Someone is Making Coffee + - delay: + hours: 0 + minutes: 4 + seconds: 0 + milliseconds: 0 + mode: single +- id: '1781024962776' + alias: 'Office: Light Control Motion' + description: Turns on office lights when the door opens, or via motion if turned + off by inactivity within the last hour. Turns off lights after 1 hour of no occupancy. + Prevents motion from re-triggering if turned off manually/Alexa. Respects a master + override toggle helper. + triggers: + - trigger: state + entity_id: binary_sensor.office_door_contact + to: 'on' + id: door_opened + - trigger: state + entity_id: binary_sensor.office_occupancy + to: 'on' + id: motion_detected + - trigger: state + entity_id: binary_sensor.office_occupancy + to: 'off' + for: + hours: 1 + minutes: 0 + seconds: 0 + id: turn_off_logic + conditions: [] + actions: + - choose: + - conditions: + - condition: time + after: '10:00:00' + before: '20:00:00' + - condition: state + entity_id: light.playroom_light + state: 'off' + - condition: state + entity_id: input_boolean.office_motion_override + state: 'off' + - condition: or + conditions: + - condition: trigger + id: door_opened + - condition: and + conditions: + - condition: trigger + id: motion_detected + - condition: template + value_template: '{{ (now() - states.light.playroom_light.last_changed).total_seconds() + < 3600 and states.light.playroom_light.context.parent_id == none }}' + sequence: + - action: light.turn_on + target: + entity_id: light.playroom_light + data: {} + - conditions: + - condition: trigger + id: turn_off_logic + - condition: state + entity_id: light.playroom_light + state: 'on' + sequence: + - action: light.turn_off + target: + entity_id: light.playroom_light + data: {} + - action: notify.notify + data: + message: Office Lights Turned Off due to inactivity + title: ⚡️ Office Lights + mode: restart +- id: '1781034724187' + alias: 'Office: Turn Off Lights on Exit' + description: Turns off office lights 2 minutes after the door closes, but only if + living room motion was detected after the door shut. + triggers: + - trigger: state + entity_id: binary_sensor.office_door_contact + to: 'off' + conditions: + - condition: state + entity_id: light.playroom_light + state: 'on' + - condition: template + value_template: '{{ states.binary_sensor.basement.last_changed > states.binary_sensor.office_door_contact.last_changed + }}' + actions: + - delay: + minutes: 2 + - action: light.turn_off + target: + entity_id: light.playroom_light + data: {} + - action: notify.notify + metadata: {} + data: + message: Office Lights Turned Off + title: ⚡️ Office Lights + mode: single +- id: '1781102033733' + alias: Water Leak Detected + description: '' + triggers: + - type: moist + device_id: 59bc102f385d7a3524c8f21a5c53592b + entity_id: c268ff8842e905515de2274d09c5ab9e + domain: binary_sensor + metadata: + secondary: false + trigger: device + conditions: [] + actions: + - action: notify.notify + metadata: {} + data: + title: "\U0001F6A8 URGENT: Water Leak Detected" + message: Water Leak Detected in Basement. Check Water Heater for Leaks. + - action: persistent_notification.create + metadata: {} + data: + title: "\U0001F6A8 URGENT: Water Leak Detected" + message: Water Leak Detected in Basement. Check Water Heater for Leaks. + mode: single +- id: '1781112110968' + alias: 'Freezer Safety: Alert if Plug Turns Off' + description: Triggers an immediate notification if the freezer smart plug is turned + off. + triggers: + - trigger: state + entity_id: switch.freezer + to: 'off' + for: + minutes: 1 + actions: + - action: notify.notify + data: + title: "\U0001F6A8 FREEZER PLUG TRIPPED!" + message: The freezer smart plug turned off. Check it immediately to prevent + food spoilage. + - action: notify.persistent_notification + metadata: {} + data: + title: "\U0001F6A8 FREEZER PLUG TRIPPED!" + message: The freezer smart plug turned off. Check it immediately to prevent + food spoilage. + mode: single +- id: '1781275767970' + alias: 'Dishwasher: Cycle Finished Notification' + description: Alerts when the dishwasher finishes washing + triggers: + - entity_id: + - sensor.150633095332665_progress + from: + - Dry + to: + - Complete + trigger: state + actions: + - action: notify.notify + data: + title: Dishwasher Done + message: The dishwasher cycle has completed successfully! + mode: single +- id: '1781275801564' + alias: 'Dishwasher: Rinse Aid Low Warning' + description: Alerts when rinse aid needs a refill + triggers: + - entity_id: binary_sensor.150633095332665_rinse_aid + to: 'on' + trigger: state + actions: + - action: notify.notify + data: + title: Dishwasher Maintenance + message: The dishwasher is low on rinse aid. Please refill it before the next + cycle! + mode: single +- id: '1781278655292' + alias: 'Dishwasher: Dishwasher Started Notification' + description: Alerts when the dishwasher starts washing + triggers: + - entity_id: + - sensor.150633095332665_status + to: + - Running + trigger: state + conditions: [] + actions: + - action: notify.notify + data: + title: Dishwasher Started + message: The dishwasher cycle has started! + mode: single +- id: '1781279642928' + alias: 'Dishwasher: Rinse Aid Refilled Notification' + description: Alerts when rinse aid has been refilled + triggers: + - entity_id: + - binary_sensor.150633095332665_rinse_aid + to: + - 'off' + trigger: state + from: + - 'on' + conditions: [] + actions: + - action: notify.notify + data: + title: Dishwasher Maintenance + message: The dishwasher rinse aid has been refilled! + mode: single +- id: '1781282506359' + alias: Dishwasher Cycle Tracker + description: '' + triggers: + - trigger: state + entity_id: + - sensor.150633095332665_progress + to: + - Complete + conditions: [] + actions: + - action: counter.increment + target: + entity_id: + - counter.dishwasher_wash_cycles + - counter.dishwasher_total_wash_cycles + data: {} + mode: single +- id: '1781283625261' + alias: 'Dishwasher: Create Persistent Filter Alert' + description: Pins a notification to the sidebar when the dishwasher hits 40 cycles. + triggers: + - entity_id: + - counter.dishwasher_wash_cycles + above: 30 + trigger: numeric_state + conditions: [] + actions: + - action: persistent_notification.create + data: + title: Dishwasher Filter Maintenance + message: The dishwasher has completed 30 cycles. Please clean the physical mesh + filter and press Reset on your dashboard. + notification_id: dishwasher_filter_reminder + - action: notify.notify + metadata: {} + data: + title: Dishwasher Filter Maintenance + message: The dishwasher has completed 30 cycles. Please clean the physical mesh + filter and press Reset on your dashboard. + mode: single +- id: '1781283661169' + alias: 'Dishwasher: Clear Persistent Filter Alert' + description: Automatically removes the sidebar notification when the counter is + reset. + triggers: + - entity_id: counter.dishwasher_wash_cycles + below: 10 + trigger: numeric_state + conditions: [] + actions: + - action: persistent_notification.dismiss + data: + notification_id: dishwasher_filter_reminder + mode: single +- id: '1781285326960' + alias: 'Tablet: Restore Screen in Morning v2' + description: Wakes the tablet and restores brightness/timeout + triggers: + - at: 07:00:00 + trigger: time + actions: + - action: notify.mobile_app_sm_t387w + data: + message: command_screen_on + data: + command: turn_on + - action: notify.mobile_app_sm_t387w + data: + message: command_screen_off_timeout + data: + command: 120000 + - action: notify.mobile_app_sm_t387w + data: + message: command_screen_brightness_level + data: + command: 180 +- id: '1781320273276' + alias: 'Tablet: Fix Greyed Out Brightness' + description: Disables auto-brightness and restores manual slider control + triggers: + - at: 07:00:00 + trigger: time + actions: + - action: notify.mobile_app_sm_t387w + data: + message: command_auto_screen_brightness + data: + command: turn_off + - action: notify.mobile_app_sm_t387w + data: + message: command_screen_brightness_level + data: + command: 180 +- id: '1781392657515' + alias: 'Washing Machine: Cycle Status' + description: Triggers notifications when the washing machine starts and finishes + its cycle based on power consumption with debouncing. + triggers: + - entity_id: sensor.washer_power + above: 10 + id: washer_started + trigger: numeric_state + - entity_id: sensor.washer_power + below: 3 + for: + hours: 0 + minutes: 4 + seconds: 0 + id: washer_finished + trigger: numeric_state + conditions: [] + actions: + - choose: + - conditions: + - condition: trigger + id: washer_started + - condition: state + entity_id: input_boolean.washer_running + state: 'off' + sequence: + - action: input_boolean.turn_on + target: + entity_id: input_boolean.washer_running + - action: notify.notify + data: + title: Washer Started + message: The washing machine has started its cycle. + - conditions: + - condition: trigger + id: washer_finished + - condition: state + entity_id: input_boolean.washer_running + state: 'on' + sequence: + - action: input_boolean.turn_off + target: + entity_id: input_boolean.washer_running + - action: notify.notify + data: + title: Washer Finished + message: The washing machine has completed its cycle. Time to empty it! +- id: '1781402128095' + alias: Daily Morning Climate Report + description: Triggers the weather and comfort report script every morning at 7:00 + AM + triggers: + - at: 07:00:00 + trigger: time + conditions: [] + actions: + - action: script.weather_and_comfort_report + mode: single +- id: '1781403940897' + alias: 'Microwave: Cycle Status' + description: Triggers notifications when the microwave starts and finishes based + on power consumption. + triggers: + - entity_id: + - sensor.microwave_power + above: 100 + id: microwave_started + trigger: numeric_state + - entity_id: sensor.microwave_power + below: 3 + for: + hours: 0 + minutes: 1 + seconds: 0 + id: microwave_finished + trigger: numeric_state + conditions: [] + actions: + - choose: + - conditions: + - condition: trigger + id: microwave_started + - condition: state + entity_id: input_boolean.microwave_running + state: 'off' + sequence: + - action: input_boolean.turn_on + target: + entity_id: input_boolean.microwave_running + - action: notify.notify + data: + title: Microwave Started + message: The microwave is running. + - conditions: + - condition: trigger + id: microwave_finished + - condition: state + entity_id: input_boolean.microwave_running + state: 'on' + sequence: + - action: input_boolean.turn_off + target: + entity_id: input_boolean.microwave_running + - action: notify.notify + data: + title: Microwave Finished + message: The microwave has finished! +- id: '1781404011664' + alias: 'Ninja Toaster Oven: Cycle Status' + description: Triggers notifications when the Ninja toaster oven starts and finishes + cooking, with a buffer for element heat cycling. + triggers: + - entity_id: sensor.ninja_power + above: 10 + id: ninja_started + trigger: numeric_state + - entity_id: sensor.ninja_power + below: 3 + for: + hours: 0 + minutes: 3 + seconds: 0 + id: ninja_finished + trigger: numeric_state + conditions: [] + actions: + - choose: + - conditions: + - condition: trigger + id: ninja_started + - condition: state + entity_id: input_boolean.ninja_running + state: 'off' + sequence: + - action: input_boolean.turn_on + target: + entity_id: input_boolean.ninja_running + - action: notify.notify + data: + title: Ninja Ovens Started + message: The Ninja toaster oven is heating up. + - conditions: + - condition: trigger + id: ninja_finished + - condition: state + entity_id: input_boolean.ninja_running + state: 'on' + sequence: + - action: input_boolean.turn_off + target: + entity_id: input_boolean.ninja_running + - action: notify.notify + data: + title: Ninja Oven Finished + message: The Ninja toaster oven has finished cooking! +- id: '1781445544080' + alias: 'Kitchen: Coffee Maker Cycle Status' + description: 'Triggers on the high-wattage grinder spike. Dynamically waits for + the cycle to finish: it requires a minimum of 1 minute to pass AND the power to + drop back to the TV baseline.' + triggers: + - trigger: numeric_state + entity_id: sensor.coffee_maker_power + above: 800 + id: coffee_started + - trigger: numeric_state + entity_id: sensor.coffee_maker_power + below: 15 + for: + hours: 0 + minutes: 1 + seconds: 0 + id: coffee_finished + conditions: [] + actions: + - choose: + - conditions: + - condition: trigger + id: coffee_started + - condition: state + entity_id: input_boolean.coffee_maker_running + state: 'off' + sequence: + - action: input_boolean.turn_on + target: + entity_id: input_boolean.coffee_maker_running + - action: notify.notify + data: + title: ☕ Coffee Time + message: Someone is making coffee. + - conditions: + - condition: trigger + id: coffee_finished + - condition: state + entity_id: input_boolean.coffee_maker_running + state: 'on' + sequence: + - action: input_boolean.turn_off + target: + entity_id: input_boolean.coffee_maker_running + - action: notify.notify + data: + title: ☕ Coffee Ready + message: Your coffee is ready to enjoy! + mode: single +- id: '1781793117883' + alias: Basement Temperature Change + description: '' + triggers: + - type: temperature + device_id: 3e67afd88a4b4d17552c8f370aaf40b5 + entity_id: 64689573a8b11a83829efcae0022d7c1 + domain: sensor + trigger: device + above: 15 + below: 10 + conditions: [] + actions: + - action: notify.notify + metadata: {} + data: + title: Basement Temperature Change + message: Basement Temperature Changed! + mode: single +- id: '1782161891986' + alias: 'Notification: Pool Sensor Low Battery' + description: Alert when the pool temperature sensor battery drops below 15% + triggers: + - entity_id: + - sensor.pool_battery + below: 15 + trigger: numeric_state + conditions: [] + actions: + - data: + title: Pool Sensor Battery Low + message: The pool temperature sensor battery is currently at {{ states('sensor.pool_temperature_battery') + }}%. Please change it soon. + action: notify.persistent_notification + - action: notify.notify + metadata: {} + data: + title: Pool Sensor Battery Low + message: The pool temperature sensor battery is currently at {{ states('sensor.pool_temperature_battery') + }}%. Please change it soon. + mode: single +- &id001 + id: commute_morning_traffic_alert_map + alias: 'Commute: Morning Traffic Alert with Map' + description: Checks commute times at 7:15 AM and attaches a map if traffic is bad. + triggers: + - at: 07:15:00 + trigger: time + conditions: + - condition: or + conditions: + - condition: template + value_template: '{{ states(''sensor.google_travel_time_work'') | float(0) > + 35 }}' + actions: + - action: camera.snapshot + target: + entity_id: camera.camera_commute_map + data: + filename: /config/www/commute_snapshot.jpg + - action: notify.notify + data: + title: "\U0001F6A8 Commute Delay Alert!" + message: "Traffic to Work is heavier than usual this morning. \n- Google Maps: + {{ states('sensor.google_travel_time_work') }} mins." + data: + image: /local/commute_snapshot.jpg + enabled: false + - action: notify.persistent_notification + data: + title: "\U0001F6A8 Commute Delay Alert!" + message: "Traffic to Work is heavier than usual this morning. \n- Google Maps: + {{ states('sensor.google_travel_time_work') }} mins. \n![Commute Map](/local/commute_snapshot.jpg)" + mode: single +- *id001 +- *id001 +- id: '1782999027626' + alias: 'Commute: Morning Traffic Alert' + description: Pushes an interactive, live traffic map to my phone before the commute. + triggers: + - at: 07:15:00 + trigger: time + actions: + - action: notify.notify + data: + title: Morning Commute Traffic + message: Swipe down to view live traffic along your route. + data: + push: + category: map + action_data: + latitude: '45.474668' + longitude: '-73.737158' + shows_traffic: true + shows_scale: true +- id: '1783000087983' + alias: Morning Commute Alert + description: Notify when commute is longer than 35 minutes. + triggers: + - trigger: time + at: 07:15:00 + conditions: + - condition: time + weekday: + - mon + - tue + - wed + - thu + - fri + - condition: or + conditions: + - condition: numeric_state + entity_id: sensor.google_travel_time_work + above: 35 + actions: + - action: notify.notify + data: + title: "\U0001F697 Morning Commute" + message: "Google: {{ states('sensor.google_travel_time_work') }} min\n\n{% set + g = states('sensor.google_travel_time_work') | float(0) %} {% if g > 50 %} + \U0001F6A8 Major traffic delays. {% elif g > 40 %} ⚠️ Heavy traffic. {% else + %} \U0001F7E1 Moderate traffic. {% endif %}" + mode: single +- id: '1783162325964' + alias: Motion Shed - AI Description + description: Takes a snapshot when the Nest camera detects motion, a person, an + animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent + notification. + triggers: + - trigger: state + entity_id: input_boolean.blink_shed_camera_motion + to: 'on' + enabled: true + - trigger: event + event_type: state_changed + event_data: + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: state + entity_id: event.living_room_living_room_camera_motion + enabled: false + - trigger: state + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: event.received + target: + entity_id: event.entryway_entryway_camera_motion + options: + event_type: + - camera_motion + - camera_person + - camera_sound + enabled: false + - trigger: event.received + target: + device_id: 290f56e5a655e3af82cbf3cf9fd22406 + options: + event_type: + - camera_motion + - camera_person + - camera_sound + - ring + enabled: false + - trigger: event.received + target: + device_id: 679d52e0d16cf48648d11dd024b961f8 + options: + event_type: + - camera_motion + - camera_sound + - camera_person + enabled: false + conditions: + - condition: template + value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', + ''person'', ''animal'', ''chime''] }}' + enabled: false + actions: + - action: camera.record + target: + entity_id: + - camera.blink_backyard_shed + data: + duration: 10 + lookback: 0 + filename: /media/blinkshed.mp4 + enabled: false + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + enabled: false + - action: homeassistant.update_entity + target: + entity_id: camera.blink_backyard_shed + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - variables: + snapshot_filename: blink_shed_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg + - variables: + notification_id: blink_shed_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} + - action: camera.snapshot + target: + entity_id: camera.blink_backyard_shed + data: + filename: /media/{{ snapshot_filename }} + - action: camera.snapshot + target: + entity_id: camera.blink_backyard_shed + data: + filename: /config/www/{{ snapshot_filename }} + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - action: ai_task.generate_data + continue_on_error: true + data: + task_name: Blink Shed Camera Analysis + entity_id: ai_task.google_ai_task + instructions: Describe what you see in this image in brief, but make it a little + humorous. Focus on any people, objects, animals or activities. Text needs + to be max 240 characters. + attachments: + - media_content_id: media-source://media_source/local/{{ snapshot_filename }} + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + continue_on_error: true + data: + title: Motion Detected - Backyard Shed + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: persistent_notification.create + data: + title: Motion Detected - Backyard Shed + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/{{ snapshot_filename }})' + notification_id: '{{ notification_id }}' + mode: queued + max: 10 +- id: '1783293479398' + alias: Blink Camera Fast Update Cycle + description: Polls the Blink cloud every 30 seconds to bypass the default 5-minute + delay and force instant motion detection state updates. + triggers: + - trigger: time_pattern + seconds: /30 + actions: + - action: homeassistant.update_entity + target: + entity_id: + - binary_sensor.backyard_shed_motion + - binary_sensor.front_door_motion + mode: single +- id: '1783452320698' + alias: Nest Test Motion + description: Takes a snapshot when the Blink camera detects motion, analyzes it + with AI, and sends a mobile and persistent notification. + triggers: + - trigger: state + entity_id: binary_sensor.backyard_tree_motion + to: 'on' + conditions: [] + actions: + - delay: 00:00:02 + - action: camera.snapshot + target: + entity_id: + - camera.front + - camera.backyard_shed + data: + filename: /media/nest_Living2.jpg + - action: camera.snapshot + target: + entity_id: camera.backyard_tree + data: + filename: /config/www/blinkTree.jpg + - delay: 00:00:02 + - action: ai_task.generate_data + continue_on_error: true + data: + task_name: Backyard Shed Camera Analysis + entity_id: ai_task.google_ai_task + instructions: Describe what you see in this image in brief. Focus on any people, + objects, or activities. Text needs to be max 225 characters. + attachments: + - media_content_id: media-source://media_source/local/blinkTree.jpg + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + data: + title: Motion Detected - Backyard Tree + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: persistent_notification.create + data: + title: Motion Detected - Backyard Tree + notification_id: backyard_tree_motion + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/blinkTree.jpg)' + mode: queued + max: 10 +- id: '1783540959739' + alias: Motion Front Door - AI Description + description: Takes a snapshot when the Nest camera detects motion, a person, an + animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent + notification. + triggers: + - trigger: event + event_type: state_changed + event_data: + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: state + entity_id: event.living_room_living_room_camera_motion + enabled: false + - trigger: state + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: event.received + target: + entity_id: event.entryway_entryway_camera_motion + options: + event_type: + - camera_motion + - camera_person + - camera_sound + enabled: false + - trigger: event.received + target: + device_id: 290f56e5a655e3af82cbf3cf9fd22406 + options: + event_type: + - camera_motion + - camera_person + - camera_sound + - ring + - trigger: event.received + target: + device_id: 679d52e0d16cf48648d11dd024b961f8 + options: + event_type: + - camera_motion + - camera_sound + - camera_person + enabled: false + - trigger: state + entity_id: + - input_boolean.blink_front_camera_motion + to: + - 'on' + enabled: true + conditions: + - condition: template + value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', + ''person'', ''animal'', ''chime''] }}' + enabled: false + actions: + - action: blink.trigger_camera + target: + entity_id: camera.blink_front + data: {} + enabled: false + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + enabled: false + - action: homeassistant.update_entity + target: + entity_id: camera.blink_front + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - variables: + snapshot_filename: blink_Front_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg + - variables: + notification_id: blink_front_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} + - action: camera.snapshot + target: + entity_id: camera.blink_front + data: + filename: /media/{{ snapshot_filename }} + - action: camera.snapshot + target: + entity_id: camera.blink_front + data: + filename: /config/www/{{ snapshot_filename }} + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - action: ai_task.generate_data + continue_on_error: true + data: + task_name: Nest Front Camera Analysis + entity_id: ai_task.google_ai_task + instructions: Describe what you see in this image in brief, but make it a little + humorous. Focus on any people, objects, animals or activities. Text needs + to be max 240 characters. + attachments: + - media_content_id: media-source://media_source/local/{{ snapshot_filename }} + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + continue_on_error: true + data: + title: Motion Detected - Front Door + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: notify.persistent_notification + data: + title: Motion Detected - Front Door + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/{{ snapshot_filename }})' + data: + notification_id: nest_front_motion + enabled: false + - action: persistent_notification.create + metadata: {} + data: + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} ![Snapshot](/local/{{ snapshot_filename }})' + title: Motion Detected - Front Door + notification_id: '{{ notification_id }}' + mode: queued + max: 10 +- id: '1783615071224' + alias: 'TEST: AI Camera Motion Detection OLLAMA' + description: Takes a snapshot when the Nest camera detects motion, a person, an + animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent + notification. + triggers: + - trigger: event + event_type: state_changed + event_data: + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: state + entity_id: event.living_room_living_room_camera_motion + enabled: false + - trigger: state + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: event.received + target: + entity_id: event.entryway_entryway_camera_motion + options: + event_type: + - camera_motion + - camera_person + - camera_sound + enabled: false + - trigger: event.received + target: + device_id: 290f56e5a655e3af82cbf3cf9fd22406 + options: + event_type: + - camera_motion + - camera_person + - camera_sound + - ring + - trigger: event.received + target: + device_id: 679d52e0d16cf48648d11dd024b961f8 + options: + event_type: + - camera_motion + - camera_sound + - camera_person + enabled: false + conditions: + - condition: template + value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', + ''person'', ''animal'', ''chime''] }}' + enabled: false + actions: + - action: blink.trigger_camera + target: + entity_id: camera.front + data: {} + enabled: false + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + enabled: false + - action: homeassistant.update_entity + target: + entity_id: camera.front + - variables: + snapshot_filename: blink_Front_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg + - variables: + notification_id: blink_front_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} + - action: camera.snapshot + target: + entity_id: camera.blink_front + data: + filename: /media/{{ snapshot_filename }} + - action: camera.snapshot + target: + entity_id: camera.blink_front + data: + filename: /config/www/{{ snapshot_filename }} + - delay: + hours: 0 + minutes: 0 + seconds: 4 + milliseconds: 0 + - action: ai_task.generate_data + continue_on_error: true + data: + task_name: Nest Front Camera Analysis + entity_id: ai_task.ollama_ai_task_2 + instructions: Describe what you see in this image in brief. Focus on any people, + objects, animals or activities. Text needs to be max 240 characters. + attachments: + - media_content_id: media-source://media_source/local/{{ snapshot_filename }} + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + continue_on_error: true + data: + title: Motion Detected - Front Door + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: notify.persistent_notification + data: + title: Motion Detected - Front Door + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/{{ snapshot_filename }})' + data: + notification_id: nest_front_motion + enabled: false + - action: persistent_notification.create + metadata: {} + data: + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} ![Snapshot](/local/{{ snapshot_filename }})' + title: Motion Detected - Front Door + notification_id: '{{ notification_id }}' + mode: queued + max: 10 +- id: '1783628026617' + alias: 'Garage Door: Open Notification' + description: '' + triggers: + - trigger: state + entity_id: + - binary_sensor.garage_door_contact + to: + - 'on' + conditions: [] + actions: + - action: notify.notify + metadata: {} + data: + message: 'Garage Door is Opened ' + title: Garage Door + mode: single +- id: '1783628088925' + alias: 'Garage Door: Closed Notification' + description: '' + triggers: + - trigger: state + entity_id: + - binary_sensor.garage_door_contact + to: + - 'off' + from: + - 'on' + conditions: [] + actions: + - action: notify.notify + metadata: {} + data: + message: Garage Door Closed + title: Garage Door + mode: single +- id: '1783643893067' + alias: Blink Shed Camera Motion Triggered + description: Triggers local actions when the front Blink camera senses motion + triggers: + - entity_id: input_boolean.blink_shed_camera_motion + to: 'on' + trigger: state + conditions: [] + actions: + - data: + title: Shed Door Alert + message: Motion detected on the Shed Blink camera! + action: notify.persistent_notification + mode: single +- id: '1783647055563' + alias: Motion Backyard - AI Description + description: Takes a snapshot when the Nest camera detects motion, a person, an + animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent + notification. + triggers: + - trigger: state + entity_id: + - input_boolean.blink_backyard_camera_motion + to: + - 'on' + enabled: true + - trigger: event + event_type: state_changed + event_data: + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: state + entity_id: event.living_room_living_room_camera_motion + enabled: false + - trigger: state + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: event.received + target: + entity_id: event.entryway_entryway_camera_motion + options: + event_type: + - camera_motion + - camera_person + - camera_sound + enabled: false + - trigger: event.received + target: + device_id: 290f56e5a655e3af82cbf3cf9fd22406 + options: + event_type: + - camera_motion + - camera_person + - camera_sound + - ring + enabled: false + - trigger: event.received + target: + device_id: 679d52e0d16cf48648d11dd024b961f8 + options: + event_type: + - camera_motion + - camera_sound + - camera_person + enabled: false + conditions: + - condition: template + value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', + ''person'', ''animal'', ''chime''] }}' + enabled: false + actions: + - action: blink.trigger_camera + target: + entity_id: camera.blink_backyard + data: {} + enabled: false + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + enabled: false + - action: homeassistant.update_entity + target: + entity_id: camera.blink_backyard + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - variables: + snapshot_filename: blink_backyard_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg + - variables: + notification_id: blink_backyard_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} + - action: camera.snapshot + target: + entity_id: camera.blink_backyard + data: + filename: /media/{{ snapshot_filename }} + - action: camera.snapshot + target: + entity_id: camera.blink_backyard + data: + filename: /config/www/{{ snapshot_filename }} + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - action: ai_task.generate_data + continue_on_error: true + data: + task_name: Blink Backyard Camera Analysis + entity_id: ai_task.google_ai_task + instructions: Describe what you see in this image in brief, but make it a little + humorous. Focus on any people, objects, animals or activities. Text needs + to be max 240 characters. + attachments: + - media_content_id: media-source://media_source/local/{{ snapshot_filename }} + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + continue_on_error: true + data: + title: Motion Detected - Backyard + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: persistent_notification.create + data: + title: Motion Detected - Backyard + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/{{ snapshot_filename }})' + notification_id: '{{ notification_id }}' + mode: queued + max: 10 +- id: '1783649304797' + alias: Motion Inside Shed - AI Description + description: Takes a snapshot when the Nest camera detects motion, a person, an + animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent + notification. + triggers: + - trigger: state + entity_id: + - input_boolean.blink_inside_shed_camera_motion + to: + - 'on' + enabled: true + - trigger: event + event_type: state_changed + event_data: + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: state + entity_id: event.living_room_living_room_camera_motion + enabled: false + - trigger: state + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: event.received + target: + entity_id: event.entryway_entryway_camera_motion + options: + event_type: + - camera_motion + - camera_person + - camera_sound + enabled: false + - trigger: event.received + target: + device_id: 290f56e5a655e3af82cbf3cf9fd22406 + options: + event_type: + - camera_motion + - camera_person + - camera_sound + - ring + enabled: false + - trigger: event.received + target: + device_id: 679d52e0d16cf48648d11dd024b961f8 + options: + event_type: + - camera_motion + - camera_sound + - camera_person + enabled: false + conditions: + - condition: template + value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', + ''person'', ''animal'', ''chime''] }}' + enabled: false + actions: + - action: blink.trigger_camera + target: + entity_id: camera.blink_backyard_tree + data: {} + enabled: false + - delay: + hours: 0 + minutes: 0 + seconds: 10 + milliseconds: 0 + enabled: false + - action: homeassistant.update_entity + target: + entity_id: camera.blink_backyard_tree + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - variables: + snapshot_filename: blink_Inshed_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg + - variables: + notification_id: blink_Inshed_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} + - action: camera.snapshot + target: + entity_id: camera.blink_backyard_tree + data: + filename: /media/{{ snapshot_filename }} + - action: camera.snapshot + target: + entity_id: camera.blink_backyard_tree + data: + filename: /config/www/{{ snapshot_filename }} + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - action: ai_task.generate_data + continue_on_error: true + data: + task_name: Blink InShed Camera Analysis + entity_id: ai_task.google_ai_task + instructions: Describe what you see in this image in brief, but make it a little + humorous. Focus on any people, objects, animals or activities. Text needs + to be max 240 characters. + attachments: + - media_content_id: media-source://media_source/local/{{ snapshot_filename }} + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + continue_on_error: true + data: + title: Motion Detected - Backyard Inside Shed + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: persistent_notification.create + data: + title: Motion Detected - Backyard Inside Shed + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/{{ snapshot_filename }})' + notification_id: '{{ notification_id }}' + mode: queued + max: 10 +- id: '1783736251030' + alias: 'Office: Teams Webhook Busy Light' + description: Triggers light based on Windows 11 Teams log file status changes + triggers: + - webhook_id: office_teams_meeting_status_webhook_secret_99 + allowed_methods: + - POST + local_only: false + trigger: webhook + conditions: [] + actions: + - choose: + - conditions: + - condition: template + value_template: '{{ trigger.json.status in [''busy'', ''do not disturb''] + }}' + sequence: + - target: + entity_id: light.thirdreality_night_light + data: + brightness_pct: 100 + rgb_color: + - 255 + - 0 + - 0 + action: light.turn_on + - conditions: + - condition: template + value_template: '{{ trigger.json.status in [''available'', ''offline'', ''away''] + }}' + sequence: + - target: + entity_id: light.thirdreality_night_light + data: + brightness_pct: 20 + rgb_color: + - 255 + - 255 + - 255 + action: light.turn_on + mode: single +- id: '1783737753660' + alias: 'Office: Manual Meeting Status Busy Light - Working Hours' + description: 'Work hours: Toggle controls Red/Green (Motion ignored). After hours + OR Away Mode: Pure motion night light (Toggle ignored).' + triggers: + - id: status_changed + entity_id: input_boolean.in_a_meeting + trigger: state + - id: motion_detected + entity_id: binary_sensor.night_light_occupancy + trigger: state + to: 'on' + - id: motion_cleared + entity_id: binary_sensor.night_light_occupancy + trigger: state + to: 'off' + conditions: [] + actions: + - choose: + - conditions: + - condition: trigger + id: status_changed + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'on' + - condition: state + entity_id: input_boolean.away_mode + state: 'off' + - condition: time + after: 08:00:00 + before: '18:00:00' + weekday: + - mon + - tue + - wed + - thu + - fri + sequence: + - target: + entity_id: light.night_light + data: + brightness_pct: 100 + rgb_color: + - 255 + - 0 + - 0 + action: light.turn_on + - conditions: + - condition: trigger + id: status_changed + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'off' + - condition: state + entity_id: input_boolean.away_mode + state: 'off' + - condition: time + after: 08:00:00 + before: '18:00:00' + weekday: + - mon + - tue + - wed + - thu + - fri + sequence: + - target: + entity_id: light.night_light + data: + brightness_pct: 20 + rgb_color: + - 5 + - 245 + - 45 + action: light.turn_on + - conditions: + - condition: trigger + id: motion_detected + - condition: or + conditions: + - condition: state + entity_id: input_boolean.away_mode + state: 'on' + - condition: not + conditions: + - condition: time + after: 08:00:00 + before: '18:00:00' + weekday: + - mon + - tue + - wed + - thu + - fri + sequence: + - target: + entity_id: light.night_light + data: + brightness_pct: 30 + rgb_color: + - 255 + - 255 + - 255 + action: light.turn_on + - conditions: + - condition: trigger + id: motion_cleared + - condition: or + conditions: + - condition: state + entity_id: input_boolean.away_mode + state: 'on' + - condition: not + conditions: + - condition: time + after: 08:00:00 + before: '18:00:00' + weekday: + - mon + - tue + - wed + - thu + - fri + sequence: + - target: + entity_id: light.night_light + action: light.turn_off + mode: restart +- id: '1783738120074' + alias: 'Office: Webhook Toggle Meeting Status' + description: Toggles the meeting status boolean when the secret webhook URL is opened + triggers: + - webhook_id: toggle_my_meeting_status_secret_abc123 + allowed_methods: + - POST + - GET + local_only: false + trigger: webhook + conditions: [] + actions: + - target: + entity_id: input_boolean.in_a_meeting + action: input_boolean.toggle + mode: single +- id: '1783806626578' + alias: 'Office: TEST Meeting Status Lights' + description: 'DEDICATED TESTING: Forces Red/Green colors anytime the switch is toggled. + Disable production automation before using.' + triggers: + - entity_id: input_boolean.in_a_meeting + trigger: state + conditions: [] + actions: + - choose: + - conditions: + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'on' + sequence: + - target: + entity_id: light.night_light + data: + brightness_pct: 100 + rgb_color: + - 255 + - 0 + - 0 + action: light.turn_on + - conditions: + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'off' + sequence: + - target: + entity_id: light.night_light + data: + brightness_pct: 20 + rgb_color: + - 5 + - 245 + - 45 + action: light.turn_on + mode: single +- id: '1783824363227' + alias: Downstairs Bathroom Light Control + description: Turn on bathroom switch when occupied, turn off after 30 minutes of + no occupancy. + triggers: + - entity_id: binary_sensor.downstairs_bathroom_occupancy + to: 'on' + id: motion_detected + trigger: state + - entity_id: binary_sensor.downstairs_bathroom_occupancy + to: 'off' + for: + hours: 0 + minutes: 30 + seconds: 0 + id: motion_clear + trigger: state + conditions: [] + actions: + - choose: + - conditions: + - condition: trigger + id: motion_detected + sequence: + - target: + entity_id: switch.downstairs_bathroom_switch + action: switch.turn_on + - conditions: + - condition: trigger + id: motion_clear + sequence: + - target: + entity_id: switch.downstairs_bathroom_switch + action: switch.turn_off + mode: restart +- id: '1783896963375' + alias: 'Office: Smart Button Toggles Meeting Status - IKEA BILRESA' + description: Toggles the meeting input boolean when the smart button is pressed + triggers: + - trigger: event + event_type: state_changed + event_data: + entity_id: event.office_button_button_1 + conditions: [] + actions: + - target: + entity_id: input_boolean.in_a_meeting + action: input_boolean.toggle + mode: single +- id: '1783901121760' + alias: Ikea BILRESA scroll wheel - Bedroom Switch + description: '' + use_blueprint: + path: BoGu5/kea BILRESA scroll wheel (sensors for scroll, events for press).yaml + input: + remote: c7289314a586fc3739316abfeee0cdf5 + click_action_ch1: + - action: light.toggle + metadata: {} + target: + entity_id: light.bedroom_lights + data: {} + scroll_wheel_target_ch1: + - light.bedroom_lights + scroll_wheel_mode_ch1: 'lights: color temp only' +- id: '1783904189553' + alias: IKEA Bilresa Scrollwheel - Bedroom Light Control V1 - Atomic + description: '' + use_blueprint: + path: stone13/ikea_bilresa_atomic_v1.yaml + input: + light_entity: light.bedroom_lights + sensor_1: sensor.bilresa_scroll_wheel_current_switch_position_1 + sensor_2: sensor.bilresa_scroll_wheel_current_switch_position_2 + sensor_4: sensor.bilresa_scroll_wheel_current_switch_position_4 + sensor_5: sensor.bilresa_scroll_wheel_current_switch_position_5 + sensor_7: sensor.bilresa_scroll_wheel_current_switch_position_7 + sensor_8: sensor.bilresa_scroll_wheel_current_switch_position_8 + event_taste_1: event.bedroom_switch_button_3 + event_taste_2: event.bedroom_switch_button_6 + event_taste_3: event.bedroom_switch_button_9 + scroll_logic_4_5: brightness + scroll_logic_7_8: brightness +- id: '1783905541495' + alias: Ikea Bilresa Switch Bedroom Light Control + description: '' + use_blueprint: + path: jhol-byte/Ikea_bilresa_scroll_wheel.yaml + input: + remote: c7289314a586fc3739316abfeee0cdf5 + click_action_ch1: + - action: light.toggle + metadata: {} + target: + entity_id: light.bedroom_lights + data: {} + scroll_wheel_target_ch1: + - light.bedroom_lights +- id: '1783906561328' + alias: IKEA BILRESA - Light Controller for PLAYROOM TEST2 + description: '' + use_blueprint: + path: SvKRO/ha-blueprint-ikea-bilresa-scrollwheel_en.yaml + input: + remote_device: 353fb9eb9f1b2a138e468b02fb9fbf06 + input_click_1: + - action: light.toggle + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: {} + input_light_1: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + input_mode_1: brightness + input_long_1: [] + input_double_1: [] + input_sens_1: 15 + input_transition_1: 34 + input_light_2: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + input_sens_2: 15 + input_light_3: + entity_id: light.bedroom_lights + input_sens_3: 10 + input_transition_2: 0 +- id: '1783909375854' + alias: 'Office: Playroom Light Toggle - IKEA BILRESA' + description: Toggles the meeting input boolean when the smart button is pressed + triggers: + - trigger: event + event_type: state_changed + event_data: + entity_id: event.office_button_button_2 + conditions: [] + actions: + - action: light.toggle + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: {} + mode: single +- id: '1783915438772' + alias: IKEA Bilresa Scrollwheel TEST FRANCO - Light Control + description: '' + use_blueprint: + path: thetestspecimen/ikea-bilresa-scroll-wheel.yaml + input: + event_button_1: event.bilresa_switch_button_3 + event_button_2: event.bilresa_switch_button_6 + event_button_3: event.bilresa_switch_button_9 + sensor_1: sensor.bilresa_scroll_wheel_current_switch_position_1_2 + sensor_2: sensor.bilresa_scroll_wheel_current_switch_position_2_2 + sensor_4: sensor.bilresa_scroll_wheel_current_switch_position_4_2 + sensor_5: sensor.bilresa_scroll_wheel_current_switch_position_5_2 + sensor_7: sensor.bilresa_scroll_wheel_current_switch_position_7_2 + sensor_8: sensor.bilresa_scroll_wheel_current_switch_position_8_2 + global_light: + - light.living_room_living_room_home_living_room +- id: '1783960311392' + alias: Ikea_bilresa_scroll_wheel TEST + description: '' + use_blueprint: + path: thetestspecimen/ikea-bilresa-scroll-wheel.yaml + input: + event_button_1: event.bilresa_scroll_wheel_button_3 + event_button_2: event.bedroom_switch_button_6 + event_button_3: event.bilresa_scroll_wheel_button_9 + sensor_1: sensor.bilresa_scroll_wheel_current_switch_position_1 + sensor_2: sensor.bilresa_scroll_wheel_current_switch_position_2 + sensor_4: sensor.bilresa_scroll_wheel_current_switch_position_4 + sensor_5: sensor.bilresa_scroll_wheel_current_switch_position_5 + sensor_7: sensor.bilresa_scroll_wheel_current_switch_position_7 + sensor_8: sensor.bilresa_scroll_wheel_current_switch_position_8 + global_light: + - light.playroom_light + light_z1: [] +- id: '1783960650360' + alias: 'Bedroom: Ikea Bilresa Scrollwheel - Light Control' + description: '' + use_blueprint: + path: jhol-byte/Ikea_bilresa_scroll_wheel.yaml + input: + remote: c7289314a586fc3739316abfeee0cdf5 + click_action_ch1: + - action: light.toggle + metadata: {} + target: + entity_id: light.bedroom_lights + data: {} + scroll_wheel_target_ch1: + - light.bedroom_lights + scroll_wheel_mode_ext_ch1: instant + long_click_action_ch1: + - action: light.turn_on + metadata: {} + target: + entity_id: light.bedroom_lights + data: + brightness_pct: 80 + double_click_action_ch1: + - action: light.toggle + metadata: {} + target: + entity_id: light.bedroom_lights + data: + brightness_pct: 25 +- id: '1783962246983' + alias: 'OFFICE: IKEA BILRESA E2489 Dual Button (Matter) - In Meeting and Away Mode' + description: Ikea BILRESA E2489 Dual Button controls in Meeting toggle, and enable + Away Mode + use_blueprint: + path: censay/ikea-bilresa-e2489-matter-smart-button.yaml + input: + target_device: 0c00a80e3106a9d7089f44c4781e0187 + button1_event: event.office_button_button_1 + button2_event: event.office_button_button_2 + button1_single: + - action: input_boolean.toggle + metadata: {} + target: + entity_id: input_boolean.in_a_meeting + data: {} + button2_single: + - action: input_boolean.toggle + metadata: {} + target: + entity_id: input_boolean.away_mode + data: {} + button2_double: [] + button1_long_press: [] + button2_long_press: [] +- id: '1783972932942' + alias: 'Notification: Office Meeting Status' + description: Sends a notification when entering or leaving a meeting + triggers: + - entity_id: input_boolean.in_a_meeting + to: 'on' + id: meeting_started + trigger: state + - entity_id: input_boolean.in_a_meeting + to: 'off' + id: meeting_ended + trigger: state + conditions: [] + actions: + - choose: + - alias: Meeting Started Notification + conditions: + - condition: trigger + id: meeting_started + sequence: + - action: notify.notify + data: + title: Meeting Active + message: "Your status is now set to: \U0001F534 DO NOT ENTER" + - alias: Meeting Ended Notification + conditions: + - condition: trigger + id: meeting_ended + sequence: + - action: notify.notify + data: + title: Meeting Ended + message: "Your status is now set to: \U0001F7E2 AVAILABLE" +- id: '1784037118280' + alias: 'Blink Safeguard: Auto Reset Stuck Toggles' + description: Forces stuck Blink helper toggles to turn off after 2 minutes and logs + the event + triggers: + - trigger: state + entity_id: + - input_boolean.blink_backyard_camera_motion + - input_boolean.blink_front_camera_motion + - input_boolean.blink_inside_shed_camera_motion + - input_boolean.blink_shed_camera_motion + to: 'on' + for: + minutes: 2 + conditions: [] + actions: + - action: logbook.log + data: + name: Blink Safeguard + message: Alexa failed to clear motion. Forcing auto-reset. + entity_id: '{{ trigger.entity_id }}' + - action: input_boolean.turn_off + target: + entity_id: '{{ trigger.entity_id }}' + mode: parallel +- id: '1784070582456' + alias: 'Office: Lamp and lights control with IKEA Scroll Wheel' + description: '' + use_blueprint: + path: jhol-byte/Ikea_bilresa_scroll_wheel.yaml + input: + remote: c9c7e25e54eb6ac497a6791fe9cf70fe + click_action_ch1: + - action: light.toggle + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: {} + scroll_wheel_target_ch1: + - light.playroom_light + scroll_wheel_mode_ext_ch1: instant + long_click_action_ch1: + - action: light.turn_on + metadata: {} + target: + entity_id: light.playroom_light + data: + brightness_step_pct: 30 + double_click_action_ch1: [] + click_action_ch2: + - action: light.toggle + metadata: {} + target: + entity_id: light.office_lamp + data: + color_temp_kelvin: 5564 + scroll_wheel_target_ch2: + - light.office_lamp + scroll_wheel_mode_ext_ch2: instant + double_click_action_ch2: [] + scroll_wheel_mode_ch2: 'lights: dim' + on_hold_action_ch2: + - action: light.turn_on + metadata: {} + target: + entity_id: light.office_lamp + data: + color_temp_kelvin: 5765 + triple_click_action_ch2: + - action: light.turn_on + metadata: {} + target: + entity_id: light.office_lamp + data: + color_temp_kelvin: 2348 + click_action_ch3: + - action: light.toggle + metadata: {} + target: + entity_id: light.office_lamp + data: {} + - action: light.toggle + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: {} + scroll_wheel_target_ch3: + - light.office_lamp + - light.playroom_light + scroll_wheel_mode_ext_ch3: instant + on_hold_action_ch1: + - action: light.turn_on + metadata: {} + target: + entity_id: light.playroom_light + data: + brightness_pct: 30 +- id: '1784073759556' + alias: 'Office: Desk Lamp Meeting Alert' + description: Flashes the desk lamp color temperature for a few seconds when a meeting + status changes (on or off) to confirm the button press registered. + triggers: + - id: meeting_changed + entity_id: input_boolean.in_a_meeting + trigger: state + conditions: + - condition: state + entity_id: input_boolean.meeting_lamp_override + state: 'off' + actions: + - action: scene.create + data: + scene_id: desk_lamp_before_alert + snapshot_entities: + - light.office_lamp + - action: light.turn_on + target: + entity_id: light.office_lamp + data: + brightness_pct: 100 + color_temp_kelvin: 2261 + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - action: scene.turn_on + target: + entity_id: scene.desk_lamp_before_alert + data: {} + mode: queued + max: 2 +- id: '1784153893584' + alias: 'Office: Manual Meeting Status Busy Light (Night Light) - Working Hours + + Motion Detection - v2' + description: 'Work hours: Automatically turns Green or Red at 8 AM depending on + status. Toggle controls Red/Green. After hours OR Away Mode: Pure motion night + light. Instant transition when Away changes. Only triggers motion if dark.' + triggers: + - id: status_changed + entity_id: input_boolean.in_a_meeting + trigger: state + - id: away_changed + entity_id: input_boolean.away_mode + trigger: state + - id: motion_detected + entity_id: binary_sensor.night_light_occupancy + trigger: state + to: 'on' + - id: motion_cleared + entity_id: binary_sensor.night_light_occupancy + trigger: state + to: 'off' + - id: work_day_started + trigger: time + at: 08:00:00 + conditions: [] + actions: + - variables: + is_work_hours: '{{ 8 <= now().hour < 18 and now().isoweekday() in [1,2,3,4,5] + }}' + is_away: '{{ is_state(''input_boolean.away_mode'', ''on'') }}' + is_dark: '{{ states(''sensor.night_light_illuminance'') | float(0) < 15 }}' + - choose: + - alias: '--- WORKING HOURS MODE ---' + conditions: + - condition: template + value_template: '{{ is_work_hours and not is_away and (trigger.id in [''status_changed'', + ''work_day_started''] or (trigger.id == ''away_changed'' and not is_away)) + }}' + sequence: + - choose: + - alias: 'Meeting is active: Set light to solid Red (100%)' + conditions: + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'on' + sequence: + - action: light.turn_on + target: + entity_id: light.night_light + data: + brightness_pct: 100 + rgb_color: + - 255 + - 0 + - 0 + - alias: 'Meeting is inactive: Set light to solid Green (20%)' + conditions: + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'off' + sequence: + - action: light.turn_on + target: + entity_id: light.night_light + data: + brightness_pct: 30 + rgb_color: + - 5 + - 245 + - 45 + - alias: '--- AFTER HOURS / AWAY: MOTION DETECTED ---' + conditions: + - condition: template + value_template: '{{ is_dark and (not is_work_hours or is_away) and trigger.id + == ''motion_detected'' }}' + sequence: + - action: light.turn_on + target: + entity_id: light.night_light + data: + brightness_pct: 40 + color_temp_kelvin: 2700 + - alias: '--- AFTER HOURS / AWAY: MOTION CLEARED ---' + conditions: + - condition: template + value_template: '{{ (not is_work_hours or is_away) and trigger.id == ''motion_cleared'' + }}' + sequence: + - delay: 00:00:05 + - action: light.turn_off + target: + entity_id: light.night_light + - alias: '--- INSTANT AWAY TRANSITION ---' + conditions: + - condition: template + value_template: '{{ trigger.id == ''away_changed'' and is_away }}' + sequence: + - action: light.turn_off + target: + entity_id: light.night_light + - alias: '--- FALLBACK: MEETING ENDS AFTER HOURS ---' + conditions: + - condition: template + value_template: '{{ not is_work_hours and trigger.id == ''status_changed'' + and is_state(''input_boolean.in_a_meeting'', ''off'') }}' + sequence: + - action: light.turn_off + target: + entity_id: light.night_light + mode: restart +- id: '1784246257858' + alias: Sync Alexa Volume Slider + description: Syncs the Alexa Master Volume slider with the currently selected speaker. + triggers: + - entity_id: input_number.alexa_master_volume + id: slider_changed + trigger: state + - entity_id: input_select.alexa_target_device + id: speaker_changed + trigger: state + conditions: [] + actions: + - choose: + - conditions: + - condition: trigger + id: slider_changed + sequence: + - action: media_player.volume_set + target: + entity_id: '{{ states(''input_select.alexa_target_device'') }}' + data: + volume_level: '{{ states(''input_number.alexa_master_volume'') | float / + 100 }}' + - conditions: + - condition: trigger + id: speaker_changed + sequence: + - action: input_number.set_value + target: + entity_id: input_number.alexa_master_volume + data: + value: "{% set target = states('input_select.alexa_target_device') %} {% + if state_attr(target, 'volume_level') != none %}\n {{ (state_attr(target, + 'volume_level') | float * 100) | round(0) }}\n{% else %}\n 20\n{% endif + %}" + mode: restart diff --git a/configuration.yaml b/configuration.yaml index 43fd883..7c053a6 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -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 diff --git a/custom_components/ai_agent_ha/__init__.py b/custom_components/ai_agent_ha/__init__.py new file mode 100644 index 0000000..c334eae --- /dev/null +++ b/custom_components/ai_agent_ha/__init__.py @@ -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 diff --git a/custom_components/ai_agent_ha/__pycache__/__init__.cpython-314.pyc b/custom_components/ai_agent_ha/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..0407091 Binary files /dev/null and b/custom_components/ai_agent_ha/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/ai_agent_ha/__pycache__/agent.cpython-314.pyc b/custom_components/ai_agent_ha/__pycache__/agent.cpython-314.pyc new file mode 100644 index 0000000..ed1c8bf Binary files /dev/null and b/custom_components/ai_agent_ha/__pycache__/agent.cpython-314.pyc differ diff --git a/custom_components/ai_agent_ha/__pycache__/config_flow.cpython-314.pyc b/custom_components/ai_agent_ha/__pycache__/config_flow.cpython-314.pyc new file mode 100644 index 0000000..45e90e0 Binary files /dev/null and b/custom_components/ai_agent_ha/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/ai_agent_ha/__pycache__/const.cpython-314.pyc b/custom_components/ai_agent_ha/__pycache__/const.cpython-314.pyc new file mode 100644 index 0000000..f588cbb Binary files /dev/null and b/custom_components/ai_agent_ha/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/ai_agent_ha/agent.py b/custom_components/ai_agent_ha/agent.py new file mode 100644 index 0000000..bea5e72 --- /dev/null +++ b/custom_components/ai_agent_ha/agent.py @@ -0,0 +1,4415 @@ +"""The AI Agent implementation with multiple provider support. + +Example config: +ai_agent_ha: + ai_provider: openai # or 'llama', 'gemini', 'openrouter', 'anthropic', 'alter', 'zai', 'local_ollama', 'openai_compatible' + llama_token: "..." + openai_token: "..." + gemini_token: "..." + openrouter_token: "..." + anthropic_token: "..." + alter_token: "..." + zai_token: "..." + zai_endpoint: "general" # or 'coding' for z.ai (3× usage, 1/7 cost) + local_ollama_url: "http://localhost:11434/api/generate" # Required for local_ollama provider + openai_compatible_url: "http://example.com/v1/" or "http://localhost/v1/" # (Url must end with /v1/) + # Model configuration (optional, defaults will be used if not specified) + models: + openai: "gpt-3.5-turbo" # or "gpt-4", "gpt-4-turbo", etc. + llama: "Llama-4-Maverick-17B-128E-Instruct-FP8" + gemini: "gemini-2.5-flash" # or "gemini-2.5-pro", "gemini-2.0-flash", etc. + openrouter: "openai/gpt-4o" # or any model available on OpenRouter + anthropic: "claude-sonnet-4-5-20250929" # or "claude-sonnet-4-20250514", "claude-3-5-sonnet-20241022", "claude-3-opus-20240229", etc. + alter: "your-model-name" # model name for Alter API + zai: "glm-4.7" # model name for z.ai API (glm-4.7, glm-4.6, glm-4.5, etc.) + local_ollama: "llama3.2" # model name for local_ollama provider (optional if your API doesn't require it) + openai_compatible: "model unique-id or your-model-name" # model name for your OpenAI-compatible endpoint +""" + +import asyncio +import json +import logging +import os +import shutil +import tempfile +import time +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Union +from urllib.parse import quote + +import aiohttp +import yaml # type: ignore[import-untyped] +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store +from homeassistant.util import dt as dt_util + +from .const import CONF_OPENAI_BASE_URL, CONF_WEATHER_ENTITY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +# === Security Utilities === +def sanitize_for_logging(data: Any, mask: str = "***REDACTED***") -> Any: + """Sanitize sensitive data for safe logging. + + Recursively masks sensitive fields like API keys, tokens, passwords, etc. + This prevents accidental exposure of credentials in debug logs. + + Args: + data: The data structure to sanitize (dict, list, str, etc.) + mask: The string to use for masking sensitive values + + Returns: + A sanitized copy of the data with sensitive fields masked + + Example: + >>> config = {"openai_token": "sk-abc123", "ai_provider": "openai"} + >>> sanitize_for_logging(config) + {"openai_token": "***REDACTED***", "ai_provider": "openai"} + """ + # Sensitive field patterns (case-insensitive) + sensitive_patterns = { + "token", + "key", + "password", + "secret", + "credential", + "auth", + "authorization", + "api_key", + "apikey", + "llama_token", + "openai_token", + "gemini_token", + "anthropic_token", + "openrouter_token", + "alter_token", + "zai_token", + } + + if isinstance(data, dict): + sanitized = {} + for key, value in data.items(): + # Check if key matches any sensitive pattern + key_lower = str(key).lower() + is_sensitive = any(pattern in key_lower for pattern in sensitive_patterns) + + if is_sensitive: + sanitized[key] = mask + else: + # Recursively sanitize nested structures + sanitized[key] = sanitize_for_logging(value, mask) + return sanitized + + elif isinstance(data, list): + return [sanitize_for_logging(item, mask) for item in data] + + elif isinstance(data, tuple): + return tuple(sanitize_for_logging(item, mask) for item in data) + + else: + # Primitive types (str, int, bool, etc.) - return as-is + return data + + +# === AI Client Abstractions === +class NonRetryableAIError(Exception): + """Provider error that cannot succeed on retry (e.g. deterministic HTTP 4xx). + + Raised for client errors like "prompt is too long" where re-sending the + identical payload is guaranteed to fail again (see issue #80). Rate + limiting (429) and request timeouts (408) stay retryable. + """ + + +class RateLimitedAIError(Exception): + """Provider rate limit (HTTP 429) with an optional server-suggested wait. + + Carries the provider's retry-after hint so the retry loop can wait long + enough for a per-minute token window to reset instead of burning all + retries in a few seconds (see issue #80). + """ + + def __init__(self, message: str, retry_after: Optional[float] = None): + super().__init__(message) + self.retry_after = retry_after + + +class BaseAIClient: + async def get_response(self, messages, **kwargs): + raise NotImplementedError + + +class LocalOllamaClient(BaseAIClient): + """Client for Ollama-style local models using /api/generate style endpoints.""" + + def __init__(self, url, model=""): + self.url = url + self.model = model + + async def get_response(self, messages, **kwargs): + _LOGGER.debug( + "Making request to local Ollama API with model: '%s' at URL: %s", + self.model or "[NO MODEL SPECIFIED]", + self.url, + ) + + if not self.model: + _LOGGER.warning( + "No model specified for local Ollama API request. Some APIs (like Ollama) require a model name." + ) + headers = {"Content-Type": "application/json"} + + # Format user prompt from messages + prompt = "" + for message in messages: + role = message.get("role", "") + content = message.get("content", "") + + # Simple formatting: prefixing each message with its role + if role == "system": + prompt += f"System: {content}\n\n" + elif role == "user": + prompt += f"User: {content}\n\n" + elif role == "assistant": + prompt += f"Assistant: {content}\n\n" + + # Add final prompt prefix for the assistant's response + prompt += "Assistant: " + + # Build a generic payload that works with most local Ollama-style API servers + payload = { + "prompt": prompt, + "stream": False, # Disable streaming to get a single complete response + # max_tokens omitted - let local model use its default capacity + } + + # Add model if specified + if self.model: + payload["model"] = self.model + + # Note: Payloads don't contain auth tokens (those are in headers), but may contain user prompts + _LOGGER.debug( + "Local Ollama API request payload: %s", json.dumps(payload, indent=2) + ) + + # Ollama-specific validation + if "model" not in payload or not payload["model"]: + _LOGGER.warning( + "Missing 'model' field in request to local Ollama API. This may cause issues with Ollama." + ) + elif self.url and "ollama" in self.url.lower(): + _LOGGER.debug( + "Detected Ollama URL, ensuring model is specified: %s", + payload.get("model"), + ) + + async with aiohttp.ClientSession() as session: + async with session.post( + self.url, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=300), + ) as resp: + if resp.status != 200: + error_text = await resp.text() + _LOGGER.error( + "Local Ollama API error %d: %s", resp.status, error_text + ) + + # Provide more specific error messages for common Ollama issues + if resp.status == 404: + if "model" in payload and payload["model"]: + raise Exception( + f"Model '{payload['model']}' not found. Please ensure the model is installed in Ollama using: ollama pull {payload['model']}" + ) + else: + raise Exception( + "Local Ollama API endpoint not found. Please check the URL and ensure Ollama is running." + ) + elif resp.status == 400: + raise Exception( + f"Bad request to local Ollama API. Error: {error_text}" + ) + else: + raise Exception( + f"Local Ollama API error {resp.status}: {error_text}" + ) + + try: + response_text = await resp.text() + _LOGGER.debug( + "Local Ollama API response (first 200 chars): %s", + response_text[:200], + ) + _LOGGER.debug("Local Ollama API response status: %d", resp.status) + # Sanitize headers to avoid logging any auth tokens + _LOGGER.debug( + "Local Ollama API response headers: %s", + sanitize_for_logging(dict(resp.headers)), + ) + + # Try to parse as JSON + try: + data = json.loads(response_text) + + # Try common response formats + # Ollama format - return only the response text + if "response" in data: + response_content = data["response"] + _LOGGER.debug( + "Extracted response content: %s", + ( + response_content[:100] + if response_content + else "[EMPTY]" + ), + ) + + # Check if response is empty or None + if not response_content or response_content.strip() == "": + _LOGGER.warning( + "Ollama returned empty response. Full data: %s", + data, + ) + # Check if this is a loading response + if data.get("done_reason") == "load": + _LOGGER.warning( + "Ollama is still loading the model. Please wait and try again." + ) + return json.dumps( + { + "request_type": "final_response", + "response": "The AI model is still loading. Please wait a moment and try again.", + } + ) + elif data.get("done") is False: + _LOGGER.warning( + "Ollama response indicates it's not done yet." + ) + return json.dumps( + { + "request_type": "final_response", + "response": "The AI is still processing your request. Please try again.", + } + ) + else: + return json.dumps( + { + "request_type": "final_response", + "response": "The AI returned an empty response. Please try rephrasing your question.", + } + ) + + # Check if the response looks like JSON + response_content = response_content.strip() + if response_content.startswith( + "{" + ) and response_content.endswith("}"): + try: + # Validate that it's actually JSON and contains valid request_type + parsed_json = json.loads(response_content) + if ( + isinstance(parsed_json, dict) + and "request_type" in parsed_json + ): + _LOGGER.debug( + "Local Ollama model provided valid JSON response" + ) + return response_content + else: + _LOGGER.debug( + "JSON missing request_type, treating as plain text" + ) + except json.JSONDecodeError: + _LOGGER.debug( + "Invalid JSON from local Ollama model, treating as plain text" + ) + pass + + # If it's plain text, wrap it in the expected JSON format + wrapped_response = { + "request_type": "final_response", + "response": response_content, + } + _LOGGER.debug("Wrapped plain text response in JSON format") + return json.dumps(wrapped_response) + + # OpenAI-like format + elif "choices" in data and len(data["choices"]) > 0: + choice = data["choices"][0] + if "message" in choice and "content" in choice["message"]: + content = choice["message"]["content"] + elif "text" in choice: + content = choice["text"] + else: + content = str(data) + + # Check if it's valid JSON with request_type + content = content.strip() + if content.startswith("{") and content.endswith("}"): + try: + parsed_json = json.loads(content) + if ( + isinstance(parsed_json, dict) + and "request_type" in parsed_json + ): + _LOGGER.debug( + "Local Ollama model provided valid JSON response (OpenAI format)" + ) + return content + else: + _LOGGER.debug( + "JSON missing request_type, treating as plain text (OpenAI format)" + ) + except json.JSONDecodeError: + _LOGGER.debug( + "Invalid JSON from local Ollama model, treating as plain text (OpenAI format)" + ) + pass + + # Wrap in expected format if plain text + wrapped_response = { + "request_type": "final_response", + "response": content, + } + return json.dumps(wrapped_response) + + # Generic content field + elif "content" in data: + content = data["content"] + content = content.strip() + if content.startswith("{") and content.endswith("}"): + try: + parsed_json = json.loads(content) + if ( + isinstance(parsed_json, dict) + and "request_type" in parsed_json + ): + _LOGGER.debug( + "Local Ollama model provided valid JSON response (generic format)" + ) + return content + else: + _LOGGER.debug( + "JSON missing request_type, treating as plain text (generic format)" + ) + except json.JSONDecodeError: + _LOGGER.debug( + "Invalid JSON from local Ollama model, treating as plain text (generic format)" + ) + pass + + wrapped_response = { + "request_type": "final_response", + "response": content, + } + return json.dumps(wrapped_response) + + # Handle case where no standard fields are found + _LOGGER.warning( + "No standard response fields found in local Ollama API response. Full response: %s", + data, + ) + + # Check for Ollama-specific edge cases + if data.get("done_reason") == "load": + return json.dumps( + { + "request_type": "final_response", + "response": "The AI model is still loading. Please wait a moment and try again.", + } + ) + elif data.get("done") is False: + return json.dumps( + { + "request_type": "final_response", + "response": "The AI is still processing your request. Please try again.", + } + ) + elif "message" in data: + # Some APIs use "message" field + message_content = data["message"] + if ( + isinstance(message_content, dict) + and "content" in message_content + ): + content = message_content["content"] + else: + content = str(message_content) + return json.dumps( + {"request_type": "final_response", "response": content} + ) + + # Return the whole data as string if we can't find a specific field + return json.dumps( + { + "request_type": "final_response", + "response": f"Received unexpected response format from local Ollama API: {str(data)}", + } + ) + + except json.JSONDecodeError: + # If not JSON, check if it's a JSON response that got corrupted by wrapping + response_text = response_text.strip() + if response_text.startswith("{") and response_text.endswith( + "}" + ): + try: + parsed_json = json.loads(response_text) + if ( + isinstance(parsed_json, dict) + and "request_type" in parsed_json + ): + _LOGGER.debug( + "Local Ollama model provided valid JSON response (direct)" + ) + return response_text + except json.JSONDecodeError: + pass + + # If not valid JSON, wrap the raw text in expected format + _LOGGER.debug("Response is not JSON, wrapping plain text") + wrapped_response = { + "request_type": "final_response", + "response": response_text, + } + return json.dumps(wrapped_response) + + except Exception as e: + _LOGGER.error( + "Failed to parse local Ollama API response: %s", str(e) + ) + raise Exception( + f"Failed to parse local Ollama API response: {str(e)}" + ) + + +class OpenaiCompatibleClient(BaseAIClient): + """Client for OpenAI-compatible endpoints (e.g., LM Studio, vLLM, etc.). + + Expected URL format: http://example.com/v1/ + This client sends chat completions requests to: {url}/chat/completions + No API key is required by default, but can be provided if needed. + """ + + def __init__(self, base_url, model="", api_key=None): + # Ensure base_url ends with /v1/ style segment + base_url = (base_url or "").strip().rstrip("/") + if not base_url: + raise Exception("openai_compatible_url is required and must not be empty") + self.base_url = base_url + # Derive chat completions endpoint + self.api_url = f"{self.base_url}/chat/completions" + self.model = model + self.api_key = api_key or "" # Optional; many local endpoints don’t require it + + async def get_response(self, messages, **kwargs): + _LOGGER.debug( + "Making request to OpenAI-compatible endpoint at %s with model: %s", + self.api_url, + self.model or "[NO MODEL SPECIFIED]", + ) + + if not self.model: + _LOGGER.warning( + "No model specified for OpenAI-compatible request. Some servers require a model name." + ) + + headers = { + "Content-Type": "application/json", + } + + # Add Authorization header only if an API key is set + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + payload = { + "model": self.model, + "messages": messages, + "temperature": 0.7, + "top_p": 0.9, + # max_tokens omitted - let server/model use its default capacity + } + + _LOGGER.debug( + "OpenAI-compatible request payload: %s", + json.dumps(payload, indent=2), + ) + + async with aiohttp.ClientSession() as session: + async with session.post( + self.api_url, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=300), + ) as resp: + response_text = await resp.text() + _LOGGER.debug("OpenAI-compatible API response status: %d", resp.status) + _LOGGER.debug( + "OpenAI-compatible API response (first 500 chars): %s", + response_text[:500], + ) + + if resp.status != 200: + _LOGGER.error( + "OpenAI-compatible API error %d: %s", + resp.status, + response_text, + ) + raise Exception( + f"OpenAI-compatible API error {resp.status}: {response_text}" + ) + + try: + data = json.loads(response_text) + except json.JSONDecodeError as e: + _LOGGER.error( + "Failed to parse OpenAI-compatible response as JSON: %s", str(e) + ) + raise Exception( + f"Invalid JSON response from OpenAI-compatible API: {response_text[:200]}" + ) + + # Extract text from OpenAI-compatible response + choices = data.get("choices", []) + if choices and "message" in choices[0]: + content = choices[0]["message"].get("content", "") + if not content: + _LOGGER.warning( + "OpenAI-compatible API returned empty content in message" + ) + _LOGGER.debug( + "Full OpenAI-compatible API response: %s", + json.dumps(data, indent=2), + ) + return content + else: + _LOGGER.warning( + "OpenAI-compatible API response missing expected structure" + ) + _LOGGER.debug( + "Full OpenAI-compatible API response: %s", + json.dumps(data, indent=2), + ) + return str(data) + + +class LlamaClient(BaseAIClient): + def __init__(self, token, model="Llama-4-Maverick-17B-128E-Instruct-FP8"): + self.token = token + self.model = model + self.api_url = "https://api.llama.com/v1/chat/completions" + + async def get_response(self, messages, **kwargs): + _LOGGER.debug("Making request to Llama API with model: %s", self.model) + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + payload = { + "model": self.model, + "messages": messages, + "temperature": 0.7, + "top_p": 0.9, + # max_tokens omitted - let Llama use the model's default capacity + } + + _LOGGER.debug("Llama request payload: %s", json.dumps(payload, indent=2)) + + async with aiohttp.ClientSession() as session: + async with session.post( + self.api_url, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=300), + ) as resp: + if resp.status != 200: + error_text = await resp.text() + _LOGGER.error("Llama API error %d: %s", resp.status, error_text) + raise Exception(f"Llama API error {resp.status}") + data = await resp.json() + # Extract text from Llama response + completion = data.get("completion_message", {}) + content = completion.get("content", {}) + return content.get("text", str(data)) + + +async def fetch_openai_models(base_url, api_key, timeout=10): + """Fetch available OpenAI models dynamically. + + Returns a list of model IDs suitable for chat (gpt-*, o*). On failure, + returns a small safe fallback list. + """ + if not base_url: + base_url = "https://api.openai.com/v1" + url = f"{base_url.rstrip('/')}/models" + + fallback_models = [ + "gpt-4.1-mini", + "gpt-4o-mini", + "o4-mini", + ] + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + url, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as resp: + if resp.status != 200: + _LOGGER.warning( + "Failed to fetch OpenAI models (status=%d), using fallback list", + resp.status, + ) + return fallback_models + + data = await resp.json() + models = data.get("data", []) + if not isinstance(models, list): + return fallback_models + + # Filter likely chat-capable models + chat_models = [] + for m in models: + mid = m.get("id", "") + if isinstance(mid, str) and ( + mid.startswith("gpt-") or mid.startswith("o") + ): + chat_models.append(mid) + + if not chat_models: + return fallback_models + + chat_models.sort() + _LOGGER.debug("Fetched %d OpenAI chat models", len(chat_models)) + return chat_models + + except Exception as e: + _LOGGER.warning("Error fetching OpenAI models, using fallback list: %s", e) + return fallback_models + + +async def fetch_gemini_models(api_key, timeout=10): + """Fetch available Gemini models dynamically. + + Returns a list of model IDs suitable for chat. On failure, + returns a small safe fallback list. + """ + if not api_key: + return [ + "gemini-2.5-flash", + "gemini-2.5-pro", + ] + + url = f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}" + + fallback_models = [ + "gemini-2.5-flash", + "gemini-2.5-pro", + ] + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + url, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as resp: + if resp.status != 200: + _LOGGER.warning( + "Failed to fetch Gemini models (status=%d), using fallback list", + resp.status, + ) + return fallback_models + + data = await resp.json() + models = data.get("models", []) + if not isinstance(models, list): + return fallback_models + + # Filter likely chat-capable models + chat_models = [] + for m in models: + mid = m.get("name", "") + if isinstance(mid, str) and "gemini" in mid.lower(): + # Extract model ID from name like "models/gemini-2.5-flash" + model_id = mid.split("/")[-1] if "/" in mid else mid + chat_models.append(model_id) + + if not chat_models: + return fallback_models + + chat_models.sort() + _LOGGER.debug("Fetched %d Gemini models", len(chat_models)) + return chat_models + + except Exception as e: + _LOGGER.warning("Error fetching Gemini models, using fallback list: %s", e) + return fallback_models + + +async def fetch_openai_compatible_models(base_url, api_key=None, timeout=10): + """Fetch available models from an OpenAI-compatible endpoint. + + Many local/self-hosted endpoints (LM Studio, vLLM, etc.) support /v1/models. + If the endpoint does not support it, fall back to ["Custom..."] only. + """ + if not base_url: + return ["Custom..."] + + url = f"{base_url.rstrip('/')}/models" + + try: + async with aiohttp.ClientSession() as session: + headers = {"Content-Type": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + async with session.get( + url, + headers=headers, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as resp: + if resp.status not in (200, 201): + _LOGGER.debug( + "OpenAI-compatible endpoint did not support /v1/models (status=%d)", + resp.status, + ) + return ["Custom..."] + + data = await resp.json() + models = data.get("data", []) + if not isinstance(models, list): + return ["Custom..."] + + # Collect all model IDs + model_ids = [] + for m in models: + mid = m.get("id", "") + if isinstance(mid, str) and mid: + model_ids.append(mid) + + if not model_ids: + return ["Custom..."] + + model_ids.sort() + _LOGGER.debug( + "Fetched %d models from OpenAI-compatible endpoint", len(model_ids) + ) + return model_ids + + except Exception as e: + _LOGGER.debug( + "Error fetching models from OpenAI-compatible endpoint, using Custom only: %s", + e, + ) + return ["Custom..."] + + +class OpenAIClient(BaseAIClient): + def __init__(self, token, model="gpt-4.1-mini", base_url=None): + self.token = token + self.model = model + # Default endpoint is OpenAI's Responses API. If the user has pointed Base URL + # at a third-party "OpenAI-compatible" gateway (Open WebUI, LM Studio, vLLM, + # LiteLLM, ...), those servers only implement /chat/completions, so switch + # to Chat Completions when the base URL is not api.openai.com. + if base_url and base_url.strip(): + base = base_url.strip().rstrip("/") + if "api.openai.com" in base: + self.api_url = f"{base}/responses" + self.use_chat_completions = False + else: + self.api_url = f"{base}/chat/completions" + self.use_chat_completions = True + else: + self.api_url = "https://api.openai.com/v1/responses" + self.use_chat_completions = False + + def _is_restricted_model(self): + """Check if the model has restricted parameters (no temperature, top_p, etc.).""" + # Models that don't support temperature, top_p and other parameters + restricted_models = ["o3-mini", "o3", "o1-mini", "o1-preview", "o1", "gpt-5"] + + model_lower = self.model.lower() + return any(model_id in model_lower for model_id in restricted_models) + + async def get_response(self, messages, **kwargs): + _LOGGER.debug("Making request to OpenAI API with model: %s", self.model) + + # Validate token + if not self.token or not self.token.startswith("sk-"): + raise Exception("Invalid OpenAI API key format") + + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + + if self.use_chat_completions: + # Chat Completions: forward messages as-is. + payload = { + "model": self.model, + "messages": messages, + } + if not self._is_restricted_model(): + payload["temperature"] = 0.7 + payload["top_p"] = 0.9 + else: + # Responses API: flatten messages into a single input string. + parts = [] + for msg in messages: + role = (msg.get("role") or "user").lower() + content = msg.get("content") or "" + if role == "system": + parts.append(f"System: {content}") + elif role == "user": + parts.append(f"User: {content}") + elif role == "assistant": + parts.append(f"Assistant: {content}") + else: + parts.append(f"{role.capitalize()}: {content}") + payload = { + "model": self.model, + "input": "\n\n".join(parts), + } + + _LOGGER.debug("OpenAI request payload: %s", json.dumps(payload, indent=2)) + + async with aiohttp.ClientSession() as session: + async with session.post( + self.api_url, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=300), + ) as resp: + response_text = await resp.text() + _LOGGER.debug("OpenAI API response status: %d", resp.status) + _LOGGER.debug("OpenAI API response: %s", response_text[:500]) + + if resp.status != 200: + _LOGGER.error("OpenAI API error %d: %s", resp.status, response_text) + raise Exception(f"OpenAI API error {resp.status}: {response_text}") + + try: + data = json.loads(response_text) + except json.JSONDecodeError as e: + _LOGGER.error("Failed to parse OpenAI response as JSON: %s", str(e)) + raise Exception( + f"Invalid JSON response from OpenAI: {response_text[:200]}" + ) + + if self.use_chat_completions: + # Chat Completions response shape + choices = data.get("choices", []) + if choices and "message" in choices[0]: + return choices[0]["message"].get("content", "") or "" + _LOGGER.warning("OpenAI response missing expected structure") + _LOGGER.debug( + "Full OpenAI response: %s", json.dumps(data, indent=2) + ) + return str(data) + + # Extract text from OpenAI Responses API. + # Primary field: output_text is an SDK-only convenience property + # and is normally absent from the raw HTTP body, so it is only a + # fast path when a gateway happens to include it as a string. + content = data.get("output_text") + if isinstance(content, str) and content: + return content + + # Fallback: the Responses API returns + # output: [ { type: "message", content: [ { type: "output_text", + # text: "..." }, ... ] }, ... ] + # plus, for reasoning models, leading items (e.g. type "reasoning") + # that carry no text. Concatenate every output_text block we find. + # NOTE: item["content"] is a LIST here, so it must never be + # returned directly (that caused issue #75: 'list' object has no + # attribute 'strip'). + output = data.get("output") + if isinstance(output, list): + text_parts = [] + for item in output: + if not isinstance(item, dict): + continue + content_blocks = item.get("content") + if isinstance(content_blocks, list): + for block in content_blocks: + if isinstance(block, dict) and isinstance( + block.get("text"), str + ): + text_parts.append(block["text"]) + elif isinstance(content_blocks, str) and content_blocks: + text_parts.append(content_blocks) + elif isinstance(item.get("text"), str): + text_parts.append(item["text"]) + if text_parts: + return "".join(text_parts) + + # Last resort: return full response as string + _LOGGER.warning("OpenAI response missing expected structure") + _LOGGER.debug("Full OpenAI response: %s", json.dumps(data, indent=2)) + return str(data) + + +class GeminiClient(BaseAIClient): + def __init__(self, token, model="gemini-2.5-flash"): + self.token = token.strip() if token else token # Strip whitespace from token + self.model = model + # Use v1beta for all models as per Google's current API documentation + # All Gemini 2.0/2.5 models are available on v1beta endpoint + self.api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" + + async def get_response(self, messages, **kwargs): + _LOGGER.debug("Making request to Gemini API with model: %s", self.model) + + # Validate token + if not self.token: + raise Exception("Missing Gemini API key") + + headers = {"Content-Type": "application/json"} + + # Convert OpenAI-style messages to Gemini format + gemini_contents = [] + for message in messages: + role = message.get("role", "user") + content = message.get("content", "") + + if role == "system": + # Gemini doesn't have a system role, so we prepend it to the first user message + if not gemini_contents: + gemini_contents.append( + {"role": "user", "parts": [{"text": f"System: {content}"}]} + ) + else: + # Add system message as user message + gemini_contents.append( + {"role": "user", "parts": [{"text": f"System: {content}"}]} + ) + elif role == "user": + gemini_contents.append({"role": "user", "parts": [{"text": content}]}) + elif role == "assistant": + gemini_contents.append({"role": "model", "parts": [{"text": content}]}) + + payload = { + "contents": gemini_contents, + "generationConfig": { + "temperature": 0.7, + "topP": 0.9, + # maxOutputTokens omitted - let Gemini use model's maximum capacity + }, + } + + # Add API key as query parameter (URL encoded) + url_with_key = f"{self.api_url}?key={quote(self.token)}" + + _LOGGER.debug("Gemini request payload: %s", json.dumps(payload, indent=2)) + + async with aiohttp.ClientSession() as session: + async with session.post( + url_with_key, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=300), + ) as resp: + response_text = await resp.text() + _LOGGER.debug("Gemini API response status: %d", resp.status) + _LOGGER.debug("Gemini API response: %s", response_text[:500]) + + if resp.status != 200: + _LOGGER.error("Gemini API error %d: %s", resp.status, response_text) + raise Exception(f"Gemini API error {resp.status}: {response_text}") + + try: + data = json.loads(response_text) + except json.JSONDecodeError as e: + _LOGGER.error("Failed to parse Gemini response as JSON: %s", str(e)) + raise Exception( + f"Invalid JSON response from Gemini: {response_text[:200]}" + ) + + # Log token usage for debugging, especially for Gemini 2.5 extended thinking + usage_metadata = data.get("usageMetadata", {}) + if usage_metadata: + _LOGGER.debug( + "Gemini token usage - prompt: %d, total: %d, thoughts: %d", + usage_metadata.get("promptTokenCount", 0), + usage_metadata.get("totalTokenCount", 0), + usage_metadata.get("thoughtsTokenCount", 0), + ) + + # Extract text from Gemini response + candidates = data.get("candidates", []) + if candidates and "content" in candidates[0]: + # Check finish reason for potential issues + finish_reason = candidates[0].get("finishReason", "") + if finish_reason == "MAX_TOKENS": + _LOGGER.warning( + "Gemini response truncated due to MAX_TOKENS limit. " + "Thoughts used: %d tokens. Consider increasing maxOutputTokens.", + usage_metadata.get("thoughtsTokenCount", 0), + ) + + parts = candidates[0]["content"].get("parts", []) + if parts: + content = parts[0].get("text", "") + if not content: + _LOGGER.warning("Gemini returned empty text content") + _LOGGER.debug( + "Full Gemini response: %s", json.dumps(data, indent=2) + ) + return content + else: + _LOGGER.warning("Gemini response missing parts") + _LOGGER.debug( + "Full Gemini response: %s", json.dumps(data, indent=2) + ) + else: + _LOGGER.warning("Gemini response missing expected structure") + _LOGGER.debug( + "Full Gemini response: %s", json.dumps(data, indent=2) + ) + return str(data) + + +class AnthropicClient(BaseAIClient): + def __init__(self, token, model="claude-sonnet-4-5-20250929"): + self.token = token + self.model = model + self.api_url = "https://api.anthropic.com/v1/messages" + + async def get_response(self, messages, **kwargs): + _LOGGER.debug("Making request to Anthropic API with model: %s", self.model) + headers = { + "x-api-key": self.token, + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + } + + # Convert OpenAI-style messages to Anthropic format + system_message = None + anthropic_messages = [] + + for message in messages: + role = message.get("role", "user") + content = message.get("content", "") + + if role == "system": + # Anthropic uses a separate system parameter + system_message = content + elif role == "user": + anthropic_messages.append({"role": "user", "content": content}) + elif role == "assistant": + anthropic_messages.append({"role": "assistant", "content": content}) + + payload = { + "model": self.model, + "max_tokens": 8192, # Maximum for Anthropic Claude models + "temperature": 0.7, + "messages": anthropic_messages, + } + + # Add system message if present + if system_message: + payload["system"] = system_message + + _LOGGER.debug("Anthropic request payload: %s", json.dumps(payload, indent=2)) + + async with aiohttp.ClientSession() as session: + async with session.post( + self.api_url, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=300), + ) as resp: + if resp.status != 200: + error_text = await resp.text() + _LOGGER.error("Anthropic API error %d: %s", resp.status, error_text) + # Surface the API's own error message (e.g. "prompt is too + # long: N tokens > 200000 maximum") instead of a bare status + # code so the user can see why the request failed (issue #80). + try: + detail = json.loads(error_text)["error"]["message"] + except (ValueError, KeyError, TypeError): + detail = error_text[:300] + message = f"Anthropic API error {resp.status}: {detail}" + if resp.status == 429: + # Honor the server's retry-after hint so the retry + # loop waits out per-minute token windows. + retry_after = None + try: + header = resp.headers.get("retry-after") + if header is not None: + retry_after = float(header) + except (TypeError, ValueError): + retry_after = None + raise RateLimitedAIError(message, retry_after=retry_after) + if 400 <= resp.status < 500 and resp.status != 408: + # Deterministic client error - retrying the identical + # payload cannot succeed, so don't burn retries on it. + raise NonRetryableAIError(message) + raise Exception(message) + data = await resp.json() + # Extract text from Anthropic response + content_blocks = data.get("content", []) + if content_blocks and isinstance(content_blocks, list): + # Get the text from the first content block + for block in content_blocks: + if block.get("type") == "text": + return block.get("text", str(data)) + return str(data) + + +class OpenRouterClient(BaseAIClient): + def __init__(self, token, model="openai/gpt-4o"): + self.token = token + self.model = model + self.api_url = "https://openrouter.ai/api/v1/chat/completions" + + async def get_response(self, messages, **kwargs): + _LOGGER.debug("Making request to OpenRouter API with model: %s", self.model) + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + "HTTP-Referer": "https://home-assistant.io", # Optional for OpenRouter rankings + "X-Title": "Home Assistant AI Agent", # Optional for OpenRouter rankings + } + payload = { + "model": self.model, + "messages": messages, + "temperature": 0.7, + "top_p": 0.9, + # max_tokens omitted - let OpenRouter use the model's maximum capacity + } + + _LOGGER.debug("OpenRouter request payload: %s", json.dumps(payload, indent=2)) + + async with aiohttp.ClientSession() as session: + async with session.post( + self.api_url, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=300), + ) as resp: + if resp.status != 200: + error_text = await resp.text() + _LOGGER.error( + "OpenRouter API error %d: %s", resp.status, error_text + ) + raise Exception(f"OpenRouter API error {resp.status}") + data = await resp.json() + # Extract text from OpenRouter response (OpenAI-compatible format) + choices = data.get("choices", []) + if not choices: + _LOGGER.warning("OpenRouter response missing choices") + _LOGGER.debug( + "Full OpenRouter response: %s", json.dumps(data, indent=2) + ) + return str(data) + if choices and "message" in choices[0]: + return choices[0]["message"].get("content", str(data)) + return str(data) + + +class AlterClient(BaseAIClient): + def __init__(self, token, model=""): + self.token = token + self.model = model + self.api_url = "https://alterhq.com/api/v1/chat/completions" + + async def get_response(self, messages, **kwargs): + _LOGGER.debug("Making request to Alter API with model: %s", self.model) + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + payload = { + "model": self.model, + "messages": messages, + "temperature": 0.7, + "top_p": 0.9, + } + + _LOGGER.debug("Alter request payload: %s", json.dumps(payload, indent=2)) + + async with aiohttp.ClientSession() as session: + async with session.post( + self.api_url, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=300), + ) as resp: + if resp.status != 200: + error_text = await resp.text() + _LOGGER.error("Alter API error %d: %s", resp.status, error_text) + raise Exception(f"Alter API error {resp.status}") + data = await resp.json() + # Extract text from Alter response (OpenAI-compatible format) + choices = data.get("choices", []) + if not choices: + _LOGGER.warning("Alter response missing choices") + _LOGGER.debug("Full Alter response: %s", json.dumps(data, indent=2)) + return str(data) + if choices and "message" in choices[0]: + return choices[0]["message"].get("content", str(data)) + return str(data) + + +class ZaiClient(BaseAIClient): + def __init__(self, token, model="", endpoint_type="general"): + self.token = token + self.model = model + self.endpoint_type = endpoint_type + # General endpoint: https://api.z.ai/api/paas/v4/chat/completions + # Coding endpoint: https://api.z.ai/api/coding/paas/v4/chat/completions + if endpoint_type == "coding": + self.api_url = "https://api.z.ai/api/coding/paas/v4/chat/completions" + else: + self.api_url = "https://api.z.ai/api/paas/v4/chat/completions" + + async def get_response(self, messages, **kwargs): + _LOGGER.debug( + "Making request to z.ai API with model: %s, endpoint: %s", + self.model, + self.endpoint_type, + ) + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + payload = { + "model": self.model, + "messages": messages, + "temperature": 0.7, + "top_p": 0.9, + } + + _LOGGER.debug("z.ai request payload: %s", json.dumps(payload, indent=2)) + + async with aiohttp.ClientSession() as session: + async with session.post( + self.api_url, + headers=headers, + json=payload, + timeout=aiohttp.ClientTimeout(total=300), + ) as resp: + if resp.status != 200: + error_text = await resp.text() + _LOGGER.error("z.ai API error %d: %s", resp.status, error_text) + raise Exception(f"z.ai API error {resp.status}") + data = await resp.json() + # Extract text from z.ai response (OpenAI-compatible format) + choices = data.get("choices", []) + if not choices: + _LOGGER.warning("z.ai response missing choices") + _LOGGER.debug("Full z.ai response: %s", json.dumps(data, indent=2)) + return str(data) + if choices and "message" in choices[0]: + return choices[0]["message"].get("content", str(data)) + return str(data) + + +# === Main Agent === +class AiAgentHaAgent: + """Agent for handling queries with dynamic data requests and multiple AI providers.""" + + SYSTEM_PROMPT = { + "role": "system", + "content": ( + "You are an AI assistant integrated with Home Assistant.\n" + "You can request specific data by using only these commands:\n" + "- get_entity_state(entity_id): Get state of a specific entity\n" + "- get_entities_by_domain(domain): Get all entities in a domain\n" + "- get_entities_by_device_class(device_class, domain?): Get entities with specific device_class (e.g., 'temperature', 'humidity', 'motion')\n" + "- get_climate_related_entities(): Get all climate-related entities (climate.* entities + temperature/humidity sensors)\n" + "- get_entities_by_area(area_id): Get all entities in a specific area\n" + "- get_entities(area_id or area_ids): Get entities by area(s) - supports single area_id or list of area_ids\n" + " Use as: get_entities(area_ids=['area1', 'area2']) for multiple areas or get_entities(area_id='single_area')\n" + "- get_calendar_events(entity_id?): Get calendar events\n" + "- get_automations(): Get all automations\n" + "- get_weather_data(): Get current weather and forecast data\n" + "- get_entity_registry(): Get entity registry entries (now includes device_class, state_class, unit_of_measurement)\n" + "- get_device_registry(): Get device registry entries\n" + "- get_area_registry(): Get room/area information\n" + "- get_history(entity_id, hours): Get historical state changes\n" + "- get_person_data(): Get person tracking information\n" + "- get_statistics(entity_id): Get sensor statistics\n" + "- get_scenes(): Get scene configurations\n" + "- get_dashboards(): Get list of all dashboards\n" + "- get_dashboard_config(dashboard_url): Get configuration of a specific dashboard\n" + "- set_entity_state(entity_id, state, attributes?): Set state of an entity (e.g., turn on/off lights, open/close covers)\n" + "- call_service(domain, service, target?, service_data?): Call any Home Assistant service directly\n" + "- create_automation(automation): Create a new automation with the provided configuration\n" + "- create_dashboard(dashboard_config): Create a new dashboard with the provided configuration\n" + "- update_dashboard(dashboard_url, dashboard_config): Update an existing dashboard configuration\n\n" + "IMPORTANT DEVICE_CLASS GUIDANCE:\n" + "- Many sensors have a 'device_class' attribute (temperature, humidity, motion, etc.)\n" + "- Use get_climate_related_entities() for climate dashboards (includes climate.* entities and temperature/humidity sensors)\n" + "- Use get_entities_by_device_class(device_class) to filter by device_class (e.g., 'temperature', 'humidity', 'motion')\n" + "- For climate dashboards, use history-graph and gauge cards for temperature/humidity sensors\n\n" + "DASHBOARD CREATION:\n" + "When a user asks to create a dashboard:\n" + "1. Gather entities using get_climate_related_entities() or other get_* commands\n" + "2. Respond with JSON using request_type: 'dashboard_suggestion' (NEVER use 'final_response'!)\n" + "3. Use Lovelace JSON format (NOT YAML!)\n" + "4. Example response structure:\n" + '{"request_type": "dashboard_suggestion", "message": "Dashboard created", "dashboard": {"title": "...", "views": [...]}}\n' + "5. Do NOT include YAML, markdown, or code blocks - only pure JSON\n\n" + "IMPORTANT AREA/FLOOR GUIDANCE:\n" + "- When users ask for entities from a specific floor, use get_area_registry() first\n" + "- Areas have both 'area_id' and 'floor_id' - these are different concepts\n" + "- Filter areas by their floor_id to find all areas on a specific floor\n" + "- Use get_entities() with area_ids parameter to get entities from multiple areas efficiently\n" + "- Example: get_entities(area_ids=['area1', 'area2', 'area3']) for multiple areas at once\n" + "- This is more efficient than calling get_entities_by_area() multiple times\n\n" + "AUTOMATION CREATION:\n" + "When creating automations, request entities first to know the entity IDs.\n" + "For days, use: ['fri', 'mon', 'sat', 'sun', 'thu', 'tue', 'wed']\n\n" + "RESPONSE FORMATS - You must ALWAYS respond with valid JSON:\n\n" + "For automations:\n" + "{\n" + ' "request_type": "automation_suggestion",\n' + ' "message": "I\'ve created an automation that might help you. Would you like me to create it?",\n' + ' "automation": {\n' + ' "alias": "Name of the automation",\n' + ' "description": "Description of what the automation does",\n' + ' "trigger": [...], // Array of trigger conditions\n' + ' "condition": [...], // Optional array of conditions\n' + ' "action": [...] // Array of actions to perform\n' + " }\n" + "}\n\n" + "For dashboards (WHEN USER ASKS TO CREATE A DASHBOARD):\n" + "{\n" + ' "request_type": "dashboard_suggestion",\n' + ' "message": "Description of the dashboard you created",\n' + ' "dashboard": {\n' + ' "title": "Dashboard Title",\n' + ' "url_path": "url-path",\n' + ' "icon": "mdi:icon-name",\n' + ' "show_in_sidebar": true,\n' + ' "views": [{\n' + ' "title": "View Title",\n' + ' "cards": [...]\n' + " }]\n" + " }\n" + "}\n\n" + "For data requests, use this exact JSON format:\n" + "{\n" + ' "request_type": "data_request",\n' + ' "request": "command_name",\n' + ' "parameters": {...}\n' + "}\n" + 'For get_entities with multiple areas: {"request_type": "get_entities", "parameters": {"area_ids": ["area1", "area2"]}}\n' + 'For get_entities with single area: {"request_type": "get_entities", "parameters": {"area_id": "single_area"}}\n\n' + "For service calls, use this exact JSON format:\n" + "{\n" + ' "request_type": "call_service",\n' + ' "domain": "light",\n' + ' "service": "turn_on",\n' + ' "target": {"entity_id": ["entity1", "entity2"]},\n' + ' "service_data": {"brightness": 255}\n' + "}\n\n" + "For answering questions (NOT creating dashboards/automations):\n" + "{\n" + ' "request_type": "final_response",\n' + ' "response": "your answer to the user"\n' + "}\n\n" + "IMPORTANT: Use 'dashboard_suggestion' when creating dashboards, NOT 'final_response'!\n\n" + "CRITICAL FORMATTING RULES:\n" + "- You must ALWAYS respond with ONLY a valid JSON object\n" + "- DO NOT include any text before the JSON\n" + "- DO NOT include any text after the JSON\n" + "- DO NOT include explanations or descriptions outside the JSON\n" + "- Your entire response must be parseable as JSON\n" + "- Use the 'message' field inside the JSON for user-facing text\n" + "- NEVER mix regular text with JSON in your response\n\n" + "WRONG: 'I'll create this for you. {\"request_type\": ...}'\n" + 'CORRECT: \'{"request_type": "dashboard_suggestion", "message": "I\'ll create this for you.", ...}\'' + ), + } + + SYSTEM_PROMPT_LOCAL = { + "role": "system", + "content": ( + "You are an AI assistant integrated with Home Assistant.\n" + "You can request specific data by using only these commands:\n" + "- get_entity_state(entity_id): Get state of a specific entity\n" + "- get_entities_by_domain(domain): Get all entities in a domain\n" + "- get_entities_by_device_class(device_class, domain?): Get entities with specific device_class (e.g., 'temperature', 'humidity', 'motion')\n" + "- get_climate_related_entities(): Get all climate-related entities (climate.* entities + temperature/humidity sensors)\n" + "- get_entities_by_area(area_id): Get all entities in a specific area\n" + "- get_entities(area_id or area_ids): Get entities by area(s) - supports single area_id or list of area_ids\n" + " Use as: get_entities(area_ids=['area1', 'area2']) for multiple areas or get_entities(area_id='single_area')\n" + "- get_calendar_events(entity_id?): Get calendar events\n" + "- get_automations(): Get all automations\n" + "- get_weather_data(): Get current weather and forecast data\n" + "- get_entity_registry(): Get entity registry entries (now includes device_class, state_class, unit_of_measurement)\n" + "- get_device_registry(): Get device registry entries\n" + "- get_area_registry(): Get room/area information\n" + "- get_history(entity_id, hours): Get historical state changes\n" + "- get_person_data(): Get person tracking information\n" + "- get_statistics(entity_id): Get sensor statistics\n" + "- get_scenes(): Get scene configurations\n" + "- get_dashboards(): Get list of all dashboards\n" + "- get_dashboard_config(dashboard_url): Get configuration of a specific dashboard\n" + "- set_entity_state(entity_id, state, attributes?): Set state of an entity (e.g., turn on/off lights, open/close covers)\n" + "- call_service(domain, service, target?, service_data?): Call any Home Assistant service directly\n" + "- create_automation(automation): Create a new automation with the provided configuration\n" + "- create_dashboard(dashboard_config): Create a new dashboard with the provided configuration\n" + "- update_dashboard(dashboard_url, dashboard_config): Update an existing dashboard configuration\n\n" + "IMPORTANT DEVICE_CLASS GUIDANCE:\n" + "- Many sensors have a 'device_class' attribute (temperature, humidity, motion, etc.)\n" + "- Use get_climate_related_entities() for climate dashboards (includes climate.* entities and temperature/humidity sensors)\n" + "- Use get_entities_by_device_class(device_class) to filter by device_class (e.g., 'temperature', 'humidity', 'motion')\n" + "- For climate dashboards, use history-graph and gauge cards for temperature/humidity sensors\n\n" + "DASHBOARD CREATION:\n" + "When a user asks to create a dashboard:\n" + "1. Gather entities using get_climate_related_entities() or other get_* commands\n" + "2. Respond with JSON using request_type: 'dashboard_suggestion' (NEVER use 'final_response'!)\n" + "3. Use Lovelace JSON format (NOT YAML!)\n" + "4. Example response structure:\n" + '{"request_type": "dashboard_suggestion", "message": "Dashboard created", "dashboard": {"title": "...", "views": [...]}}\n' + "5. Do NOT include YAML, markdown, or code blocks - only pure JSON\n\n" + "IMPORTANT AREA/FLOOR GUIDANCE:\n" + "- When users ask for entities from a specific floor, use get_area_registry() first\n" + "- Areas have both 'area_id' and 'floor_id' - these are different concepts\n" + "- Filter areas by their floor_id to find all areas on a specific floor\n" + "- Use get_entities() with area_ids parameter to get entities from multiple areas efficiently\n" + "- Example: get_entities(area_ids=['area1', 'area2', 'area3']) for multiple areas at once\n" + "- This is more efficient than calling get_entities_by_area() multiple times\n\n" + "AUTOMATION CREATION:\n" + "When creating automations, request entities first to know the entity IDs.\n" + "For days, use: ['fri', 'mon', 'sat', 'sun', 'thu', 'tue', 'wed']\n\n" + "RESPONSE FORMATS - You must ALWAYS respond with valid JSON:\n\n" + "For automations:\n" + "{\n" + ' "request_type": "automation_suggestion",\n' + ' "message": "I\'ve created an automation that might help you. Would you like me to create it?",\n' + ' "automation": {\n' + ' "alias": "Name of the automation",\n' + ' "description": "Description of what the automation does",\n' + ' "trigger": [...], // Array of trigger conditions\n' + ' "condition": [...], // Optional array of conditions\n' + ' "action": [...] // Array of actions to perform\n' + " }\n" + "}\n\n" + "For dashboards (WHEN USER ASKS TO CREATE A DASHBOARD):\n" + "{\n" + ' "request_type": "dashboard_suggestion",\n' + ' "message": "Description of the dashboard you created",\n' + ' "dashboard": {\n' + ' "title": "Dashboard Title",\n' + ' "url_path": "url-path",\n' + ' "icon": "mdi:icon-name",\n' + ' "show_in_sidebar": true,\n' + ' "views": [{\n' + ' "title": "View Title",\n' + ' "cards": [...]\n' + " }]\n" + " }\n" + "}\n\n" + "For data requests, use this exact JSON format:\n" + "{\n" + ' "request_type": "data_request",\n' + ' "request": "command_name",\n' + ' "parameters": {...}\n' + "}\n" + 'For get_entities with multiple areas: {"request_type": "get_entities", "parameters": {"area_ids": ["area1", "area2"]}}\n' + 'For get_entities with single area: {"request_type": "get_entities", "parameters": {"area_id": "single_area"}}\n\n' + "For service calls, use this exact JSON format:\n" + "{\n" + ' "request_type": "call_service",\n' + ' "domain": "light",\n' + ' "service": "turn_on",\n' + ' "target": {"entity_id": ["entity1", "entity2"]},\n' + ' "service_data": {"brightness": 255}\n' + "}\n\n" + "For answering questions (NOT creating dashboards/automations):\n" + "{\n" + ' "request_type": "final_response",\n' + ' "response": "your answer to the user"\n' + "}\n\n" + "IMPORTANT: Use 'dashboard_suggestion' when creating dashboards, NOT 'final_response'!\n\n" + "CRITICAL FORMATTING RULES:\n" + "- You must ALWAYS respond with ONLY a valid JSON object\n" + "- DO NOT include any text before the JSON\n" + "- DO NOT include any text after the JSON\n" + "- DO NOT include explanations or descriptions outside the JSON\n" + "- Your entire response must be parseable as JSON\n" + "- Use the 'message' field inside the JSON for user-facing text\n" + "- NEVER mix regular text with JSON in your response\n\n" + "WRONG: 'I'll create this for you. {\"request_type\": ...}'\n" + 'CORRECT: \'{"request_type": "dashboard_suggestion", "message": "I\'ll create this for you.", ...}\'' + ), + } + + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]): + """Initialize the agent with provider selection.""" + self.hass = hass + self.config = config + self.conversation_history: List[Dict[str, Any]] = [] + self._cache: Dict[str, Any] = {} + self.ai_client: BaseAIClient + self._cache_timeout = 300 # 5 minutes + self._max_retries = 10 + self._retry_delay = 1 # seconds + self._rate_limit = 60 # requests per minute + self._last_request_time = 0 + self._request_count = 0 + self._request_window_start = time.time() + + provider = config.get("ai_provider", "openai") + models_config = config.get("models", {}) + + _LOGGER.debug("Initializing AiAgentHaAgent with provider: %s", provider) + _LOGGER.debug("Models config loaded: %s", models_config) + + # Set the appropriate system prompt based on provider + if provider in ("local_ollama", "openai_compatible"): + self.system_prompt = self.SYSTEM_PROMPT_LOCAL + _LOGGER.debug( + "Using local-optimized system prompt for provider: %s", provider + ) + else: + self.system_prompt = self.SYSTEM_PROMPT + _LOGGER.debug("Using standard system prompt") + + # Initialize the appropriate AI client with model selection + if provider == "openai": + model = models_config.get("openai", "gpt-3.5-turbo") + base_url = config.get(CONF_OPENAI_BASE_URL) or "" + self.ai_client = OpenAIClient( + config.get("openai_token"), model, base_url or None + ) + elif provider == "gemini": + model = models_config.get("gemini", "gemini-2.5-flash") + self.ai_client = GeminiClient(config.get("gemini_token"), model) + elif provider == "openrouter": + model = models_config.get("openrouter", "openai/gpt-4o") + self.ai_client = OpenRouterClient(config.get("openrouter_token"), model) + elif provider == "anthropic": + model = models_config.get("anthropic", "claude-sonnet-4-5-20250929") + self.ai_client = AnthropicClient(config.get("anthropic_token"), model) + elif provider == "alter": + model = models_config.get("alter", "") + self.ai_client = AlterClient(config.get("alter_token"), model) + elif provider == "zai": + model = models_config.get("zai", "glm-4.7") + endpoint_type = config.get("zai_endpoint", "general") + self.ai_client = ZaiClient(config.get("zai_token"), model, endpoint_type) + elif provider == "local_ollama": + # Support both new local_ollama_url and legacy local_url + url = config.get("local_ollama_url") or config.get("local_url") + model = models_config.get("local_ollama") or models_config.get("local", "") + if not url: + _LOGGER.error("Missing local_ollama_url for local_ollama provider") + raise Exception( + "Missing local_ollama_url configuration for local_ollama provider" + ) + self.ai_client = LocalOllamaClient(url, model) + elif provider == "openai_compatible": + url = config.get("openai_compatible_url") + model = models_config.get("openai_compatible", "") + api_key = config.get("openai_compatible_api_key", "") or "" + if not url: + _LOGGER.error( + "Missing openai_compatible_url for openai_compatible provider" + ) + raise Exception( + "Missing openai_compatible_url configuration for openai_compatible provider" + ) + self.ai_client = OpenaiCompatibleClient(url, model, api_key or None) + else: # default to llama if somehow specified + model = models_config.get("llama", "Llama-4-Maverick-17B-128E-Instruct-FP8") + self.ai_client = LlamaClient(config.get("llama_token"), model) + + _LOGGER.debug( + "AiAgentHaAgent initialized successfully with provider: %s, model: %s", + provider, + model, + ) + + def _validate_api_key(self) -> bool: + """Validate the API key format.""" + provider = self.config.get("ai_provider", "openai") + + if provider == "openai": + token = self.config.get("openai_token") + elif provider == "gemini": + token = self.config.get("gemini_token") + elif provider == "openrouter": + token = self.config.get("openrouter_token") + elif provider == "anthropic": + token = self.config.get("anthropic_token") + elif provider == "alter": + token = self.config.get("alter_token") + elif provider == "zai": + token = self.config.get("zai_token") + elif provider == "local_ollama": + # For local_ollama, the “token” is actually the URL; support legacy local_url + token = self.config.get("local_ollama_url") or self.config.get("local_url") + elif provider == "openai_compatible": + # For openai_compatible, validate the URL is present + token = self.config.get("openai_compatible_url") + else: + token = self.config.get("llama_token") + + if not token or not isinstance(token, str): + return False + + # For local_ollama and openai_compatible, validate URL format + if provider in ("local_ollama", "openai_compatible"): + return bool(token.startswith(("http://", "https://"))) + + # Add more specific validation based on your API key format + return len(token) >= 32 + + def _check_rate_limit(self) -> bool: + """Check if we're within rate limits.""" + current_time = time.time() + if current_time - self._request_window_start >= 60: + self._request_count = 0 + self._request_window_start = current_time + + if self._request_count >= self._rate_limit: + return False + + self._request_count += 1 + return True + + def _get_cached_data(self, key: str) -> Optional[Any]: + """Get data from cache if it's still valid.""" + if key in self._cache: + timestamp, data = self._cache[key] + if time.time() - timestamp < self._cache_timeout: + return data + del self._cache[key] + return None + + def _set_cached_data(self, key: str, data: Any) -> None: + """Store data in cache with timestamp.""" + self._cache[key] = (time.time(), data) + + # Maximum size (in characters) of a single data message added to the + # conversation. Large installs can return megabytes of entity data, which + # blows past the model's context window (~200k tokens for Claude) and makes + # every request fail with a deterministic 400 (issue #80). 50k chars is + # roughly 13k tokens, which also keeps requests within entry-tier + # per-minute token rate limits (e.g. Anthropic tier 1: 30k input + # tokens/min) even with a data message persisting in the history window. + MAX_DATA_MESSAGE_CHARS = 50_000 + + # Maximum combined size (in characters) of the message window sent to the + # provider per request. Several capped data messages can still stack past + # context and per-minute token budgets (issue #80); the most recent + # messages are kept. ~100k chars is roughly 25k tokens. + MAX_WINDOW_CHARS = 100_000 + + def _format_data_message(self, data: Any) -> str: + """Serialize fetched HA data for the conversation, capping its size. + + If the payload is too large, list items are dropped from the end and a + truncation notice is included so the model knows to request more + specific data instead of the full dump. + """ + message = json.dumps({"data": data}, default=str) + if len(message) <= self.MAX_DATA_MESSAGE_CHARS: + return message + + note = ( + "Data truncated to fit the model context window. " + "Request more specific data (e.g. a specific entity, domain or area) " + "to see the rest." + ) + if isinstance(data, list) and data: + truncated = data + while len(message) > self.MAX_DATA_MESSAGE_CHARS and len(truncated) > 1: + # Scale down proportionally to the overshoot, then re-check + # (item sizes vary, so loop until it actually fits). + keep = max( + 1, + len(truncated) * self.MAX_DATA_MESSAGE_CHARS // len(message), + ) + truncated = truncated[:keep] + message = json.dumps( + { + "data": truncated, + "truncated": True, + "total_items": len(data), + "items_shown": len(truncated), + "note": note, + }, + default=str, + ) + if len(message) <= self.MAX_DATA_MESSAGE_CHARS: + _LOGGER.warning( + "Data response truncated from %d to %d items to fit context window", + len(data), + len(truncated), + ) + return message + # A single item alone exceeds the cap - fall through to the + # hard-truncated preview below so the cap always holds. + + # Oversized non-list payloads (or a single oversized list item): keep + # a prefix of the serialized form as a preview. + _LOGGER.warning( + "Oversized data response hard-truncated from %d chars", len(message) + ) + preview = message[: self.MAX_DATA_MESSAGE_CHARS] + result = json.dumps({"data_preview": preview, "truncated": True, "note": note}) + # JSON-escaping the preview can push the result back over the cap; + # shrink until the final message actually fits. + while len(result) > self.MAX_DATA_MESSAGE_CHARS and preview: + preview = preview[: int(len(preview) * 0.9)] + result = json.dumps( + {"data_preview": preview, "truncated": True, "note": note} + ) + return result + + def _sanitize_automation_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Sanitize automation configuration to prevent injection attacks.""" + sanitized: Dict[str, Any] = {} + for key, value in config.items(): + if key in ["alias", "description"]: + # Sanitize strings + sanitized[key] = str(value).strip()[:100] # Limit length + elif key in ["trigger", "condition", "action"]: + # Home Assistant accepts either a single mapping or a list for + # trigger/condition/action. Normalize a single mapping to a + # one-element list instead of silently dropping it (which would + # later raise KeyError and reject a perfectly valid automation). + if isinstance(value, list): + sanitized[key] = value + elif isinstance(value, dict): + sanitized[key] = [value] + elif key == "mode": + # Validate mode + if value in ["single", "restart", "queued", "parallel"]: + sanitized[key] = value + return sanitized + + @staticmethod + def _read_automations_file(path: str) -> List[Dict[str, Any]]: + """Read and parse automations.yaml into a list of automations. + + Runs inside an executor thread. Raises FileNotFoundError when the file + does not exist (the caller treats that as "no automations yet"). + """ + with open(path, "r", encoding="utf-8") as handle: + data = yaml.safe_load(handle) + + if data is None: + return [] + if not isinstance(data, list): + # automations.yaml is always a top-level list. If it is anything + # else, refuse to touch it rather than risk clobbering content we + # don't understand. + raise ValueError("automations.yaml does not contain a list of automations") + return data + + @staticmethod + def _write_automations_file(path: str, automations: List[Dict[str, Any]]) -> None: + """Safely persist automations to automations.yaml. + + Runs inside an executor thread. Compared to a naive ``yaml.dump`` to an + open file handle, this: + * keeps accented/unicode text readable instead of mangling it into + ``\\uXXXX`` escapes (``allow_unicode=True``), + * preserves key order so automations stay diff-friendly + (``sort_keys=False``), + * never line-wraps long Jinja templates (``width``), + * validates that the serialized YAML round-trips back to the same + data before touching disk, + * backs up the previous file to ``.bak`` so the user can roll + back, + * writes atomically (temp file + ``os.replace``) so a crash or a bad + write can never leave a half-written / corrupted file in place. + """ + # Use safe_dump (the SafeDumper) so the writer mirrors the safe_load + # reader: only plain YAML types are ever emitted, never opaque + # ``!!python/object`` tags. + content = yaml.safe_dump( + automations, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + width=4096, + ) + + # Guard against ever writing YAML that does not decode back to the + # exact same data structure. + if yaml.safe_load(content) != automations: + raise ValueError( + "Refusing to write automations.yaml: serialized YAML did not " + "round-trip cleanly" + ) + + path_exists = os.path.exists(path) + + # Back up the existing file before overwriting it. + if path_exists: + shutil.copy2(path, f"{path}.bak") + + # Atomic write: write to a temp file in the same directory, fsync, then + # atomically replace the target. + directory = os.path.dirname(path) or "." + fd, tmp_path = tempfile.mkstemp( + prefix=".automations.", suffix=".yaml.tmp", dir=directory + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + # Preserve the original file's permission bits. mkstemp creates the + # temp file as 0600, so without this an atomic replace would strip + # any group/world bits the user or an external editor relied on. + if path_exists: + shutil.copymode(path, tmp_path) + os.replace(tmp_path, path) + except BaseException: + # Never leave a stray temp file behind on failure. + if os.path.exists(tmp_path): + os.remove(tmp_path) + raise + + async def get_entity_state(self, entity_id: str) -> Dict[str, Any]: + """Get the state of a specific entity.""" + try: + _LOGGER.debug("Requesting entity state for: %s", entity_id) + state = self.hass.states.get(entity_id) + if not state: + _LOGGER.warning("Entity not found: %s", entity_id) + return {"error": f"Entity {entity_id} not found"} + + # Get area information from entity/device registry + # Wrapped in try-except to handle cases where registries aren't available (e.g., in tests) + area_id = None + area_name = None + + try: + from homeassistant.helpers import area_registry as ar + from homeassistant.helpers import device_registry as dr + from homeassistant.helpers import entity_registry as er + + entity_registry = er.async_get(self.hass) + device_registry = dr.async_get(self.hass) + area_registry = ar.async_get(self.hass) + + if entity_registry and hasattr(entity_registry, "async_get"): + # Try to find the entity in the registry + entity_entry = entity_registry.async_get(entity_id) + if entity_entry: + _LOGGER.debug("Entity %s found in registry", entity_id) + # Check if entity has a direct area assignment + if hasattr(entity_entry, "area_id") and entity_entry.area_id: + area_id = entity_entry.area_id + _LOGGER.debug( + "Entity %s has direct area assignment: %s", + entity_id, + area_id, + ) + # Otherwise check if the entity's device has an area + elif ( + hasattr(entity_entry, "device_id") + and entity_entry.device_id + and device_registry + and hasattr(device_registry, "async_get") + ): + _LOGGER.debug( + "Entity %s has device_id: %s, checking device area", + entity_id, + entity_entry.device_id, + ) + device_entry = device_registry.async_get( + entity_entry.device_id + ) + if device_entry: + if ( + hasattr(device_entry, "area_id") + and device_entry.area_id + ): + area_id = device_entry.area_id + _LOGGER.debug( + "Device %s has area: %s", + entity_entry.device_id, + area_id, + ) + else: + _LOGGER.debug( + "Device %s has no area assigned", + entity_entry.device_id, + ) + else: + _LOGGER.debug( + "Device %s not found in registry", + entity_entry.device_id, + ) + else: + _LOGGER.debug( + "Entity %s has no area_id and no device_id", entity_id + ) + else: + _LOGGER.debug( + "Entity %s not found in entity registry", entity_id + ) + else: + _LOGGER.debug("Entity registry not available for %s", entity_id) + + # Get area name from area_id + if ( + area_id + and area_registry + and hasattr(area_registry, "async_get_area") + ): + area_entry = area_registry.async_get_area(area_id) + if area_entry and hasattr(area_entry, "name"): + area_name = area_entry.name + _LOGGER.debug( + "Resolved area_id %s to area_name: %s", area_id, area_name + ) + else: + _LOGGER.debug("Could not resolve area_id %s to name", area_id) + elif area_id: + _LOGGER.debug( + "Have area_id %s but area_registry not available", area_id + ) + except Exception as e: + # Registries not available (likely in test environment) - skip area information + _LOGGER.warning( + "Exception retrieving area information for %s: %s", + entity_id, + str(e), + ) + + result = { + "entity_id": state.entity_id, + "state": state.state, + "last_changed": ( + state.last_changed.isoformat() if state.last_changed else None + ), + "friendly_name": state.attributes.get("friendly_name"), + "area_id": area_id, + "area_name": area_name, + "attributes": { + k: (v.isoformat() if hasattr(v, "isoformat") else v) + for k, v in state.attributes.items() + }, + } + _LOGGER.debug( + "Retrieved entity state for %s: area_id=%s, area_name=%s", + entity_id, + area_id, + area_name, + ) + return result + except Exception as e: + _LOGGER.exception("Error getting entity state: %s", str(e)) + return {"error": f"Error getting entity state: {str(e)}"} + + async def get_entities_by_domain(self, domain: str) -> List[Dict[str, Any]]: + """Get all entities for a specific domain.""" + try: + _LOGGER.debug("Requesting all entities for domain: %s", domain) + states = [ + state + for state in self.hass.states.async_all() + if state.entity_id.startswith(f"{domain}.") + ] + _LOGGER.debug("Found %d entities in domain %s", len(states), domain) + return [await self.get_entity_state(state.entity_id) for state in states] + except Exception as e: + _LOGGER.exception("Error getting entities by domain: %s", str(e)) + return [{"error": f"Error getting entities for domain {domain}: {str(e)}"}] + + async def get_entities_by_device_class( + self, device_class: str, domain: str = None + ) -> List[Dict[str, Any]]: + """Get all entities with a specific device_class. + + Args: + device_class: The device class to filter by (e.g., 'temperature', 'humidity', 'motion') + domain: Optional domain to restrict search (e.g., 'sensor', 'binary_sensor') + + Returns: + List of entity state dictionaries that match the device_class + """ + try: + _LOGGER.debug( + "Requesting all entities with device_class: %s (domain: %s)", + device_class, + domain or "all", + ) + matching_entities = [] + + for state in self.hass.states.async_all(): + # Filter by domain if specified + if domain and not state.entity_id.startswith(f"{domain}."): + continue + + # Check if this entity has the matching device_class + entity_device_class = state.attributes.get("device_class") + if entity_device_class == device_class: + matching_entities.append(state.entity_id) + + _LOGGER.debug( + "Found %d entities with device_class %s", + len(matching_entities), + device_class, + ) + + # Get full state information for each matching entity + return [ + await self.get_entity_state(entity_id) + for entity_id in matching_entities + ] + + except Exception as e: + _LOGGER.exception("Error getting entities by device_class: %s", str(e)) + return [ + { + "error": f"Error getting entities with device_class {device_class}: {str(e)}" + } + ] + + async def get_climate_related_entities(self) -> List[Dict[str, Any]]: + """Get all climate-related entities including climate domain and temperature/humidity sensors. + + Returns: + List of entity state dictionaries for: + - All climate.* entities (thermostats, HVAC systems) + - All sensor.* entities with device_class: temperature + - All sensor.* entities with device_class: humidity + """ + try: + _LOGGER.debug("Requesting all climate-related entities") + climate_entities = [] + + # Get all climate domain entities (thermostats, HVAC) + climate_domain = await self.get_entities_by_domain("climate") + climate_entities.extend(climate_domain) + + # Get temperature sensors + temp_sensors = await self.get_entities_by_device_class( + "temperature", "sensor" + ) + climate_entities.extend(temp_sensors) + + # Get humidity sensors + humidity_sensors = await self.get_entities_by_device_class( + "humidity", "sensor" + ) + climate_entities.extend(humidity_sensors) + + # Deduplicate by entity_id (edge case: if an entity appears in multiple categories) + seen_entity_ids = set() + unique_entities = [] + for entity in climate_entities: + entity_id = entity.get("entity_id") + if entity_id and entity_id not in seen_entity_ids: + seen_entity_ids.add(entity_id) + unique_entities.append(entity) + + _LOGGER.debug( + "Found %d total climate-related entities (deduplicated from %d)", + len(unique_entities), + len(climate_entities), + ) + return unique_entities + + except Exception as e: + _LOGGER.exception("Error getting climate-related entities: %s", str(e)) + return [{"error": f"Error getting climate-related entities: {str(e)}"}] + + async def get_entities_by_area(self, area_id: str) -> List[Dict[str, Any]]: + """Get all entities for a specific area.""" + try: + _LOGGER.debug("Requesting all entities for area: %s", area_id) + + # Get entity registry to find entities assigned to the area + from homeassistant.helpers import device_registry as dr + from homeassistant.helpers import entity_registry as er + + entity_registry = er.async_get(self.hass) + device_registry = dr.async_get(self.hass) + + entities_in_area = [] + + # Find entities assigned to the area (directly or through their device) + for entity in entity_registry.entities.values(): + # Check if entity is directly assigned to the area + if entity.area_id == area_id: + entities_in_area.append(entity.entity_id) + # Check if entity's device is assigned to the area + elif entity.device_id: + device = device_registry.devices.get(entity.device_id) + if device and device.area_id == area_id: + entities_in_area.append(entity.entity_id) + + _LOGGER.debug( + "Found %d entities in area %s", len(entities_in_area), area_id + ) + + # Get state information for each entity + result = [] + for entity_id in entities_in_area: + state_info = await self.get_entity_state(entity_id) + if not state_info.get("error"): # Only include entities that exist + result.append(state_info) + + return result + + except Exception as e: + _LOGGER.exception("Error getting entities by area: %s", str(e)) + return [{"error": f"Error getting entities for area {area_id}: {str(e)}"}] + + async def get_entities(self, area_id=None, area_ids=None) -> List[Dict[str, Any]]: + """Get entities by area(s) - flexible method that supports single area or multiple areas.""" + try: + # Handle different parameter formats + areas_to_process = [] + + if area_ids: + # Multiple areas provided + if isinstance(area_ids, list): + areas_to_process = area_ids + else: + areas_to_process = [area_ids] + elif area_id: + # Single area provided + if isinstance(area_id, list): + areas_to_process = area_id + else: + areas_to_process = [area_id] + else: + return [{"error": "No area_id or area_ids provided"}] + + _LOGGER.debug("Requesting entities for areas: %s", areas_to_process) + + all_entities = [] + for area in areas_to_process: + entities_in_area = await self.get_entities_by_area(area) + all_entities.extend(entities_in_area) + + # Remove duplicates based on entity_id + seen_entities = set() + unique_entities = [] + for entity in all_entities: + if isinstance(entity, dict) and "entity_id" in entity: + if entity["entity_id"] not in seen_entities: + seen_entities.add(entity["entity_id"]) + unique_entities.append(entity) + else: + unique_entities.append(entity) # Keep error messages + + _LOGGER.debug( + "Found %d unique entities across %d areas", + len(unique_entities), + len(areas_to_process), + ) + return unique_entities + + except Exception as e: + _LOGGER.exception("Error getting entities: %s", str(e)) + return [{"error": f"Error getting entities: {str(e)}"}] + + async def get_calendar_events( + self, entity_id: Optional[str] = None + ) -> List[Dict[str, Any]]: + """Get calendar events, optionally filtered by entity_id.""" + try: + if entity_id: + _LOGGER.debug( + "Requesting calendar events for specific entity: %s", entity_id + ) + return [await self.get_entity_state(entity_id)] + + _LOGGER.debug("Requesting all calendar events") + return await self.get_entities_by_domain("calendar") + except Exception as e: + _LOGGER.exception("Error getting calendar events: %s", str(e)) + return [{"error": f"Error getting calendar events: {str(e)}"}] + + async def get_automations(self) -> List[Dict[str, Any]]: + """Get all automations.""" + try: + _LOGGER.debug("Requesting all automations") + return await self.get_entities_by_domain("automation") + except Exception as e: + _LOGGER.exception("Error getting automations: %s", str(e)) + return [{"error": f"Error getting automations: {str(e)}"}] + + async def get_entity_registry(self) -> List[Dict]: + """Get entity registry entries with device_class and other metadata. + + Area information is resolved from the entity or its device. + """ + _LOGGER.debug("Requesting all entity registry entries") + try: + from homeassistant.helpers import area_registry as ar + from homeassistant.helpers import device_registry as dr + from homeassistant.helpers import entity_registry as er + + entity_registry = er.async_get(self.hass) + if not entity_registry: + return [] + + device_registry = dr.async_get(self.hass) + area_registry = ar.async_get(self.hass) + + result = [] + for entry in entity_registry.entities.values(): + # Get the current state to access device_class and other attributes + state = self.hass.states.get(entry.entity_id) + device_class = state.attributes.get("device_class") if state else None + state_class = state.attributes.get("state_class") if state else None + unit_of_measurement = ( + state.attributes.get("unit_of_measurement") if state else None + ) + + # Resolve area_id and area_name + # First check entity's direct area assignment + area_id = entry.area_id + area_name = None + + # If entity doesn't have area, check device's area + if not area_id and entry.device_id and device_registry: + device_entry = device_registry.async_get(entry.device_id) + if device_entry and hasattr(device_entry, "area_id"): + area_id = device_entry.area_id + + # Resolve area_name from area_id + if area_id and area_registry: + area_entry = area_registry.async_get_area(area_id) + if area_entry and hasattr(area_entry, "name"): + area_name = area_entry.name + + result.append( + { + "entity_id": entry.entity_id, + "device_id": entry.device_id, + "platform": entry.platform, + "disabled": entry.disabled, + "area_id": area_id, + "area_name": area_name, + "original_name": entry.original_name, + "unique_id": entry.unique_id, + "device_class": device_class, + "state_class": state_class, + "unit_of_measurement": unit_of_measurement, + } + ) + + return result + except Exception as e: + _LOGGER.exception("Error getting entity registry entries: %s", str(e)) + return [{"error": f"Error getting entity registry entries: {str(e)}"}] + + async def get_device_registry(self) -> List[Dict]: + """Get device registry entries""" + _LOGGER.debug("Requesting all device registry entries") + try: + from homeassistant.helpers import device_registry as dr + + registry = dr.async_get(self.hass) + if not registry: + return [] + return [ + { + "id": device.id, + "name": device.name, + "model": device.model, + "manufacturer": device.manufacturer, + "sw_version": device.sw_version, + "hw_version": device.hw_version, + "connections": ( + list(device.connections) if device.connections else [] + ), + "identifiers": ( + list(device.identifiers) if device.identifiers else [] + ), + "area_id": device.area_id, + "disabled": device.disabled_by is not None, + "entry_type": ( + device.entry_type.value if device.entry_type else None + ), + "name_by_user": device.name_by_user, + } + for device in registry.devices.values() + ] + except Exception as e: + _LOGGER.exception("Error getting device registry entries: %s", str(e)) + return [{"error": f"Error getting device registry entries: {str(e)}"}] + + async def get_history(self, entity_id: str, hours: int = 24) -> List[Dict]: + """Get historical state changes for an entity""" + _LOGGER.debug("Requesting historical state changes for entity: %s", entity_id) + try: + from homeassistant.components.recorder.history import get_significant_states + + now = dt_util.utcnow() + start = now - timedelta(hours=hours) + + # Get history using the recorder history module + history_data = await self.hass.async_add_executor_job( + get_significant_states, + self.hass, + start, + now, + [entity_id], + ) + + # Convert to serializable format + result = [] + for entity_id_key, states in history_data.items(): + for state in states: + # Skip if it's a dict (mypy type narrowing) + if isinstance(state, dict): + continue + result.append( + { + "entity_id": state.entity_id, + "state": state.state, + "last_changed": state.last_changed.isoformat(), + "last_updated": state.last_updated.isoformat(), + "attributes": dict(state.attributes), + } + ) + return result + except Exception as e: + _LOGGER.exception("Error getting history: %s", str(e)) + return [{"error": f"Error getting history: {str(e)}"}] + + async def get_area_registry(self) -> Dict[str, Any]: + """Get area registry information""" + _LOGGER.debug("Get area registry information") + try: + from homeassistant.helpers import area_registry as ar + + registry = ar.async_get(self.hass) + if not registry: + return {} + + result = {} + for area in registry.areas.values(): + result[area.id] = { + "name": area.name, + "normalized_name": area.normalized_name, + "picture": area.picture, + "icon": area.icon, + "floor_id": area.floor_id, + "labels": list(area.labels) if area.labels else [], + } + return result + except Exception as e: + _LOGGER.exception("Error getting area registry: %s", str(e)) + return {"error": f"Error getting area registry: {str(e)}"} + + async def get_person_data(self) -> List[Dict]: + """Get person tracking information""" + _LOGGER.debug("Requesting person tracking information") + try: + result = [] + for state in self.hass.states.async_all("person"): + result.append( + { + "entity_id": state.entity_id, + "name": state.attributes.get("friendly_name", state.entity_id), + "state": state.state, + "latitude": state.attributes.get("latitude"), + "longitude": state.attributes.get("longitude"), + "source": state.attributes.get("source"), + "gps_accuracy": state.attributes.get("gps_accuracy"), + "last_changed": ( + state.last_changed.isoformat() + if state.last_changed + else None + ), + } + ) + return result + except Exception as e: + _LOGGER.exception("Error getting person tracking information: %s", str(e)) + return [{"error": f"Error getting person tracking information: {str(e)}"}] + + async def get_statistics(self, entity_id: str) -> Dict: + """Get statistics for an entity""" + _LOGGER.debug("Requesting statistics for entity: %s", entity_id) + try: + from homeassistant.components import recorder + + # Check if recorder is available + if not self.hass.data.get(recorder.DATA_INSTANCE): + return {"error": "Recorder component is not available"} + + # from homeassistant.components.recorder.statistics import get_latest_short_term_statistics + import homeassistant.components.recorder.statistics as stats_module + + # Get latest statistics + stats = await self.hass.async_add_executor_job( + # get_latest_short_term_statistics, + stats_module.get_last_short_term_statistics, + self.hass, + 1, + entity_id, + True, + set(), + ) + + if entity_id in stats: + stat_data = stats[entity_id][0] if stats[entity_id] else {} + return { + "entity_id": entity_id, + "start": stat_data.get("start"), + "mean": stat_data.get("mean"), + "min": stat_data.get("min"), + "max": stat_data.get("max"), + "last_reset": stat_data.get("last_reset"), + "state": stat_data.get("state"), + "sum": stat_data.get("sum"), + } + else: + return {"error": f"No statistics available for entity {entity_id}"} + except Exception as e: + _LOGGER.exception("Error getting statistics: %s", str(e)) + return {"error": f"Error getting statistics: {str(e)}"} + + async def get_scenes(self) -> List[Dict]: + """Get scene configurations""" + _LOGGER.debug("Requesting scene configurations") + try: + result = [] + for state in self.hass.states.async_all("scene"): + result.append( + { + "entity_id": state.entity_id, + "name": state.attributes.get("friendly_name", state.entity_id), + "last_activated": state.attributes.get("last_activated"), + "icon": state.attributes.get("icon"), + "last_changed": ( + state.last_changed.isoformat() + if state.last_changed + else None + ), + } + ) + return result + except Exception as e: + _LOGGER.exception("Error getting scene configurations: %s", str(e)) + return [{"error": f"Error getting scene configurations: {str(e)}"}] + + async def get_weather_data(self) -> Dict[str, Any]: + """Get weather data from any available weather entity in the system.""" + try: + # Find all weather entities + weather_entities = [ + state + for state in self.hass.states.async_all() + if state.domain == "weather" + ] + + if not weather_entities: + return { + "error": "No weather entities found in the system. Please add a weather integration." + } + + # Use the first available weather entity + state = weather_entities[0] + _LOGGER.debug("Using weather entity: %s", state.entity_id) + + # Get all available attributes + all_attributes = state.attributes + _LOGGER.debug( + "Available weather attributes: %s", json.dumps(all_attributes) + ) + + # Get forecast data + forecast = all_attributes.get("forecast", []) + + # Process forecast data + processed_forecast = [] + for day in forecast: + forecast_entry = { + "datetime": day.get("datetime"), + "temperature": day.get("temperature"), + "condition": day.get("condition"), + "precipitation": day.get("precipitation"), + "precipitation_probability": day.get("precipitation_probability"), + "humidity": day.get("humidity"), + "wind_speed": day.get("wind_speed"), + "wind_bearing": day.get("wind_bearing"), + } + # Only add entries that have at least some data + if any(v is not None for v in forecast_entry.values()): + processed_forecast.append(forecast_entry) + + # Get current weather data + current = { + "entity_id": state.entity_id, + "temperature": all_attributes.get("temperature"), + "humidity": all_attributes.get("humidity"), + "pressure": all_attributes.get("pressure"), + "wind_speed": all_attributes.get("wind_speed"), + "wind_bearing": all_attributes.get("wind_bearing"), + "condition": state.state, + "forecast_available": len(processed_forecast) > 0, + } + + # Log the processed data for debugging + _LOGGER.debug( + "Processed weather data: %s", + json.dumps( + {"current": current, "forecast_count": len(processed_forecast)} + ), + ) + + return {"current": current, "forecast": processed_forecast} + except Exception as e: + _LOGGER.exception("Error getting weather data: %s", str(e)) + return {"error": f"Error getting weather data: {str(e)}"} + + async def create_automation( + self, automation_config: Dict[str, Any] + ) -> Dict[str, Any]: + """Create a new automation with validation and sanitization.""" + try: + _LOGGER.debug( + "Creating automation with config: %s", json.dumps(automation_config) + ) + + # Validate required fields + if not all( + key in automation_config for key in ["alias", "trigger", "action"] + ): + return {"error": "Missing required fields in automation configuration"} + + # Sanitize configuration + sanitized_config = self._sanitize_automation_config(automation_config) + + # Make sure the core building blocks survived sanitization. A + # malformed trigger/action (e.g. not a list or mapping) is dropped + # by the sanitizer, so validate here and fail with a clear message + # instead of raising KeyError further down. + if not sanitized_config.get("alias"): + return {"error": "Automation must include a non-empty alias"} + if not sanitized_config.get("trigger"): + return {"error": "Automation must include at least one trigger"} + if not sanitized_config.get("action"): + return {"error": "Automation must include at least one action"} + + # Generate a unique ID for the automation + automation_id = f"ai_agent_auto_{int(time.time() * 1000)}" + + # Create the automation entry + automation_entry = { + "id": automation_id, + "alias": sanitized_config["alias"], + "description": sanitized_config.get("description", ""), + "trigger": sanitized_config["trigger"], + "condition": sanitized_config.get("condition", []), + "action": sanitized_config["action"], + "mode": sanitized_config.get("mode", "single"), + } + + # Read current automations.yaml using async executor + automations_path = self.hass.config.path("automations.yaml") + try: + current_automations = await self.hass.async_add_executor_job( + self._read_automations_file, automations_path + ) + except FileNotFoundError: + current_automations = [] + + # Check for duplicate automation names + if any( + auto.get("alias") == automation_entry["alias"] + for auto in current_automations + ): + return { + "error": f"An automation with the name '{automation_entry['alias']}' already exists" + } + + # Append new automation + current_automations.append(automation_entry) + + # Write back to file safely: backs up the previous file, validates + # the YAML round-trips, preserves unicode/key order, and replaces + # the file atomically so a bad write can never corrupt it. + await self.hass.async_add_executor_job( + self._write_automations_file, + automations_path, + current_automations, + ) + + # Reload automations + await self.hass.services.async_call("automation", "reload") + + # Clear automation-related caches + self._cache.clear() + + return { + "success": True, + "message": f"Automation '{automation_entry['alias']}' created successfully", + } + + except Exception as e: + _LOGGER.exception("Error creating automation: %s", str(e)) + return {"error": f"Error creating automation: {str(e)}"} + + async def get_dashboards(self) -> List[Dict[str, Any]]: + """Get list of all dashboards.""" + try: + _LOGGER.debug("Requesting all dashboards") + + # Get dashboards via WebSocket API + ws_api = self.hass.data.get("websocket_api") + if not ws_api: + return [{"error": "WebSocket API not available"}] + + # Use the lovelace service to get dashboards + try: + from homeassistant.components.lovelace import DOMAIN as LOVELACE_DOMAIN + + # Get lovelace data using property access (required for HA 2026.2+) + # lovelace_data is a LovelaceData dataclass with a 'dashboards' attribute + lovelace_data = self.hass.data.get(LOVELACE_DOMAIN) + if lovelace_data is None: + return [{"error": "Lovelace not available"}] + + # Safety check for dashboards attribute (backward compatibility) + if not hasattr(lovelace_data, "dashboards"): + return [{"error": "Lovelace dashboards not available"}] + + # Use property access instead of dictionary access + dashboards = lovelace_data.dashboards + + # Get YAML dashboard configs for metadata (title, icon, etc.) + # yaml_dashboards contains the configuration with metadata + yaml_configs = getattr(lovelace_data, "yaml_dashboards", {}) or {} + + dashboard_list = [] + + # Iterate over all dashboards (None key = default dashboard) + for url_path, dashboard_obj in dashboards.items(): + # Try to get metadata from yaml_dashboards first + yaml_config = yaml_configs.get(url_path, {}) or {} + + # Get title - check yaml config, then use defaults + title = yaml_config.get("title") + if not title: + title = ( + "Overview" + if url_path is None + else (url_path or "Dashboard") + ) + + # Get icon - check yaml config, then use defaults + icon = yaml_config.get("icon") + if not icon: + icon = "mdi:home" if url_path is None else "mdi:view-dashboard" + + # Get sidebar/admin settings from yaml config or defaults + show_in_sidebar = yaml_config.get("show_in_sidebar", True) + require_admin = yaml_config.get("require_admin", False) + + dashboard_list.append( + { + "url_path": url_path, + "title": title, + "icon": icon, + "show_in_sidebar": show_in_sidebar, + "require_admin": require_admin, + } + ) + + _LOGGER.debug("Found %d dashboards", len(dashboard_list)) + return dashboard_list + + except Exception as e: + _LOGGER.warning("Could not get dashboards via lovelace: %s", str(e)) + return [{"error": f"Could not retrieve dashboards: {str(e)}"}] + + except Exception as e: + _LOGGER.exception("Error getting dashboards: %s", str(e)) + return [{"error": f"Error getting dashboards: {str(e)}"}] + + async def get_dashboard_config( + self, dashboard_url: Optional[str] = None + ) -> Dict[str, Any]: + """Get configuration of a specific dashboard.""" + try: + _LOGGER.debug( + "Requesting dashboard config for: %s", dashboard_url or "default" + ) + + # Get dashboard configuration + try: + from homeassistant.components.lovelace import DOMAIN as LOVELACE_DOMAIN + + # Get lovelace data using property access (required for HA 2026.2+) + lovelace_data = self.hass.data.get(LOVELACE_DOMAIN) + if lovelace_data is None: + return {"error": "Lovelace not available"} + + # Safety check for dashboards attribute (backward compatibility) + if not hasattr(lovelace_data, "dashboards"): + return {"error": "Lovelace dashboards not available"} + + # Use property access instead of dictionary access + # The dashboards dict uses None as key for the default dashboard + dashboards = lovelace_data.dashboards + + # Get the dashboard (None key = default dashboard) + dashboard_key = None if dashboard_url is None else dashboard_url + if dashboard_key in dashboards: + dashboard = dashboards[dashboard_key] + config = await dashboard.async_get_info() + return dict(config) if config else {"error": "No dashboard config"} + else: + if dashboard_url is None: + return {"error": "Default dashboard not found"} + else: + return {"error": f"Dashboard '{dashboard_url}' not found"} + + except Exception as e: + _LOGGER.warning("Could not get dashboard config: %s", str(e)) + return {"error": f"Could not retrieve dashboard config: {str(e)}"} + + except Exception as e: + _LOGGER.exception("Error getting dashboard config: %s", str(e)) + return {"error": f"Error getting dashboard config: {str(e)}"} + + async def create_dashboard( + self, dashboard_config: Dict[str, Any] + ) -> Dict[str, Any]: + """Create a new dashboard using Home Assistant's Lovelace WebSocket API.""" + try: + _LOGGER.debug( + "Creating dashboard with config: %s", + json.dumps(dashboard_config, default=str), + ) + + # Validate required fields + if not dashboard_config.get("title"): + return {"error": "Dashboard title is required"} + + if not dashboard_config.get("url_path"): + return {"error": "Dashboard URL path is required"} + + # Sanitize the URL path + url_path = ( + dashboard_config["url_path"].lower().replace(" ", "-").replace("_", "-") + ) + + # Prepare dashboard configuration for Lovelace + dashboard_data = { + "title": dashboard_config["title"], + "icon": dashboard_config.get("icon", "mdi:view-dashboard"), + "show_in_sidebar": dashboard_config.get("show_in_sidebar", True), + "require_admin": dashboard_config.get("require_admin", False), + "views": dashboard_config.get("views", []), + } + + try: + # Create dashboard file directly - this is the most reliable method + import os + + import yaml + + # Create the dashboard YAML file + lovelace_config_file = self.hass.config.path( + f"ui-lovelace-{url_path}.yaml" + ) + + # Use async_add_executor_job to perform file I/O asynchronously + def write_dashboard_file(): + with open(lovelace_config_file, "w") as f: + yaml.dump( + dashboard_data, + f, + default_flow_style=False, + allow_unicode=True, + ) + + await self.hass.async_add_executor_job(write_dashboard_file) + + _LOGGER.info( + "Successfully created dashboard file: %s", lovelace_config_file + ) + + # Now update configuration.yaml + try: + config_file = self.hass.config.path("configuration.yaml") + dashboard_config_entry = { + url_path: { + "mode": "yaml", + "title": dashboard_config["title"], + "icon": dashboard_config.get("icon", "mdi:view-dashboard"), + "show_in_sidebar": dashboard_config.get( + "show_in_sidebar", True + ), + "filename": f"ui-lovelace-{url_path}.yaml", + } + } + + def update_config_file(): + try: + with open(config_file, "r") as f: + content = f.read() + + # Dashboard configuration to add + dashboard_yaml = f""" {url_path}: + mode: yaml + title: {dashboard_config['title']} + icon: {dashboard_config.get('icon', 'mdi:view-dashboard')} + show_in_sidebar: {str(dashboard_config.get('show_in_sidebar', True)).lower()} + filename: ui-lovelace-{url_path}.yaml""" + + # Check if lovelace section exists + if "lovelace:" not in content: + # Add complete lovelace section at the end + lovelace_section = f""" +# Lovelace dashboards configuration added by AI Agent +lovelace: + dashboards: +{dashboard_yaml} +""" + with open(config_file, "a") as f: + f.write(lovelace_section) + return True + + # If lovelace exists, check for dashboards section + lines = content.split("\n") + new_lines = [] + dashboard_added = False + in_lovelace = False + lovelace_indent = 0 + + for i, line in enumerate(lines): + new_lines.append(line) + + # Detect lovelace section + if ( + line.strip() == "lovelace:" + or line.strip().startswith("lovelace:") + ): + in_lovelace = True + lovelace_indent = len(line) - len(line.lstrip()) + continue + + # If we're in lovelace section + if in_lovelace: + current_indent = ( + len(line) - len(line.lstrip()) + if line.strip() + else 0 + ) + + # If we hit another top-level section, we're out of lovelace + if ( + line.strip() + and current_indent <= lovelace_indent + and not line.startswith(" ") + ): + if line.strip() != "lovelace:": + in_lovelace = False + + # Look for dashboards section + if in_lovelace and "dashboards:" in line: + # Add our dashboard after the dashboards: line + new_lines.append(dashboard_yaml) + dashboard_added = True + in_lovelace = False # We're done + break + + # If we found lovelace but no dashboards section, add it + if not dashboard_added and "lovelace:" in content: + # Find lovelace section and add dashboards + new_lines = [] + for line in lines: + new_lines.append(line) + if ( + line.strip() == "lovelace:" + or line.strip().startswith("lovelace:") + ): + # Add dashboards section right after lovelace + new_lines.append(" dashboards:") + new_lines.append(dashboard_yaml) + dashboard_added = True + break + + if dashboard_added: + with open(config_file, "w") as f: + f.write("\n".join(new_lines)) + return True + else: + # Last resort: append to end of file + with open(config_file, "a") as f: + f.write(f"\n dashboards:\n{dashboard_yaml}\n") + return True + + except Exception as e: + _LOGGER.error( + "Failed to update configuration.yaml: %s", str(e) + ) + # Fallback to simple append method + try: + with open(config_file, "r") as f: + content = f.read() + + # Check if lovelace section exists + if "lovelace:" not in content: + # Add lovelace section + lovelace_config = f""" +# Lovelace dashboards +lovelace: + dashboards: + {url_path}: + mode: yaml + title: {dashboard_config['title']} + icon: {dashboard_config.get('icon', 'mdi:view-dashboard')} + show_in_sidebar: {str(dashboard_config.get('show_in_sidebar', True)).lower()} + filename: ui-lovelace-{url_path}.yaml +""" + with open(config_file, "a") as f: + f.write(lovelace_config) + else: + # Add to existing lovelace section (simple approach) + dashboard_entry = f""" {url_path}: + mode: yaml + title: {dashboard_config['title']} + icon: {dashboard_config.get('icon', 'mdi:view-dashboard')} + show_in_sidebar: {str(dashboard_config.get('show_in_sidebar', True)).lower()} + filename: ui-lovelace-{url_path}.yaml +""" + # Find the dashboards section and add to it + lines = content.split("\n") + new_lines = [] + in_dashboards = False + dashboards_indented = False + + for line in lines: + new_lines.append(line) + if ( + "dashboards:" in line + and "lovelace" + in content[: content.find(line)] + ): + in_dashboards = True + # Add our dashboard entry after dashboards: + new_lines.append(dashboard_entry.rstrip()) + in_dashboards = False + + # If we couldn't find dashboards section, add it under lovelace + if not any("dashboards:" in line for line in lines): + for i, line in enumerate(new_lines): + if line.strip() == "lovelace:": + new_lines.insert(i + 1, " dashboards:") + new_lines.insert( + i + 2, dashboard_entry.rstrip() + ) + break + + with open(config_file, "w") as f: + f.write("\n".join(new_lines)) + + return True + except Exception as fallback_error: + _LOGGER.error( + "Fallback config update also failed: %s", + str(fallback_error), + ) + return False + + config_updated = await self.hass.async_add_executor_job( + update_config_file + ) + + if config_updated: + success_message = f"""Dashboard '{dashboard_config['title']}' created successfully! + +✅ Dashboard file created: ui-lovelace-{url_path}.yaml +✅ Configuration.yaml updated automatically + +🔄 Please restart Home Assistant to see your new dashboard in the sidebar.""" + + return { + "success": True, + "message": success_message, + "url_path": url_path, + "restart_required": True, + } + else: + # Config update failed, provide manual instructions + config_instructions = f"""Dashboard '{dashboard_config['title']}' created successfully! + +✅ Dashboard file created: ui-lovelace-{url_path}.yaml +⚠️ Could not automatically update configuration.yaml + +Please manually add this to your configuration.yaml: + +lovelace: + dashboards: + {url_path}: + mode: yaml + title: {dashboard_config['title']} + icon: {dashboard_config.get('icon', 'mdi:view-dashboard')} + show_in_sidebar: {str(dashboard_config.get('show_in_sidebar', True)).lower()} + filename: ui-lovelace-{url_path}.yaml + +Then restart Home Assistant to see your new dashboard in the sidebar.""" + + return { + "success": True, + "message": config_instructions, + "url_path": url_path, + "restart_required": True, + } + + except Exception as config_error: + _LOGGER.error( + "Error updating configuration.yaml: %s", str(config_error) + ) + # Provide manual instructions as fallback + config_instructions = f"""Dashboard '{dashboard_config['title']}' created successfully! + +✅ Dashboard file created: ui-lovelace-{url_path}.yaml +⚠️ Could not automatically update configuration.yaml + +Please manually add this to your configuration.yaml: + +lovelace: + dashboards: + {url_path}: + mode: yaml + title: {dashboard_config['title']} + icon: {dashboard_config.get('icon', 'mdi:view-dashboard')} + show_in_sidebar: {str(dashboard_config.get('show_in_sidebar', True)).lower()} + filename: ui-lovelace-{url_path}.yaml + +Then restart Home Assistant to see your new dashboard in the sidebar.""" + + return { + "success": True, + "message": config_instructions, + "url_path": url_path, + "restart_required": True, + } + + except Exception as e: + _LOGGER.error("Failed to create dashboard file: %s", str(e)) + return {"error": f"Failed to create dashboard file: {str(e)}"} + + except Exception as e: + _LOGGER.exception("Error creating dashboard: %s", str(e)) + return {"error": f"Error creating dashboard: {str(e)}"} + + async def update_dashboard( + self, dashboard_url: str, dashboard_config: Dict[str, Any] + ) -> Dict[str, Any]: + """Update an existing dashboard using Home Assistant's Lovelace WebSocket API.""" + try: + _LOGGER.debug( + "Updating dashboard %s with config: %s", + dashboard_url, + json.dumps(dashboard_config, default=str), + ) + + # Prepare updated dashboard configuration + dashboard_data = { + "title": dashboard_config.get("title", "Updated Dashboard"), + "icon": dashboard_config.get("icon", "mdi:view-dashboard"), + "show_in_sidebar": dashboard_config.get("show_in_sidebar", True), + "require_admin": dashboard_config.get("require_admin", False), + "views": dashboard_config.get("views", []), + } + + try: + # Update dashboard file directly + import os + + import yaml + + # Try updating the YAML file + dashboard_file = self.hass.config.path( + f"ui-lovelace-{dashboard_url}.yaml" + ) + + # Check if file exists asynchronously + def check_file_exists(): + return os.path.exists(dashboard_file) + + file_exists = await self.hass.async_add_executor_job(check_file_exists) + + if not file_exists: + dashboard_file = self.hass.config.path( + f"dashboards/{dashboard_url}.yaml" + ) + file_exists = await self.hass.async_add_executor_job( + lambda: os.path.exists(dashboard_file) + ) + + if file_exists: + # Use async_add_executor_job to perform file I/O asynchronously + def update_dashboard_file(): + with open(dashboard_file, "w") as f: + yaml.dump( + dashboard_data, + f, + default_flow_style=False, + allow_unicode=True, + ) + + await self.hass.async_add_executor_job(update_dashboard_file) + + _LOGGER.info( + "Successfully updated dashboard file: %s", dashboard_file + ) + return { + "success": True, + "message": f"Dashboard '{dashboard_url}' updated successfully!", + } + else: + return {"error": f"Dashboard file for '{dashboard_url}' not found"} + + except Exception as e: + _LOGGER.error("Failed to update dashboard file: %s", str(e)) + return {"error": f"Failed to update dashboard file: {str(e)}"} + + except Exception as e: + _LOGGER.exception("Error updating dashboard: %s", str(e)) + return {"error": f"Error updating dashboard: {str(e)}"} + + async def process_query( + self, user_query: str, provider: Optional[str] = None, debug: bool = False + ) -> Dict[str, Any]: + """Process a user query with input validation and rate limiting.""" + try: + if not user_query or not isinstance(user_query, str): + return {"success": False, "error": "Invalid query format"} + + # Get the correct configuration for the requested provider + if provider and provider in self.hass.data[DOMAIN]["configs"]: + config = self.hass.data[DOMAIN]["configs"][provider] + else: + config = self.config + + _LOGGER.debug(f"Processing query with provider: {provider}") + # Log sanitized config (masks all tokens/keys for security) + _LOGGER.debug( + f"Using config: {json.dumps(sanitize_for_logging(config), default=str)}" + ) + + selected_provider = provider or config.get("ai_provider", "llama") + models_config = config.get("models", {}) + + provider_config = { + "openai": { + "token_key": "openai_token", # nosec B105 - dict-key field name, not a credential value (false positive) + "model": models_config.get("openai", "gpt-3.5-turbo"), + "client_class": OpenAIClient, + }, + "gemini": { + "token_key": "gemini_token", # nosec B105 - dict-key field name, not a credential value (false positive) + "model": models_config.get("gemini", "gemini-1.5-flash"), + "client_class": GeminiClient, + }, + "openrouter": { + "token_key": "openrouter_token", # nosec B105 - dict-key field name, not a credential value (false positive) + "model": models_config.get("openrouter", "openai/gpt-4o"), + "client_class": OpenRouterClient, + }, + "llama": { + "token_key": "llama_token", # nosec B105 - dict-key field name, not a credential value (false positive) + "model": models_config.get( + "llama", "Llama-4-Maverick-17B-128E-Instruct-FP8" + ), + "client_class": LlamaClient, + }, + "anthropic": { + "token_key": "anthropic_token", # nosec B105 - dict-key field name, not a credential value (false positive) + "model": models_config.get( + "anthropic", "claude-sonnet-4-5-20250929" + ), + "client_class": AnthropicClient, + }, + "alter": { + "token_key": "alter_token", # nosec B105 - dict-key field name, not a credential value (false positive) + "model": models_config.get("alter", ""), + "client_class": AlterClient, + }, + "zai": { + "token_key": "zai_token", # nosec B105 - dict-key field name, not a credential value (false positive) + "model": models_config.get("zai", ""), + "client_class": ZaiClient, + }, + "local_ollama": { + "token_key": "local_ollama_url", # nosec B105 - dict-key field name, not a credential value (false positive) + "model": models_config.get("local_ollama", ""), + "client_class": LocalOllamaClient, + }, + "openai_compatible": { + "token_key": "openai_compatible_url", # nosec B105 - dict-key field name, not a credential value (false positive) + "model": models_config.get("openai_compatible", ""), + "client_class": OpenaiCompatibleClient, + }, + } + + # Validate provider and get configuration + if selected_provider not in provider_config: + _LOGGER.warning( + f"Invalid provider {selected_provider}, falling back to llama" + ) + selected_provider = "llama" + + provider_settings = provider_config[selected_provider] + token = self.config.get(provider_settings["token_key"]) + + def _with_debug(result: Dict[str, Any]) -> Dict[str, Any]: + """Attach a sanitized trace when UI requests debug info.""" + if debug and "debug" not in result: + result["debug"] = self._build_debug_trace( + selected_provider, + provider_settings, + config.get("zai_endpoint", "general"), + ) + return result + + # Validate token/URL + if not token: + is_url_provider = selected_provider in ( + "local_ollama", + "openai_compatible", + ) + error_msg = f"No {'URL' if is_url_provider else 'token'} configured for provider {selected_provider}" + _LOGGER.error(error_msg) + return _with_debug({"success": False, "error": error_msg}) + + # Initialize client + try: + if selected_provider == "zai": + # ZaiClient takes (token, model, endpoint_type) + endpoint_type = config.get("zai_endpoint", "general") + self.ai_client = provider_settings["client_class"]( + token=token, + model=provider_settings["model"], + endpoint_type=endpoint_type, + ) + _LOGGER.debug( + f"Initialized {selected_provider} client with model {provider_settings['model']}, endpoint_type {endpoint_type}" + ) + elif selected_provider in ("local_ollama", "openai_compatible"): + # LocalOllamaClient and OpenaiCompatibleClient take (url, model) + if selected_provider == "local_ollama": + # Support legacy local_url + url = token or config.get("local_url") + self.ai_client = provider_settings["client_class"]( + url, provider_settings["model"] + ) + else: + url = token + api_key = config.get("openai_compatible_api_key", "") or "" + self.ai_client = provider_settings["client_class"]( + url, provider_settings["model"], api_key or None + ) + _LOGGER.debug( + f"Initialized {selected_provider} client with model {provider_settings['model']}" + ) + else: + # Other clients take (token, model) + self.ai_client = provider_settings["client_class"]( + token=token, model=provider_settings["model"] + ) + _LOGGER.debug( + f"Initialized {selected_provider} client with model {provider_settings['model']}" + ) + except Exception as e: + error_msg = f"Error initializing {selected_provider} client: {str(e)}" + _LOGGER.error(error_msg) + return _with_debug({"success": False, "error": error_msg}) + + # Process the query with rate limiting and retries + if not self._check_rate_limit(): + return _with_debug( + { + "success": False, + "error": "Rate limit exceeded. Please wait before trying again.", + } + ) + + # Sanitize user input + user_query = user_query.strip()[:1000] # Limit length and trim whitespace + + _LOGGER.debug("Processing new query: %s", user_query) + + # Check cache for identical query + cache_key = f"query_{hash(user_query)}_{provider}_{debug}" + cached_result = self._get_cached_data(cache_key) + if cached_result: + return ( + dict(cached_result) + if isinstance(cached_result, dict) + else {"error": "Invalid cached result"} + ) + + # Add system message to conversation if it's the first message + if not self.conversation_history: + _LOGGER.debug("Adding system message to new conversation") + self.conversation_history.append(self.system_prompt) + + # Remember where this query started so a failure can be rolled + # back instead of leaving dangling/oversized messages that poison + # every subsequent query (issue #80). + history_checkpoint = len(self.conversation_history) + + # Add user query to conversation + self.conversation_history.append({"role": "user", "content": user_query}) + _LOGGER.debug("Added user query to conversation history") + + # Prevent infinite loops while leaving room for multi-step + # discovery: with data responses capped (issue #80) the model may + # need several narrower data requests instead of one big dump. + max_iterations = 8 + iteration = 0 + + while iteration < max_iterations: + iteration += 1 + _LOGGER.debug(f"Processing iteration {iteration} of {max_iterations}") + + try: + # Get AI response + _LOGGER.debug("Requesting response from AI provider") + response = await self._get_ai_response() + _LOGGER.debug("Received response from AI provider: %s", response) + + try: + # Try to parse the response as JSON with simplified approach + response_clean = response.strip() + + # Remove potential BOM and other invisible characters + import codecs + + if response_clean.startswith(codecs.BOM_UTF8.decode("utf-8")): + response_clean = response_clean[1:] + + # Remove other common invisible characters + invisible_chars = [ + "\ufeff", + "\u200b", + "\u200c", + "\u200d", + "\u2060", + ] + for char in invisible_chars: + response_clean = response_clean.replace(char, "") + + _LOGGER.debug( + "Cleaned response length: %d", len(response_clean) + ) + _LOGGER.debug( + "Cleaned response first 100 chars: %s", response_clean[:100] + ) + _LOGGER.debug( + "Cleaned response last 100 chars: %s", response_clean[-100:] + ) + + # Simple strategy: try to parse the cleaned response directly + response_data = None + try: + _LOGGER.debug("Attempting basic JSON parse...") + response_data = json.loads(response_clean) + _LOGGER.debug("Basic JSON parse succeeded!") + except json.JSONDecodeError as e: + _LOGGER.warning("Basic JSON parse failed: %s", str(e)) + _LOGGER.debug("JSON error position: %d", e.pos) + if e.pos < len(response_clean): + _LOGGER.debug( + "Character at error position: %s (ord: %d)", + repr(response_clean[e.pos]), + ord(response_clean[e.pos]), + ) + _LOGGER.debug( + "Context around error: %s", + repr( + response_clean[max(0, e.pos - 10) : e.pos + 10] + ), + ) + + # Fallback: try to extract JSON by finding the first { and last } + json_start = response_clean.find("{") + json_end = response_clean.rfind("}") + + if ( + json_start != -1 + and json_end != -1 + and json_end > json_start + ): + json_part = response_clean[json_start : json_end + 1] + _LOGGER.debug( + "Trying fallback extraction from pos %d to %d", + json_start, + json_end, + ) + _LOGGER.debug("Extracted JSON: %s", json_part[:200]) + + try: + response_data = json.loads(json_part) + _LOGGER.debug("Fallback JSON extraction succeeded!") + except json.JSONDecodeError as e2: + _LOGGER.warning( + "Fallback JSON extraction also failed: %s", + str(e2), + ) + raise e # Re-raise the original error + else: + _LOGGER.warning( + "Could not find JSON boundaries in response" + ) + raise e # Re-raise the original error + + if response_data is None: + raise json.JSONDecodeError( + "All parsing strategies failed", response_clean, 0 + ) + + _LOGGER.debug("Successfully parsed JSON response") + _LOGGER.debug( + "Parsed response type: %s", + response_data.get("request_type", "unknown"), + ) + + # Check if this is a data request (either format) + data_request_types = [ + "get_entity_state", + "get_entities_by_domain", + "get_entities_by_device_class", + "get_climate_related_entities", + "get_entities_by_area", + "get_entities", + "get_calendar_events", + "get_automations", + "get_entity_registry", + "get_device_registry", + "get_weather_data", + "get_area_registry", + "get_history", + "get_person_data", + "get_statistics", + "get_scenes", + "get_dashboards", + "get_dashboard_config", + "set_entity_state", + "create_automation", + "create_dashboard", + "update_dashboard", + ] + + if ( + response_data.get("request_type") == "data_request" + or response_data.get("request_type") in data_request_types + ): + # Handle data request (both standard format and direct request type) + if response_data.get("request_type") == "data_request": + request_type = response_data.get("request") + else: + request_type = response_data.get("request_type") + parameters = response_data.get("parameters", {}) + _LOGGER.debug( + "Processing data request: %s with parameters: %s", + request_type, + json.dumps(parameters), + ) + + # Add AI's response to conversation history + self.conversation_history.append( + { + "role": "assistant", + "content": json.dumps( + response_data + ), # Store clean JSON + } + ) + + # Get requested data + data: Union[Dict[str, Any], List[Dict[str, Any]]] + if request_type == "get_entity_state": + data = await self.get_entity_state( + parameters.get("entity_id") + ) + elif request_type == "get_entities_by_domain": + data = await self.get_entities_by_domain( + parameters.get("domain") + ) + elif request_type == "get_entities_by_area": + data = await self.get_entities_by_area( + parameters.get("area_id") + ) + elif request_type == "get_entities": + data = await self.get_entities( + area_id=parameters.get("area_id"), + area_ids=parameters.get("area_ids"), + ) + elif request_type == "get_entities_by_device_class": + data = await self.get_entities_by_device_class( + parameters.get("device_class"), + parameters.get("domain"), + ) + elif request_type == "get_climate_related_entities": + data = await self.get_climate_related_entities() + elif request_type == "get_calendar_events": + data = await self.get_calendar_events( + parameters.get("entity_id") + ) + elif request_type == "get_automations": + data = await self.get_automations() + elif request_type == "get_entity_registry": + data = await self.get_entity_registry() + elif request_type == "get_device_registry": + data = await self.get_device_registry() + elif request_type == "get_weather_data": + data = await self.get_weather_data() + elif request_type == "get_area_registry": + data = await self.get_area_registry() + elif request_type == "get_history": + data = await self.get_history( + parameters.get("entity_id"), + parameters.get("hours", 24), + ) + elif request_type == "get_person_data": + data = await self.get_person_data() + elif request_type == "get_statistics": + data = await self.get_statistics( + parameters.get("entity_id") + ) + elif request_type == "get_scenes": + data = await self.get_scenes() + elif request_type == "get_dashboards": + data = await self.get_dashboards() + elif request_type == "get_dashboard_config": + data = await self.get_dashboard_config( + parameters.get("dashboard_url") + ) + elif request_type == "set_entity_state": + data = await self.set_entity_state( + parameters.get("entity_id"), + parameters.get("state"), + parameters.get("attributes"), + ) + elif request_type == "create_automation": + data = await self.create_automation( + parameters.get("automation") + ) + elif request_type == "create_dashboard": + data = await self.create_dashboard( + parameters.get("dashboard_config") + ) + elif request_type == "update_dashboard": + data = await self.update_dashboard( + parameters.get("dashboard_url"), + parameters.get("dashboard_config"), + ) + else: + data = { + "error": f"Unknown request type: {request_type}" + } + _LOGGER.warning( + "Unknown request type: %s", request_type + ) + + # Check if any data request resulted in an error + if isinstance(data, dict) and "error" in data: + return _with_debug( + {"success": False, "error": data["error"]} + ) + elif isinstance(data, list) and any( + "error" in item + for item in data + if isinstance(item, dict) + ): + errors = [ + item["error"] + for item in data + if isinstance(item, dict) and "error" in item + ] + return _with_debug( + {"success": False, "error": "; ".join(errors)} + ) + + _LOGGER.debug( + "Retrieved data for request: %s", + json.dumps(data, default=str), + ) + + # Add data to conversation as a user message (not system to avoid overwriting system prompt in Anthropic API) + self.conversation_history.append( + { + "role": "user", + "content": self._format_data_message(data), + } + ) + continue + + elif response_data.get("request_type") == "final_response": + # Add final response to conversation history + self.conversation_history.append( + { + "role": "assistant", + "content": json.dumps( + response_data + ), # Store clean JSON + } + ) + + # Return final response + _LOGGER.debug( + "Received final response: %s", + response_data.get("response"), + ) + result = { + "success": True, + "answer": response_data.get("response", ""), + } + result = _with_debug(result) + self._set_cached_data(cache_key, result) + return result + elif ( + response_data.get("request_type") == "automation_suggestion" + ): + # Add automation suggestion to conversation history + self.conversation_history.append( + { + "role": "assistant", + "content": json.dumps( + response_data + ), # Store clean JSON + } + ) + + # Return automation suggestion + _LOGGER.debug( + "Received automation suggestion: %s", + json.dumps(response_data.get("automation")), + ) + result = { + "success": True, + "answer": json.dumps(response_data), + } + result = _with_debug(result) + self._set_cached_data(cache_key, result) + return result + elif ( + response_data.get("request_type") == "dashboard_suggestion" + ): + # Add dashboard suggestion to conversation history + self.conversation_history.append( + { + "role": "assistant", + "content": json.dumps( + response_data + ), # Store clean JSON + } + ) + + # Return dashboard suggestion + _LOGGER.debug( + "Received dashboard suggestion: %s", + json.dumps(response_data.get("dashboard")), + ) + result = { + "success": True, + "answer": json.dumps(response_data), + } + result = _with_debug(result) + self._set_cached_data(cache_key, result) + return result + elif response_data.get("request_type") in [ + "get_entities", + "get_entities_by_area", + ]: + # Handle direct get_entities request (for backward compatibility) + parameters = response_data.get("parameters", {}) + _LOGGER.debug( + "Processing direct get_entities request with parameters: %s", + json.dumps(parameters), + ) + + # Add AI's response to conversation history + self.conversation_history.append( + { + "role": "assistant", + "content": json.dumps( + response_data + ), # Store clean JSON + } + ) + + # Get entities data + if response_data.get("request_type") == "get_entities": + data = await self.get_entities( + area_id=parameters.get("area_id"), + area_ids=parameters.get("area_ids"), + ) + else: # get_entities_by_area + data = await self.get_entities_by_area( + parameters.get("area_id") + ) + + _LOGGER.debug( + "Retrieved %d entities", + len(data) if isinstance(data, list) else 1, + ) + + # Add data to conversation as a user message (not system to avoid overwriting system prompt in Anthropic API) + self.conversation_history.append( + { + "role": "user", + "content": self._format_data_message(data), + } + ) + continue + elif response_data.get("request_type") == "call_service": + # Handle service call request + domain = response_data.get("domain") + service = response_data.get("service") + target = response_data.get("target", {}) + service_data = response_data.get("service_data", {}) + + # Resolve nested requests in target + if target and "entity_id" in target: + entity_id_value = target["entity_id"] + if ( + isinstance(entity_id_value, dict) + and "request_type" in entity_id_value + ): + # This is a nested request, resolve it + nested_request_type = entity_id_value.get( + "request_type" + ) + nested_parameters = entity_id_value.get( + "parameters", {} + ) + + _LOGGER.debug( + "Resolving nested request: %s with parameters: %s", + nested_request_type, + json.dumps(nested_parameters), + ) + + # Resolve the nested request + if nested_request_type == "get_entities": + entities_data = await self.get_entities( + area_id=nested_parameters.get("area_id"), + area_ids=nested_parameters.get("area_ids"), + ) + elif nested_request_type == "get_entities_by_area": + entities_data = await self.get_entities_by_area( + nested_parameters.get("area_id") + ) + elif ( + nested_request_type == "get_entities_by_domain" + ): + entities_data = ( + await self.get_entities_by_domain( + nested_parameters.get("domain") + ) + ) + else: + _LOGGER.error( + "Unsupported nested request type: %s", + nested_request_type, + ) + return { + "success": False, + "error": f"Unsupported nested request type: {nested_request_type}", + } + + # Extract entity IDs from the resolved data + if isinstance(entities_data, list): + entity_ids = [ + entity.get("entity_id") + for entity in entities_data + if entity.get("entity_id") + ] + target["entity_id"] = entity_ids + _LOGGER.debug( + "Resolved nested request to entity IDs: %s", + entity_ids, + ) + else: + _LOGGER.error( + "Nested request returned unexpected data format" + ) + return _with_debug( + { + "success": False, + "error": "Nested request returned unexpected data format", + } + ) + + # Handle backward compatibility with old format + if not domain or not service: + request = response_data.get("request") + parameters = response_data.get("parameters", {}) + + if request and "entity_id" in parameters: + entity_id = parameters["entity_id"] + # Infer domain from entity_id + if "." in entity_id: + domain = entity_id.split(".")[0] + service = request + target = {"entity_id": entity_id} + # Remove entity_id from parameters to avoid duplication + service_data = { + k: v + for k, v in parameters.items() + if k != "entity_id" + } + _LOGGER.debug( + "Converted old format: domain=%s, service=%s", + domain, + service, + ) + + _LOGGER.debug( + "Processing service call: %s.%s with target: %s and data: %s", + domain, + service, + json.dumps(target), + json.dumps(service_data), + ) + + # Add AI's response to conversation history + self.conversation_history.append( + { + "role": "assistant", + "content": json.dumps( + response_data + ), # Store clean JSON + } + ) + + # Call the service + data = await self.call_service( + domain, service, target, service_data + ) + + # Check if service call resulted in an error + if isinstance(data, dict) and "error" in data: + return _with_debug( + {"success": False, "error": data["error"]} + ) + + _LOGGER.debug( + "Service call completed: %s", + json.dumps(data, default=str), + ) + + # Add data to conversation as a user message (not system to avoid overwriting system prompt in Anthropic API) + self.conversation_history.append( + { + "role": "user", + "content": self._format_data_message(data), + } + ) + # Go to next iteration to continue the loop + continue + + # Unknown request type + _LOGGER.warning( + "Unknown response type: %s", + response_data.get("request_type"), + ) + return _with_debug( + { + "success": False, + "error": f"Unknown response type: {response_data.get('request_type')}", + } + ) + + except json.JSONDecodeError as e: + # Check if this is a local provider that might have already wrapped the response + provider = self.config.get("ai_provider", "unknown") + if provider in ("local_ollama", "openai_compatible"): + _LOGGER.debug( + "Local provider returned non-JSON response (this is normal and handled): %s", + response[:200], + ) + else: + # Log more of the response to help with debugging for non-local providers + response_preview = ( + response[:1000] if len(response) > 1000 else response + ) + _LOGGER.warning( + "Failed to parse response as JSON: %s. Response length: %d. Response preview: %s", + str(e), + len(response), + response_preview, + ) + + # Log additional debugging information + _LOGGER.debug( + "First 50 characters as bytes: %s", + response[:50].encode("utf-8") if response else b"", + ) + _LOGGER.debug( + "Response starts with: %s", + repr(response[:10]) if response else "None", + ) + + # Also log the response to a separate debug file for detailed analysis (non-local providers only) + if provider not in ("local_ollama", "openai_compatible"): + try: + import os + + debug_dir = "/config/ai_agent_ha_debug" + + def write_debug_file(): + if not os.path.exists(debug_dir): + os.makedirs(debug_dir) + + import datetime + + timestamp = datetime.datetime.now().strftime( + "%Y%m%d_%H%M%S" + ) + debug_file = os.path.join( + debug_dir, f"failed_response_{timestamp}.txt" + ) + + with open(debug_file, "w", encoding="utf-8") as f: + f.write(f"Timestamp: {timestamp}\n") + f.write(f"Provider: {provider}\n") + f.write(f"Error: {str(e)}\n") + f.write(f"Response length: {len(response)}\n") + f.write( + f"Response bytes: {response.encode('utf-8') if response else b''}\n" + ) + f.write(f"Response repr: {repr(response)}\n") + f.write(f"Full response:\n{response}\n") + + return debug_file + + # Run file operations in executor to avoid blocking + debug_file = await self.hass.async_add_executor_job( + write_debug_file + ) + _LOGGER.info( + "Failed response saved to debug file: %s", + debug_file, + ) + except Exception as debug_error: + _LOGGER.debug( + "Could not save debug file: %s", str(debug_error) + ) + + # Check if this looks like a corrupted automation suggestion + if ( + response.strip().startswith( + '{"request_type": "automation_suggestion' + ) + and len(response) > 10000 + and response.count("for its use in various fields") > 50 + ): + _LOGGER.warning( + "Detected corrupted automation suggestion response with repetitive text" + ) + result = _with_debug( + { + "success": False, + "error": "AI generated corrupted automation response. Please try again with a more specific automation request.", + } + ) + self._set_cached_data(cache_key, result) + return result + + # If response is not valid JSON, try to wrap it as a final response + try: + # Truncate extremely long responses to prevent memory issues + response_to_wrap = response + if len(response) > 50000: + response_to_wrap = ( + response[:5000] + + "... [Response truncated due to excessive length]" + ) + _LOGGER.warning( + "Truncated extremely long response from %d to 5000 characters", + len(response), + ) + + wrapped_response = { + "request_type": "final_response", + "response": response_to_wrap, + } + # Keep the conversation paired: record the assistant + # reply so history doesn't end with a dangling user + # message (issue #80). + self.conversation_history.append( + {"role": "assistant", "content": response_to_wrap} + ) + result = { + "success": True, + "answer": json.dumps(wrapped_response), + } + _LOGGER.debug("Wrapped non-JSON response as final_response") + except Exception as wrap_error: + _LOGGER.error( + "Failed to wrap response: %s", str(wrap_error) + ) + result = { + "success": False, + "error": f"Invalid response format: {str(e)}", + } + + result = _with_debug(result) + self._set_cached_data(cache_key, result) + return result + + except Exception as e: + _LOGGER.exception("Error processing AI response: %s", str(e)) + # Roll back this query's messages so the failure doesn't + # poison subsequent queries (issue #80). + del self.conversation_history[history_checkpoint:] + return _with_debug( + { + "success": False, + "error": f"Error processing AI response: {str(e)}", + } + ) + + # If we've reached max iterations without a final response + _LOGGER.warning("Reached maximum iterations without final response") + # Roll back this query's messages so the failure doesn't poison + # subsequent queries (issue #80). + del self.conversation_history[history_checkpoint:] + result = { + "success": False, + "error": "Maximum iterations reached without final response", + } + result = _with_debug(result) + self._set_cached_data(cache_key, result) + return result + + except Exception as e: + _LOGGER.exception("Error in process_query: %s", str(e)) + return _with_debug( + {"success": False, "error": f"Error in process_query: {str(e)}"} + ) + + def _build_debug_trace( + self, + provider: Optional[str], + provider_settings: Optional[Dict[str, Any]], + endpoint_type: Optional[str], + ) -> Dict[str, Any]: + """Return a sanitized snapshot of the HA↔AI conversation for UI display.""" + history_tail = ( + self.conversation_history[-20:] if self.conversation_history else [] + ) + return { + "provider": provider, + "model": provider_settings.get("model") if provider_settings else None, + "endpoint_type": endpoint_type, + "conversation": history_tail, + } + + async def _get_ai_response(self) -> str: + """Get response from the selected AI provider with retries and rate limiting.""" + if not self._check_rate_limit(): + raise Exception("Rate limit exceeded. Please try again later.") + retry_count = 0 + last_error = None + # Limit conversation history to the last 10 messages to prevent token + # overflow, and cap the window's total size as well: even + # individually-capped data messages can stack up past context and + # per-minute token budgets (issue #80). Most recent messages win. + candidates = [ + m for m in self.conversation_history[-10:] if m.get("role") != "system" + ] + recent_messages: List[Dict[str, Any]] = [] + total_chars = 0 + for message in reversed(candidates): + size = len(str(message.get("content") or "")) + if recent_messages and total_chars + size > self.MAX_WINDOW_CHARS: + break + recent_messages.insert(0, message) + total_chars += size + # Dropping/slicing can cut mid-turn; ensure the window starts with a + # user turn (issue #80). + while recent_messages and recent_messages[0].get("role") == "assistant": + recent_messages.pop(0) + # System prompt is always the first message + recent_messages = [self.system_prompt] + recent_messages + + _LOGGER.debug("Sending %d messages to AI provider", len(recent_messages)) + _LOGGER.debug("AI provider: %s", self.config.get("ai_provider", "unknown")) + + while retry_count < self._max_retries: + try: + _LOGGER.debug( + "Attempt %d/%d: Calling AI client", + retry_count + 1, + self._max_retries, + ) + response = await self.ai_client.get_response(recent_messages) + # Every client is expected to return a string. Guard against a + # client handing back a non-string (e.g. a raw list/dict from an + # unexpected provider response shape) so the downstream string + # operations below don't crash with an unhelpful AttributeError + # and burn all retries (see issue #75). + if response is not None and not isinstance(response, str): + _LOGGER.warning( + "AI client returned non-string response of type %s; coercing to str", + type(response).__name__, + ) + response = ( + json.dumps(response) + if isinstance(response, (list, dict)) + else str(response) + ) + _LOGGER.debug( + "AI client returned response of length: %d", len(response or "") + ) + _LOGGER.debug("AI response preview: %s", (response or "")[:200]) + + # Check for extremely long responses that might indicate model issues + if response and len(response) > 50000: + _LOGGER.warning( + "AI returned extremely long response (%d characters), this may indicate a model issue", + len(response), + ) + # Check for repetitive patterns that indicate a corrupted response + if response.count("for its use in various fields") > 50: + _LOGGER.error( + "Detected corrupted repetitive response, aborting this iteration" + ) + raise Exception( + "AI generated corrupted response with repetitive text. Please try again with a clearer request." + ) + + # Check if response is empty + if not response or response.strip() == "": + _LOGGER.warning( + "AI client returned empty response on attempt %d", + retry_count + 1, + ) + if retry_count + 1 >= self._max_retries: + raise Exception( + "AI provider returned empty response after all retries" + ) + else: + retry_count += 1 + await asyncio.sleep(self._retry_delay * retry_count) + continue + + return str(response) + except NonRetryableAIError: + # Deterministic client error (e.g. 400 "prompt is too long") - + # retrying the same payload cannot succeed (issue #80). + raise + except Exception as e: + _LOGGER.error( + "AI client error on attempt %d: %s", retry_count + 1, str(e) + ) + last_error = e + retry_count += 1 + if retry_count < self._max_retries: + delay: float = self._retry_delay * retry_count + if isinstance(e, RateLimitedAIError) and e.retry_after: + # Wait at least as long as the provider asked for + # (capped at 60s) so per-minute token windows can + # actually reset (issue #80). + delay = max(delay, min(e.retry_after, 60)) + await asyncio.sleep(delay) + continue + raise Exception( + f"Failed after {retry_count} retries. Last error: {str(last_error)}" + ) + + def clear_conversation_history(self) -> None: + """Clear the conversation history and cache.""" + self.conversation_history = [] + self._cache.clear() + _LOGGER.debug("Conversation history and cache cleared") + + async def set_entity_state( + self, entity_id: str, state: str, attributes: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Set the state of an entity.""" + try: + _LOGGER.debug( + "Setting state for entity %s to %s with attributes: %s", + entity_id, + state, + json.dumps(attributes or {}), + ) + + # Validate entity exists + if not self.hass.states.get(entity_id): + return {"error": f"Entity {entity_id} not found"} + + # Call the appropriate service based on the domain + domain = entity_id.split(".")[0] + + if domain == "light": + service = ( + "turn_on" if state.lower() in ["on", "true", "1"] else "turn_off" + ) + service_data = {"entity_id": entity_id} + if attributes and service == "turn_on": + service_data.update(attributes) + await self.hass.services.async_call("light", service, service_data) + + elif domain == "switch": + service = ( + "turn_on" if state.lower() in ["on", "true", "1"] else "turn_off" + ) + await self.hass.services.async_call( + "switch", service, {"entity_id": entity_id} + ) + + elif domain == "cover": + if state.lower() in ["open", "up"]: + service = "open_cover" + elif state.lower() in ["close", "down"]: + service = "close_cover" + elif state.lower() == "stop": + service = "stop_cover" + else: + return {"error": f"Invalid state {state} for cover entity"} + await self.hass.services.async_call( + "cover", service, {"entity_id": entity_id} + ) + + elif domain == "climate": + service_data = {"entity_id": entity_id} + if state.lower() in ["on", "true", "1"]: + service = "turn_on" + elif state.lower() in ["off", "false", "0"]: + service = "turn_off" + elif state.lower() in ["heat", "cool", "dry", "fan_only", "auto"]: + service = "set_hvac_mode" + service_data["hvac_mode"] = state.lower() + else: + return {"error": f"Invalid state {state} for climate entity"} + await self.hass.services.async_call("climate", service, service_data) + + elif domain == "fan": + service = ( + "turn_on" if state.lower() in ["on", "true", "1"] else "turn_off" + ) + service_data = {"entity_id": entity_id} + if attributes and service == "turn_on": + service_data.update(attributes) + await self.hass.services.async_call("fan", service, service_data) + + else: + # For other domains, try to set the state directly + self.hass.states.async_set(entity_id, state, attributes or {}) + + # Get the new state to confirm the change + new_state = self.hass.states.get(entity_id) + return { + "success": True, + "entity_id": entity_id, + "new_state": new_state.state, + "new_attributes": new_state.attributes, + } + + except Exception as e: + _LOGGER.exception("Error setting entity state: %s", str(e)) + return {"error": f"Error setting entity state: {str(e)}"} + + async def call_service( + self, + domain: str, + service: str, + target: Optional[Dict[str, Any]] = None, + service_data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Call a Home Assistant service.""" + try: + _LOGGER.debug( + "Calling service %s.%s with target: %s and data: %s", + domain, + service, + json.dumps(target or {}), + json.dumps(service_data or {}), + ) + + # Prepare the service call data + call_data = {} + + # Add target entities if provided + if target: + if "entity_id" in target: + entity_ids = target["entity_id"] + if isinstance(entity_ids, list): + call_data["entity_id"] = entity_ids + else: + call_data["entity_id"] = [entity_ids] + + # Add other target properties + for key, value in target.items(): + if key != "entity_id": + call_data[key] = value + + # Add service data if provided + if service_data: + call_data.update(service_data) + + _LOGGER.debug("Final service call data: %s", json.dumps(call_data)) + + # Call the service + await self.hass.services.async_call(domain, service, call_data) + + # Get the updated states of affected entities + result_entities = [] + if "entity_id" in call_data: + for entity_id in call_data["entity_id"]: + state = self.hass.states.get(entity_id) + if state: + result_entities.append( + { + "entity_id": entity_id, + "state": state.state, + "attributes": dict(state.attributes), + } + ) + + return { + "success": True, + "service": f"{domain}.{service}", + "entities_affected": result_entities, + "message": f"Successfully called {domain}.{service}", + } + + except Exception as e: + _LOGGER.exception( + "Error calling service %s.%s: %s", domain, service, str(e) + ) + return {"error": f"Error calling service {domain}.{service}: {str(e)}"} + + async def save_user_prompt_history( + self, user_id: str, history: List[str] + ) -> Dict[str, Any]: + """Save user's prompt history to HA storage.""" + try: + store: Store = Store(self.hass, 1, f"ai_agent_ha_history_{user_id}") + await store.async_save({"history": history}) + return {"success": True} + except Exception as e: + _LOGGER.exception("Error saving prompt history: %s", str(e)) + return {"error": f"Error saving prompt history: {str(e)}"} + + async def load_user_prompt_history(self, user_id: str) -> Dict[str, Any]: + """Load user's prompt history from HA storage.""" + try: + store: Store = Store(self.hass, 1, f"ai_agent_ha_history_{user_id}") + data = await store.async_load() + history = data.get("history", []) if data else [] + return {"success": True, "history": history} + except Exception as e: + _LOGGER.exception("Error loading prompt history: %s", str(e)) + return {"error": f"Error loading prompt history: {str(e)}", "history": []} diff --git a/custom_components/ai_agent_ha/config_flow.py b/custom_components/ai_agent_ha/config_flow.py new file mode 100644 index 0000000..4207b94 --- /dev/null +++ b/custom_components/ai_agent_ha/config_flow.py @@ -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], + }, + ) diff --git a/custom_components/ai_agent_ha/const.py b/custom_components/ai_agent_ha/const.py new file mode 100644 index 0000000..ab89f0c --- /dev/null +++ b/custom_components/ai_agent_ha/const.py @@ -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" diff --git a/custom_components/ai_agent_ha/dashboard_templates.py b/custom_components/ai_agent_ha/dashboard_templates.py new file mode 100644 index 0000000..14b2947 --- /dev/null +++ b/custom_components/ai_agent_ha/dashboard_templates.py @@ -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 diff --git a/custom_components/ai_agent_ha/frontend.yaml b/custom_components/ai_agent_ha/frontend.yaml new file mode 100644 index 0000000..bd294c3 --- /dev/null +++ b/custom_components/ai_agent_ha/frontend.yaml @@ -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 \ No newline at end of file diff --git a/custom_components/ai_agent_ha/frontend/ai_agent_ha-panel.js b/custom_components/ai_agent_ha/frontend/ai_agent_ha-panel.js new file mode 100644 index 0000000..4fc23f7 --- /dev/null +++ b/custom_components/ai_agent_ha/frontend/ai_agent_ha-panel.js @@ -0,0 +1,1479 @@ +import { + LitElement, + html, + css, +} from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module"; + +console.log("AI Agent HA Panel loading..."); // Debug log + +const PROVIDERS = { + openai: "OpenAI", + llama: "Llama", + gemini: "Google Gemini", + openrouter: "OpenRouter", + anthropic: "Anthropic", + alter: "Alter", + zai: "z.ai", + local: "Local Model", + local_ollama: "Local Ollama", + openai_compatible: "OpenAI-Compatible", +}; + +class AiAgentHaPanel extends LitElement { + static get properties() { + return { + hass: { type: Object, reflect: false, attribute: false }, + narrow: { type: Boolean, reflect: false, attribute: false }, + panel: { type: Object, reflect: false, attribute: false }, + _messages: { type: Array, reflect: false, attribute: false }, + _isLoading: { type: Boolean, reflect: false, attribute: false }, + _error: { type: String, reflect: false, attribute: false }, + _pendingAutomation: { type: Object, reflect: false, attribute: false }, + _promptHistory: { type: Array, reflect: false, attribute: false }, + _showPredefinedPrompts: { type: Boolean, reflect: false, attribute: false }, + _showPromptHistory: { type: Boolean, reflect: false, attribute: false }, + _selectedPrompts: { type: Array, reflect: false, attribute: false }, + _selectedProvider: { type: String, reflect: false, attribute: false }, + _availableProviders: { type: Array, reflect: false, attribute: false }, + _showProviderDropdown: { type: Boolean, reflect: false, attribute: false }, + _showThinking: { type: Boolean, reflect: false, attribute: false }, + _thinkingExpanded: { type: Boolean, reflect: false, attribute: false }, + _debugInfo: { type: Object, reflect: false, attribute: false } + }; + } + + static get styles() { + return css` + :host { + background: var(--primary-background-color); + -webkit-font-smoothing: antialiased; + display: flex; + flex-direction: column; + height: 100vh; + } + .header { + background: var(--app-header-background-color); + color: var(--app-header-text-color); + padding: 16px 24px; + display: flex; + align-items: center; + gap: 12px; + font-size: 20px; + font-weight: 500; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + position: relative; + z-index: 100; + } + .clear-button { + margin-left: auto; + border: none; + border-radius: 16px; + background: var(--error-color); + color: #fff; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + font-weight: 500; + font-size: 13px; + box-shadow: 0 1px 2px rgba(0,0,0,0.08); + min-width: unset; + width: auto; + height: 36px; + flex-shrink: 0; + position: relative; + z-index: 101; + font-family: inherit; + } + .clear-button:hover { + background: var(--error-color); + opacity: 0.92; + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(0,0,0,0.13); + } + .clear-button:active { + transform: translateY(0); + box-shadow: 0 1px 2px rgba(0,0,0,0.08); + } + .clear-button ha-icon { + --mdc-icon-size: 16px; + margin-right: 2px; + color: #fff; + } + .clear-button span { + color: #fff; + font-weight: 500; + } + .content { + flex-grow: 1; + padding: 24px; + overflow-y: auto; + display: flex; + flex-direction: column; + justify-content: flex-end; + } + .chat-container { + width: 100%; + padding: 0; + display: flex; + flex-direction: column; + flex-grow: 1; + height: 100%; + } + .messages { + overflow-y: auto; + border: 1px solid var(--divider-color); + border-radius: 12px; + margin-bottom: 24px; + padding: 0; + background: var(--primary-background-color); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + flex-grow: 1; + width: 100%; + } + .prompts-section { + margin-bottom: 12px; + padding: 12px 16px; + background: var(--secondary-background-color); + border-radius: 16px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05); + border: 1px solid var(--divider-color); + } + .prompts-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + font-size: 14px; + font-weight: 500; + color: var(--secondary-text-color); + } + .prompts-toggle { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + color: var(--primary-color); + font-size: 12px; + font-weight: 500; + padding: 2px 6px; + border-radius: 4px; + transition: background-color 0.2s ease; + } + .prompts-toggle:hover { + background: var(--primary-color); + color: var(--text-primary-color); + } + .prompts-toggle ha-icon { + --mdc-icon-size: 14px; + } + .prompt-bubbles { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + } + .prompt-bubble { + background: var(--primary-background-color); + border: 1px solid var(--divider-color); + border-radius: 20px; + padding: 6px 12px; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; + line-height: 1.3; + color: var(--primary-text-color); + white-space: nowrap; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + } + .prompt-bubble:hover { + border-color: var(--primary-color); + background: var(--primary-color); + color: var(--text-primary-color); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + .prompt-bubble:active { + transform: translateY(0); + } + .history-bubble { + background: var(--primary-background-color); + border: 1px solid var(--accent-color); + border-radius: 20px; + padding: 6px 12px; + cursor: pointer; + transition: all 0.2s ease; + font-size: 12px; + line-height: 1.3; + color: var(--accent-color); + white-space: nowrap; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 6px; + } + .history-bubble:hover { + background: var(--accent-color); + color: var(--text-primary-color); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + .history-delete { + opacity: 0; + transition: opacity 0.2s ease; + color: var(--error-color); + cursor: pointer; + --mdc-icon-size: 14px; + } + .history-bubble:hover .history-delete { + opacity: 1; + color: var(--text-primary-color); + } + .message { + margin-bottom: 16px; + padding: 12px 16px; + border-radius: 12px; + max-width: 80%; + line-height: 1.5; + animation: fadeIn 0.3s ease-out; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + word-wrap: break-word; + } + .user-message { + background: var(--primary-color); + color: var(--text-primary-color); + margin-left: auto; + border-bottom-right-radius: 4px; + } + .assistant-message { + background: var(--secondary-background-color); + margin-right: auto; + border-bottom-left-radius: 4px; + } + .input-container { + position: relative; + width: 100%; + background: var(--card-background-color); + border: 1px solid var(--divider-color); + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + margin-bottom: 24px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + } + .input-container:focus-within { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.1); + } + .input-main { + display: flex; + align-items: flex-end; + padding: 12px; + gap: 12px; + } + .input-wrapper { + flex-grow: 1; + position: relative; + border: 1px solid var(--divider-color); + } + textarea { + width: 100%; + min-height: 24px; + max-height: 200px; + padding: 12px 16px 12px 16px; + border: none; + outline: none; + resize: none; + font-size: 16px; + line-height: 1.5; + background: transparent; + color: var(--primary-text-color); + font-family: inherit; + } + textarea::placeholder { + color: var(--secondary-text-color); + } + .input-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px 12px 16px; + border-top: 1px solid var(--divider-color); + background: var(--card-background-color); + border-radius: 0 0 12px 12px; + } + .provider-selector { + position: relative; + display: flex; + align-items: center; + gap: 8px; + } + .provider-button { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--secondary-background-color); + border: 1px solid var(--divider-color); + border-radius: 8px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + color: var(--primary-text-color); + transition: all 0.2s ease; + min-width: 150px; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-image: url('data:image/svg+xml;charset=US-ASCII,'); + background-repeat: no-repeat; + background-position: right 8px center; + padding-right: 30px; + } + .provider-button:hover { + background-color: var(--primary-background-color); + border-color: var(--primary-color); + } + .provider-button:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.2); + } + .provider-label { + font-size: 12px; + color: var(--secondary-text-color); + margin-right: 8px; + } + .thinking-toggle { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--secondary-text-color); + cursor: pointer; + user-select: none; + } + .thinking-toggle input { + margin: 0; + } + .thinking-panel { + border: 1px dashed var(--divider-color); + border-radius: 10px; + padding: 10px 12px; + margin: 12px 0; + background: var(--secondary-background-color); + } + .thinking-header { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + gap: 10px; + } + .thinking-title { + font-weight: 600; + color: var(--primary-text-color); + font-size: 14px; + } + .thinking-subtitle { + display: block; + font-size: 12px; + color: var(--secondary-text-color); + margin-top: 2px; + } + .thinking-body { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 10px; + max-height: 240px; + overflow-y: auto; + } + .thinking-entry { + border: 1px solid var(--divider-color); + border-radius: 8px; + padding: 8px; + background: var(--primary-background-color); + } + .thinking-entry .badge { + display: inline-block; + background: var(--secondary-background-color); + color: var(--secondary-text-color); + font-size: 11px; + padding: 2px 6px; + border-radius: 6px; + margin-bottom: 6px; + } + .thinking-entry pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + } + .thinking-empty { + color: var(--secondary-text-color); + font-size: 12px; + } + .send-button { + --mdc-theme-primary: var(--primary-color); + --mdc-theme-on-primary: var(--text-primary-color); + --mdc-typography-button-font-size: 14px; + --mdc-typography-button-text-transform: none; + --mdc-typography-button-letter-spacing: 0; + --mdc-typography-button-font-weight: 500; + --mdc-button-height: 36px; + --mdc-button-padding: 0 16px; + border-radius: 8px; + transition: all 0.2s ease; + min-width: 80px; + } + .send-button:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + .send-button:active { + transform: translateY(0); + } + .send-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .loading { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + padding: 12px 16px; + border-radius: 12px; + background: var(--secondary-background-color); + margin-right: auto; + max-width: 80%; + animation: fadeIn 0.3s ease-out; + } + .loading-dots { + display: flex; + gap: 4px; + } + .dot { + width: 8px; + height: 8px; + background: var(--primary-color); + border-radius: 50%; + animation: bounce 1.4s infinite ease-in-out; + } + .dot:nth-child(1) { animation-delay: -0.32s; } + .dot:nth-child(2) { animation-delay: -0.16s; } + @keyframes bounce { + 0%, 80%, 100% { + transform: scale(0); + } + 40% { + transform: scale(1.0); + } + } + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .error { + color: var(--error-color); + padding: 16px; + margin: 8px 0; + border-radius: 12px; + background: var(--error-background-color); + border: 1px solid var(--error-color); + animation: fadeIn 0.3s ease-out; + } + .automation-suggestion { + background: var(--secondary-background-color); + border: 1px solid var(--primary-color); + border-radius: 12px; + padding: 16px; + margin: 8px 0; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + position: relative; + z-index: 10; + } + .automation-title { + font-weight: 500; + margin-bottom: 8px; + color: var(--primary-color); + font-size: 16px; + } + .automation-description { + margin-bottom: 16px; + color: var(--secondary-text-color); + line-height: 1.4; + } + .automation-actions { + display: flex; + gap: 8px; + margin-top: 16px; + justify-content: flex-end; + } + .automation-actions ha-button { + --mdc-button-height: 40px; + --mdc-button-padding: 0 20px; + --mdc-typography-button-font-size: 14px; + --mdc-typography-button-font-weight: 600; + border-radius: 20px; + } + .automation-actions ha-button:first-child { + --mdc-theme-primary: var(--success-color, #4caf50); + --mdc-theme-on-primary: #fff; + } + .automation-actions ha-button:last-child { + --mdc-theme-primary: var(--error-color); + --mdc-theme-on-primary: #fff; + } + .automation-details { + margin-top: 8px; + padding: 8px; + background: var(--primary-background-color); + border-radius: 8px; + font-family: monospace; + font-size: 12px; + white-space: pre-wrap; + overflow-x: auto; + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--divider-color); + } + .dashboard-suggestion { + background: var(--secondary-background-color); + border: 1px solid var(--info-color, #2196f3); + border-radius: 12px; + padding: 16px; + margin: 8px 0; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + position: relative; + z-index: 10; + } + .dashboard-title { + font-weight: 500; + margin-bottom: 8px; + color: var(--info-color, #2196f3); + font-size: 16px; + } + .dashboard-description { + margin-bottom: 16px; + color: var(--secondary-text-color); + line-height: 1.4; + } + .dashboard-actions { + display: flex; + gap: 8px; + margin-top: 16px; + justify-content: flex-end; + } + .dashboard-actions ha-button { + --mdc-button-height: 40px; + --mdc-button-padding: 0 20px; + --mdc-typography-button-font-size: 14px; + --mdc-typography-button-font-weight: 600; + border-radius: 20px; + } + .dashboard-actions ha-button:first-child { + --mdc-theme-primary: var(--info-color, #2196f3); + --mdc-theme-on-primary: #fff; + } + .dashboard-actions ha-button:last-child { + --mdc-theme-primary: var(--error-color); + --mdc-theme-on-primary: #fff; + } + .dashboard-details { + margin-top: 8px; + padding: 8px; + background: var(--primary-background-color); + border-radius: 8px; + font-family: monospace; + font-size: 12px; + white-space: pre-wrap; + overflow-x: auto; + max-height: 200px; + overflow-y: auto; + border: 1px solid var(--divider-color); + } + .no-providers { + color: var(--error-color); + font-size: 14px; + padding: 8px; + } + `; + } + + constructor() { + super(); + this._messages = []; + this._isLoading = false; + this._error = null; + this._pendingAutomation = null; + this._promptHistory = []; + this._promptHistoryLoaded = false; + this._showPredefinedPrompts = true; + this._showPromptHistory = true; + this._predefinedPrompts = [ + "Build a new automation to turn off all lights at 10:00 PM every day", + "What's the current temperature inside and outside?", + "Turn on all the lights in the living room", + "Show me today's weather forecast", + "What devices are currently on?", + "Show me the energy usage for today", + "Are all the doors and windows locked?", + "Turn on movie mode in the living room", + "What's the status of my security system?", + "Show me who's currently home", + "Turn off all devices when I leave home" + ]; + this._selectedPrompts = this._getRandomPrompts(); + this._selectedProvider = null; + this._availableProviders = []; + this._showProviderDropdown = false; + this.providersLoaded = false; + this._eventSubscriptionSetup = false; + this._serviceCallTimeout = null; + this._showThinking = false; + this._thinkingExpanded = false; + this._debugInfo = null; + console.debug("AI Agent HA Panel constructor called"); + } + + _getRandomPrompts() { + // Shuffle array and take first 3 items + const shuffled = [...this._predefinedPrompts].sort(() => 0.5 - Math.random()); + return shuffled.slice(0, 3); + } + + async connectedCallback() { + super.connectedCallback(); + console.debug("AI Agent HA Panel connected"); + if (this.hass && !this._eventSubscriptionSetup) { + this._eventSubscriptionSetup = true; + this.hass.connection.subscribeEvents( + (event) => this._handleLlamaResponse(event), + 'ai_agent_ha_response' + ); + console.debug("Event subscription set up in connectedCallback()"); + // Load prompt history from Home Assistant storage + await this._loadPromptHistory(); + } + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!this.shadowRoot.querySelector('.provider-selector')?.contains(e.target)) { + this._showProviderDropdown = false; + } + }); + } + + async updated(changedProps) { + console.debug("Updated called with:", changedProps); + + // Set up event subscription when hass becomes available + if (changedProps.has('hass') && this.hass && !this._eventSubscriptionSetup) { + this._eventSubscriptionSetup = true; + this.hass.connection.subscribeEvents( + (event) => this._handleLlamaResponse(event), + 'ai_agent_ha_response' + ); + console.debug("Event subscription set up in updated()"); + } + + // Load providers when hass becomes available + if (changedProps.has('hass') && this.hass && !this.providersLoaded) { + this.providersLoaded = true; + + try { + // Uses the WebSocket API to get all entries with their complete data + const allEntries = await this.hass.callWS({ type: 'config_entries/get' }); + + const aiAgentEntries = allEntries.filter( + entry => entry.domain === 'ai_agent_ha' + ); + + if (aiAgentEntries.length > 0) { + const providers = aiAgentEntries + .map(entry => { + const provider = this._resolveProviderFromEntry(entry); + if (!provider) return null; + + return { + value: provider, + label: PROVIDERS[provider] || provider + }; + }) + .filter(Boolean); + + this._availableProviders = providers; + + if ( + (!this._selectedProvider || !providers.find(p => p.value === this._selectedProvider)) && + providers.length > 0 + ) { + this._selectedProvider = providers[0].value; + } + } else { + console.debug("No 'ai_agent_ha' config entries found via WebSocket."); + this._availableProviders = []; + } + } catch (error) { + console.error("Error fetching config entries via WebSocket:", error); + this._error = error.message || 'Failed to load AI provider configurations.'; + this._availableProviders = []; + } + this.requestUpdate(); + } + + // Load prompt history when hass becomes available and we haven't loaded it yet + if (changedProps.has('hass') && this.hass && !this._promptHistoryLoaded) { + this._promptHistoryLoaded = true; + await this._loadPromptHistory(); + } + + // Load prompt history when provider changes + if (changedProps.has('_selectedProvider') && this._selectedProvider && this.hass) { + await this._loadPromptHistory(); + } + + if (changedProps.has('_messages') || changedProps.has('_isLoading')) { + this._scrollToBottom(); + } + } + + _renderPromptsSection() { + return html` +
+
+ Quick Actions +
+
this._togglePredefinedPrompts()}> + + Suggestions +
+ ${this._promptHistory.length > 0 ? html` +
this._togglePromptHistory()}> + + Recent +
+ ` : ''} +
+
+ + ${this._showPredefinedPrompts ? html` +
+ ${this._selectedPrompts.map(prompt => html` +
this._usePrompt(prompt)}> + ${prompt} +
+ `)} +
+ ` : ''} + + ${this._showPromptHistory && this._promptHistory.length > 0 ? html` +
+ ${this._promptHistory.slice(-3).reverse().map((prompt, index) => html` +
this._useHistoryPrompt(e, prompt)}> + ${prompt} + this._deleteHistoryItem(e, prompt)} + > +
+ `)} +
+ ` : ''} +
+ `; + } + + _togglePredefinedPrompts() { + this._showPredefinedPrompts = !this._showPredefinedPrompts; + // Refresh random selection when toggling on + if (this._showPredefinedPrompts) { + this._selectedPrompts = this._getRandomPrompts(); + } + } + + _togglePromptHistory() { + this._showPromptHistory = !this._showPromptHistory; + } + + _usePrompt(prompt) { + if (this._isLoading) return; + const promptEl = this.shadowRoot.querySelector('#prompt'); + if (promptEl) { + promptEl.value = prompt; + promptEl.focus(); + } + } + + _useHistoryPrompt(event, prompt) { + event.stopPropagation(); + if (this._isLoading) return; + const promptEl = this.shadowRoot.querySelector('#prompt'); + if (promptEl) { + promptEl.value = prompt; + promptEl.focus(); + } + } + + async _deleteHistoryItem(event, prompt) { + event.stopPropagation(); + this._promptHistory = this._promptHistory.filter(p => p !== prompt); + await this._savePromptHistory(); + this.requestUpdate(); + } + + async _addToHistory(prompt) { + if (!prompt || prompt.trim().length === 0) return; + + // Remove duplicates and add to front + this._promptHistory = this._promptHistory.filter(p => p !== prompt); + this._promptHistory.push(prompt); + + // Keep only last 20 prompts + if (this._promptHistory.length > 20) { + this._promptHistory = this._promptHistory.slice(-20); + } + + await this._savePromptHistory(); + this.requestUpdate(); + } + + async _loadPromptHistory() { + if (!this.hass) { + console.debug('Hass not available, skipping prompt history load'); + return; + } + + console.debug('Loading prompt history...'); + try { + const result = await this.hass.callService('ai_agent_ha', 'load_prompt_history', { + provider: this._selectedProvider + }); + console.debug('Prompt history service result:', result); + + if (result && result.response && result.response.history) { + this._promptHistory = result.response.history; + console.debug('Loaded prompt history from service:', this._promptHistory); + this.requestUpdate(); + } else if (result && result.history) { + this._promptHistory = result.history; + console.debug('Loaded prompt history from service (direct):', this._promptHistory); + this.requestUpdate(); + } else { + console.debug('No prompt history returned from service, checking localStorage'); + // Fallback to localStorage if service returns no data + this._loadFromLocalStorage(); + } + } catch (error) { + console.error('Error loading prompt history from service:', error); + // Fallback to localStorage if service fails + this._loadFromLocalStorage(); + } + } + + _loadFromLocalStorage() { + try { + const savedList = localStorage.getItem('ai_agent_ha_prompt_history'); + if (savedList) { + const parsedList = JSON.parse(savedList); + const saved = parsedList.history && parsedList.provider === this._selectedProvider ? parsedList.history : null; + if (saved) { + this._promptHistory = JSON.parse(saved); + console.debug('Loaded prompt history from localStorage:', this._promptHistory); + this.requestUpdate(); + } else { + console.debug('No prompt history in localStorage'); + this._promptHistory = []; + } + } + } catch (e) { + console.error('Error loading from localStorage:', e); + this._promptHistory = []; + } + } + + async _savePromptHistory() { + if (!this.hass) { + console.debug('Hass not available, saving to localStorage only'); + this._saveToLocalStorage(); + return; + } + + console.debug('Saving prompt history:', this._promptHistory); + try { + const result = await this.hass.callService('ai_agent_ha', 'save_prompt_history', { + history: this._promptHistory, + provider: this._selectedProvider + }); + console.debug('Save prompt history result:', result); + + // Also save to localStorage as backup + this._saveToLocalStorage(); + } catch (error) { + console.error('Error saving prompt history to service:', error); + // Fallback to localStorage if service fails + this._saveToLocalStorage(); + } + } + + _saveToLocalStorage() { + try { + const data = { + provider: this._selectedProvider, + history: JSON.stringify(this._promptHistory) + } + localStorage.setItem('ai_agent_ha_prompt_history', JSON.stringify(data)); + console.debug('Saved prompt history to localStorage'); + } catch (e) { + console.error('Error saving to localStorage:', e); + } + } + + render() { + console.debug("Rendering with state:", { + messages: this._messages, + isLoading: this._isLoading, + error: this._error + }); + console.debug("Messages array:", this._messages); + + return html` +
+ + AI Agent HA + +
+
+
+
+ ${this._messages.map(msg => html` +
+ ${msg.text} + ${msg.automation ? html` +
+
${msg.automation.alias}
+
${msg.automation.description}
+
+ ${JSON.stringify(msg.automation, null, 2)} +
+
+ this._approveAutomation(msg.automation)} + .disabled=${this._isLoading} + >Approve + this._rejectAutomation()} + .disabled=${this._isLoading} + >Reject +
+
+ ` : ''} + ${msg.dashboard ? html` +
+
${msg.dashboard.title}
+
Dashboard with ${msg.dashboard.views ? msg.dashboard.views.length : 0} view(s)
+
+ ${JSON.stringify(msg.dashboard, null, 2)} +
+
+ this._approveDashboard(msg.dashboard)} + .disabled=${this._isLoading} + >Create Dashboard + this._rejectDashboard()} + .disabled=${this._isLoading} + >Cancel +
+
+ ` : ''} +
+ `)} + ${this._isLoading ? html` +
+ AI Agent is thinking +
+
+
+
+
+
+ ` : ''} + ${this._error ? html` +
${this._error}
+ ` : ''} + ${this._showThinking ? this._renderThinkingPanel() : ''} +
+ ${this._renderPromptsSection()} +
+
+
+ +
+
+ + +
+
+
+ `; + } + + _scrollToBottom() { + const messages = this.shadowRoot.querySelector('#messages'); + if (messages) { + messages.scrollTop = messages.scrollHeight; + } + } + + _autoResize(e) { + const textarea = e.target; + textarea.style.height = 'auto'; + textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'; + } + + _handleKeyDown(e) { + if (e.key === 'Enter' && !e.shiftKey && !this._isLoading) { + e.preventDefault(); + this._sendMessage(); + } + } + + _toggleProviderDropdown() { + this._showProviderDropdown = !this._showProviderDropdown; + console.log("Toggling provider dropdown:", this._showProviderDropdown); + this.requestUpdate(); // Añade esta línea para forzar la actualización + } + + async _selectProvider(provider) { + this._selectedProvider = provider; + console.debug("Provider changed to:", provider); + await this._loadPromptHistory(); + this.requestUpdate(); + } + + _getSelectedProviderLabel() { + const provider = this._availableProviders.find(p => p.value === this._selectedProvider); + return provider ? provider.label : 'Select Model'; + } + + async _sendMessage() { + const promptEl = this.shadowRoot.querySelector('#prompt'); + const prompt = promptEl.value.trim(); + if (!prompt || this._isLoading) return; + + console.debug("Sending message:", prompt); + console.debug("Sending message with provider:", this._selectedProvider); + + // Add to history + await this._addToHistory(prompt); + + // Add user message + this._messages = [...this._messages, { type: 'user', text: prompt }]; + promptEl.value = ''; + promptEl.style.height = 'auto'; + this._isLoading = true; + this._error = null; + this._debugInfo = null; + this._thinkingExpanded = false; // keep collapsed until a trace arrives + + // Clear any existing timeout + if (this._serviceCallTimeout) { + clearTimeout(this._serviceCallTimeout); + } + + // Set timeout to clear loading state after 10 minutes (for slower/local models) + this._serviceCallTimeout = setTimeout(() => { + if (this._isLoading) { + console.warn("Service call timeout - clearing loading state"); + this._isLoading = false; + this._error = 'Request timed out. Please try again.'; + this._messages = [...this._messages, { + type: 'assistant', + text: 'Sorry, the request timed out. Please try again.' + }]; + this.requestUpdate(); + } + }, 600000); // 10 minute timeout + + try { + console.debug("Calling ai_agent_ha service"); + await this.hass.callService('ai_agent_ha', 'query', { + prompt: prompt, + provider: this._selectedProvider, + debug: this._showThinking + }); + } catch (error) { + console.error("Error calling service:", error); + this._clearLoadingState(); + this._error = error.message || 'An error occurred while processing your request'; + this._messages = [...this._messages, { + type: 'assistant', + text: `Error: ${this._error}` + }]; + } + } + + _clearLoadingState() { + this._isLoading = false; + if (this._serviceCallTimeout) { + clearTimeout(this._serviceCallTimeout); + this._serviceCallTimeout = null; + } + } + + _handleLlamaResponse(event) { + console.debug("Received llama response:", event); + + try { + this._clearLoadingState(); + this._debugInfo = this._showThinking ? (event.data.debug || null) : null; + if (this._showThinking && this._debugInfo) { + this._thinkingExpanded = true; + } + if (event.data.success) { + // Check if the answer is empty + if (!event.data.answer || event.data.answer.trim() === '') { + console.warn("AI agent returned empty response"); + this._messages = [ + ...this._messages, + { type: 'assistant', text: 'I received your message but I\'m not sure how to respond. Could you please try rephrasing your question?' } + ]; + return; + } + + let message = { type: 'assistant', text: event.data.answer }; + + // Check if the response contains an automation or dashboard suggestion + try { + console.debug("Attempting to parse response as JSON:", event.data.answer); + let jsonText = event.data.answer; + + // Try to extract JSON from mixed text+JSON responses + const jsonMatch = jsonText.match(/\{[\s\S]*\}/); + if (jsonMatch && jsonMatch[0] !== jsonText.trim()) { + console.debug("Found JSON within mixed response, extracting:", jsonMatch[0]); + jsonText = jsonMatch[0]; + } + + const response = JSON.parse(jsonText); + console.debug("Parsed JSON response:", response); + + if (response.request_type === 'automation_suggestion') { + console.debug("Found automation suggestion"); + message.automation = response.automation; + message.text = response.message || 'I found an automation that might help you. Would you like me to create it?'; + } else if (response.request_type === 'dashboard_suggestion') { + console.debug("Found dashboard suggestion:", response.dashboard); + message.dashboard = response.dashboard; + message.text = response.message || 'I created a dashboard configuration for you. Would you like me to create it?'; + } else if (response.request_type === 'final_response') { + // If it's a final response, use the response field + message.text = response.response || response.message || event.data.answer; + } else if (response.message) { + // If there's a message field, use it + message.text = response.message; + } else if (response.response) { + // If there's a response field, use it + message.text = response.response; + } + // If none of the above, keep the original event.data.answer as message.text + } catch (e) { + // Not a JSON response, treat as normal message + console.debug("Response is not JSON, using as-is:", event.data.answer); + console.debug("JSON parse error:", e); + // message.text is already set to event.data.answer + } + + console.debug("Adding message to UI:", message); + this._messages = [...this._messages, message]; + } else { + this._error = event.data.error || 'An error occurred'; + this._messages = [ + ...this._messages, + { type: 'assistant', text: `Error: ${this._error}` } + ]; + } + } catch (error) { + console.error("Error in _handleLlamaResponse:", error); + this._clearLoadingState(); + this._error = 'An error occurred while processing the response'; + this._messages = [...this._messages, { + type: 'assistant', + text: 'Sorry, an error occurred while processing the response. Please try again.' + }]; + this.requestUpdate(); + } + } + + async _approveAutomation(automation) { + if (this._isLoading) return; + this._isLoading = true; + try { + const result = await this.hass.callService('ai_agent_ha', 'create_automation', { + automation: automation + }); + + console.debug("Automation creation result:", result); + + // The result should be an object with a message property + if (result && result.message) { + this._messages = [...this._messages, { + type: 'assistant', + text: result.message + }]; + } else { + // Fallback success message if no message is provided + this._messages = [...this._messages, { + type: 'assistant', + text: `Automation "${automation.alias}" has been created successfully!` + }]; + } + } catch (error) { + console.error("Error creating automation:", error); + this._error = error.message || 'An error occurred while creating the automation'; + this._messages = [...this._messages, { + type: 'assistant', + text: `Error: ${this._error}` + }]; + } finally { + this._clearLoadingState(); + } + } + + _rejectAutomation() { + this._messages = [...this._messages, { + type: 'assistant', + text: 'Automation creation cancelled. Would you like to try something else?' + }]; + } + + async _approveDashboard(dashboard) { + if (this._isLoading) return; + this._isLoading = true; + try { + const result = await this.hass.callService('ai_agent_ha', 'create_dashboard', { + dashboard_config: dashboard + }); + + console.debug("Dashboard creation result:", result); + + // The result should be an object with a message property + if (result && result.message) { + this._messages = [...this._messages, { + type: 'assistant', + text: result.message + }]; + } else { + // Fallback success message if no message is provided + this._messages = [...this._messages, { + type: 'assistant', + text: `Dashboard "${dashboard.title}" has been created successfully!` + }]; + } + } catch (error) { + console.error("Error creating dashboard:", error); + this._error = error.message || 'An error occurred while creating the dashboard'; + this._messages = [...this._messages, { + type: 'assistant', + text: `Error: ${this._error}` + }]; + } finally { + this._clearLoadingState(); + } + } + + _rejectDashboard() { + this._messages = [...this._messages, { + type: 'assistant', + text: 'Dashboard creation cancelled. Would you like me to create a different dashboard?' + }]; + } + + shouldUpdate(changedProps) { + // Only update if internal state changes, not on every hass update + return changedProps.has('_messages') || + changedProps.has('_isLoading') || + changedProps.has('_error') || + changedProps.has('_promptHistory') || + changedProps.has('_showPredefinedPrompts') || + changedProps.has('_showPromptHistory') || + changedProps.has('_availableProviders') || + changedProps.has('_selectedProvider') || + changedProps.has('_showProviderDropdown'); + } + + _clearChat() { + this._messages = []; + this._clearLoadingState(); + this._error = null; + this._pendingAutomation = null; + this._debugInfo = null; + // Don't clear prompt history - users might want to keep it + } + + _resolveProviderFromEntry(entry) { + if (!entry) return null; + + const providerFromData = entry.data?.ai_provider || entry.options?.ai_provider; + if (providerFromData && PROVIDERS[providerFromData]) { + return providerFromData; + } + + const uniqueId = entry.unique_id || entry.uniqueId; + if (uniqueId && uniqueId.startsWith("ai_agent_ha_")) { + const fromUniqueId = uniqueId.replace("ai_agent_ha_", ""); + if (PROVIDERS[fromUniqueId]) { + return fromUniqueId; + } + } + + // Fallback: try to match from title (case-insensitive, partial match) + if (entry.title) { + const lowerTitle = entry.title.toLowerCase(); + + // Direct keyword match for known providers + const keywordMap = [ + { key: "openai_compatible", keywords: ["openai-compatible", "openai compatible"] }, + { key: "local_ollama", keywords: ["local ollama", "ollama"] }, + { key: "openrouter", keywords: ["openrouter"] }, + { key: "gemini", keywords: ["google gemini", "gemini"] }, + { key: "openai", keywords: ["openai"] }, + { key: "llama", keywords: ["llama"] }, + { key: "anthropic", keywords: ["anthropic", "claude"] }, + { key: "alter", keywords: ["alter"] }, + { key: "zai", keywords: ["z.ai"] }, + { key: "local", keywords: ["local model"] }, + ]; + + for (const { key, keywords } of keywordMap) { + if (PROVIDERS[key] && keywords.some(k => lowerTitle.includes(k))) { + return key; + } + } + } + + return null; + } + + _getProviderInfo(providerId) { + return this._availableProviders.find(p => p.value === providerId); + } + + _hasProviders() { + return this._availableProviders && this._availableProviders.length > 0; + } + + _toggleThinkingPanel() { + this._thinkingExpanded = !this._thinkingExpanded; + } + + _toggleShowThinking(e) { + this._showThinking = e.target.checked; + if (!this._showThinking) { + this._thinkingExpanded = false; + } + } + + _renderThinkingPanel() { + if (!this._debugInfo) { + return ''; + } + + const subtitleParts = []; + if (this._debugInfo.provider) subtitleParts.push(this._debugInfo.provider); + if (this._debugInfo.model) subtitleParts.push(this._debugInfo.model); + if (this._debugInfo.endpoint_type) subtitleParts.push(this._debugInfo.endpoint_type); + const subtitle = subtitleParts.join(" · "); + const conversation = this._debugInfo.conversation || []; + + return html` +
+
this._toggleThinkingPanel()}> +
+ Thinking trace + ${subtitle ? html`${subtitle}` : ''} +
+ +
+ ${this._thinkingExpanded ? html` +
+ ${conversation.length === 0 ? html` +
No trace captured.
+ ` : conversation.map((entry, index) => html` +
+
${entry.role || 'unknown'}
+
${entry.content || ''}
+
+ `)} +
+ ` : ''} +
+ `; + } +} + +customElements.define("ai_agent_ha-panel", AiAgentHaPanel); + +console.log("AI Agent HA Panel registered"); diff --git a/custom_components/ai_agent_ha/manifest.json b/custom_components/ai_agent_ha/manifest.json new file mode 100644 index 0000000..a72874e --- /dev/null +++ b/custom_components/ai_agent_ha/manifest.json @@ -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" +} diff --git a/custom_components/ai_agent_ha/services.yaml b/custom_components/ai_agent_ha/services.yaml new file mode 100644 index 0000000..32df89e --- /dev/null +++ b/custom_components/ai_agent_ha/services.yaml @@ -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" diff --git a/custom_components/ai_agent_ha/strings.json b/custom_components/ai_agent_ha/strings.json new file mode 100644 index 0000000..1e1799a --- /dev/null +++ b/custom_components/ai_agent_ha/strings.json @@ -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)" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/ai_agent_ha/translations/ca.json b/custom_components/ai_agent_ha/translations/ca.json new file mode 100644 index 0000000..3468855 --- /dev/null +++ b/custom_components/ai_agent_ha/translations/ca.json @@ -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)" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/ai_agent_ha/translations/de.json b/custom_components/ai_agent_ha/translations/de.json new file mode 100644 index 0000000..bfd8cf2 --- /dev/null +++ b/custom_components/ai_agent_ha/translations/de.json @@ -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...')" + } + } + } + } +} diff --git a/custom_components/ai_agent_ha/translations/en.json b/custom_components/ai_agent_ha/translations/en.json new file mode 100644 index 0000000..1e1799a --- /dev/null +++ b/custom_components/ai_agent_ha/translations/en.json @@ -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)" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/ai_agent_ha/translations/en.txt b/custom_components/ai_agent_ha/translations/en.txt new file mode 100644 index 0000000..ab3ce1b --- /dev/null +++ b/custom_components/ai_agent_ha/translations/en.txt @@ -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)" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/ai_agent_ha/translations/es.json b/custom_components/ai_agent_ha/translations/es.json new file mode 100644 index 0000000..dc214bb --- /dev/null +++ b/custom_components/ai_agent_ha/translations/es.json @@ -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)" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/alarmo/__init__.py b/custom_components/alarmo/__init__.py new file mode 100644 index 0000000..e0a29a2 --- /dev/null +++ b/custom_components/alarmo/__init__.py @@ -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, + ) diff --git a/custom_components/alarmo/__pycache__/__init__.cpython-314.pyc b/custom_components/alarmo/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..f2466db Binary files /dev/null and b/custom_components/alarmo/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/alarm_control_panel.cpython-314.pyc b/custom_components/alarmo/__pycache__/alarm_control_panel.cpython-314.pyc new file mode 100644 index 0000000..37c7882 Binary files /dev/null and b/custom_components/alarmo/__pycache__/alarm_control_panel.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/automations.cpython-314.pyc b/custom_components/alarmo/__pycache__/automations.cpython-314.pyc new file mode 100644 index 0000000..ff3bbcb Binary files /dev/null and b/custom_components/alarmo/__pycache__/automations.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/card.cpython-314.pyc b/custom_components/alarmo/__pycache__/card.cpython-314.pyc new file mode 100644 index 0000000..cbd2930 Binary files /dev/null and b/custom_components/alarmo/__pycache__/card.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/config_flow.cpython-314.pyc b/custom_components/alarmo/__pycache__/config_flow.cpython-314.pyc new file mode 100644 index 0000000..cd7dfe1 Binary files /dev/null and b/custom_components/alarmo/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/const.cpython-314.pyc b/custom_components/alarmo/__pycache__/const.cpython-314.pyc new file mode 100644 index 0000000..a16d24d Binary files /dev/null and b/custom_components/alarmo/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/event.cpython-314.pyc b/custom_components/alarmo/__pycache__/event.cpython-314.pyc new file mode 100644 index 0000000..9efa6af Binary files /dev/null and b/custom_components/alarmo/__pycache__/event.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/helpers.cpython-314.pyc b/custom_components/alarmo/__pycache__/helpers.cpython-314.pyc new file mode 100644 index 0000000..9f49da1 Binary files /dev/null and b/custom_components/alarmo/__pycache__/helpers.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/mqtt.cpython-314.pyc b/custom_components/alarmo/__pycache__/mqtt.cpython-314.pyc new file mode 100644 index 0000000..acbf733 Binary files /dev/null and b/custom_components/alarmo/__pycache__/mqtt.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/panel.cpython-314.pyc b/custom_components/alarmo/__pycache__/panel.cpython-314.pyc new file mode 100644 index 0000000..1e76db9 Binary files /dev/null and b/custom_components/alarmo/__pycache__/panel.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/sensors.cpython-314.pyc b/custom_components/alarmo/__pycache__/sensors.cpython-314.pyc new file mode 100644 index 0000000..14c058c Binary files /dev/null and b/custom_components/alarmo/__pycache__/sensors.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/store.cpython-314.pyc b/custom_components/alarmo/__pycache__/store.cpython-314.pyc new file mode 100644 index 0000000..7ef57b6 Binary files /dev/null and b/custom_components/alarmo/__pycache__/store.cpython-314.pyc differ diff --git a/custom_components/alarmo/__pycache__/websockets.cpython-314.pyc b/custom_components/alarmo/__pycache__/websockets.cpython-314.pyc new file mode 100644 index 0000000..3ec52a5 Binary files /dev/null and b/custom_components/alarmo/__pycache__/websockets.cpython-314.pyc differ diff --git a/custom_components/alarmo/alarm_control_panel.py b/custom_components/alarmo/alarm_control_panel.py new file mode 100644 index 0000000..9a2182f --- /dev/null +++ b/custom_components/alarmo/alarm_control_panel.py @@ -0,0 +1,1597 @@ +"""Initialization of Alarmo alarm_control_panel platform.""" + +import logging +import datetime +import operator +import functools +from abc import abstractmethod + +import homeassistant.util.dt as dt_util +from homeassistant.core import ( + HomeAssistant, + callback, +) +from homeassistant.util import slugify +from homeassistant.const import ( + ATTR_NAME, + STATE_UNKNOWN, + ATTR_CODE_FORMAT, + STATE_UNAVAILABLE, +) +from homeassistant.helpers import entity_platform +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.event import ( + async_call_later, + async_track_point_in_time, +) +from homeassistant.helpers.dispatcher import ( + dispatcher_send, + async_dispatcher_send, + async_dispatcher_connect, +) +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.components.alarm_control_panel import ( + DOMAIN as PLATFORM, +) +from homeassistant.components.alarm_control_panel import ( + ATTR_CODE_ARM_REQUIRED, + AlarmControlPanelState, + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, +) + +from . import const + +_LOGGER = logging.getLogger(__name__) + +# Store per-config-entry unsubscribe callbacks for platform-level dispatcher listeners +PLATFORM_UNSUBS = "platform_unsubs" + + +async def async_setup(hass, config): + """Track states and offer events for alarm_control_panel.""" + return True + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the platform from config.""" + return True + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Alarmo entities.""" + + @callback + def async_add_alarm_entity(config: dict): + """Add each entity as Alarm Control Panel.""" + entity_id = f"{PLATFORM}.{slugify(config['name'])}" + + # Guard against duplicate registration (reloads/upgrade timing) + if config["area_id"] in hass.data[const.DOMAIN]["areas"]: + existing = hass.data[const.DOMAIN]["areas"][config["area_id"]] + if existing and getattr(existing, "entity_id", None) == entity_id: + _LOGGER.debug( + "Area %s already registered as %s; skipping duplicate add", + config["area_id"], + entity_id, + ) + return + + alarm_entity = AlarmoAreaEntity( + hass=hass, + entity_id=entity_id, + name=config["name"], + area_id=config["area_id"], + ) + hass.data[const.DOMAIN]["areas"][config["area_id"]] = alarm_entity + async_add_devices([alarm_entity]) + + unsub_area = async_dispatcher_connect( + hass, "alarmo_register_entity", async_add_alarm_entity + ) + + @callback + def async_add_alarm_master(config: dict): + """Add each entity as Alarm Control Panel.""" + entity_id = f"{PLATFORM}.{slugify(config['name'])}" + + # Guard against duplicate master registration + if hass.data[const.DOMAIN]["master"] is not None: + existing = hass.data[const.DOMAIN]["master"] + if existing and getattr(existing, "entity_id", None) == entity_id: + _LOGGER.debug( + "Master already registered as %s; skipping duplicate add", entity_id + ) + return + + alarm_entity = AlarmoMasterEntity( + hass=hass, + entity_id=entity_id, + name=config["name"], + ) + hass.data[const.DOMAIN]["master"] = alarm_entity + async_add_devices([alarm_entity]) + + unsub_master = async_dispatcher_connect( + hass, "alarmo_register_master", async_add_alarm_master + ) + # Track unsubs per config entry for proper cleanup on unload + hass.data.setdefault(const.DOMAIN, {}).setdefault(PLATFORM_UNSUBS, {})[ + config_entry.entry_id + ] = [unsub_area, unsub_master] + async_dispatcher_send(hass, "alarmo_platform_loaded") + + # Register services + platform = entity_platform.current_platform.get() + platform.async_register_entity_service( + const.SERVICE_ARM, + const.SERVICE_ARM_SCHEMA, + "async_service_arm_handler", + ) + platform.async_register_entity_service( + const.SERVICE_DISARM, + const.SERVICE_DISARM_SCHEMA, + "async_service_disarm_handler", + ) + platform.async_register_entity_service( + const.SERVICE_SKIP_DELAY, + const.SERVICE_SKIP_DELAY_SCHEMA, + "async_service_skip_delay_handler", + ) + + +async def async_unload_entry(hass, config_entry): + """Unload the Alarmo alarm_control_panel platform for a config entry.""" + unsubs = ( + hass.data.get(const.DOMAIN, {}) + .get(PLATFORM_UNSUBS, {}) + .pop(config_entry.entry_id, []) + ) + for unsub in unsubs: + try: + unsub() + except Exception: # defensive: ensure unload proceeds + _LOGGER.debug("Error while unsubscribing platform listener", exc_info=True) + return True + + +class AlarmoBaseEntity(AlarmControlPanelEntity, RestoreEntity): + """Defines a base alarm_control_panel entity.""" + + def __init__(self, hass: HomeAssistant, name: str, entity_id: str) -> None: + """Initialize the alarm_control_panel entity.""" + self.entity_id = entity_id + self._name = name + self._state = None + self.hass = hass + self._config = {} + self._arm_mode = None + self._changed_by = None + self._open_sensors = {} + self._bypass_open_sensors = False + self._bypassed_sensors = [] + self._delay = None + self.expiration = None + self.area_id = None + self._revert_state = None + self._ready_to_arm_modes = [] + self._last_triggered = None + + @property + def device_info(self) -> dict: + """Return info for device registry.""" + return { + "identifiers": { + (const.DOMAIN, self.hass.data[const.DOMAIN]["coordinator"].id) + }, + "name": const.NAME, + "model": const.NAME, + "sw_version": const.VERSION, + "manufacturer": const.MANUFACTURER, + } + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self.entity_id}" + + @property + def name(self): + """Return the friendly name to use for this entity.""" + return self._name + + @property + def should_poll(self) -> bool: + """Return the polling state.""" + return False + + @property + def code_format(self): + """Return whether code consists of digits or characters.""" + if self._state == AlarmControlPanelState.DISARMED and self.code_arm_required: + return self._config[ATTR_CODE_FORMAT] + + elif ( + self._state != AlarmControlPanelState.DISARMED + and self._config + and const.ATTR_CODE_DISARM_REQUIRED in self._config + and self._config[const.ATTR_CODE_DISARM_REQUIRED] + ): + return self._config[ATTR_CODE_FORMAT] + + else: + return None + + @property + def changed_by(self): + """Last change triggered by.""" + return self._changed_by + + @property + def alarm_state(self) -> AlarmControlPanelState | None: + """Return the state of the device.""" + return self._state + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return 0 + + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + if not self._config or ATTR_CODE_ARM_REQUIRED not in self._config: + return True # assume code is needed (conservative approach) + elif self._state != AlarmControlPanelState.DISARMED: + return self._config[const.ATTR_CODE_MODE_CHANGE_REQUIRED] + else: + return self._config[ATTR_CODE_ARM_REQUIRED] + + @property + def arm_mode(self): + """Return the arm mode.""" + return ( + self._arm_mode if self._state != AlarmControlPanelState.DISARMED else None + ) + + @property + def open_sensors(self): + """Get open sensors.""" + if not self._open_sensors: + return None + else: + return self._open_sensors + + @open_sensors.setter + def open_sensors(self, value): + """Set open_sensors sensors.""" + if type(value) is dict: + self._open_sensors = value + else: + self._open_sensors = {} + + @property + def bypassed_sensors(self): + """Get bypassed sensors.""" + if not self._bypassed_sensors: + return None + else: + return self._bypassed_sensors + + @bypassed_sensors.setter + def bypassed_sensors(self, value): + """Set bypassed sensors.""" + if type(value) is list: + self._bypassed_sensors = value + elif not value: + self._bypassed_sensors = None + + @property + def delay(self): + """Get delay.""" + return self._delay + + @delay.setter + def delay(self, value): + """Set delay.""" + if type(value) is int: + self._delay = value + self.expiration = ( + dt_util.utcnow() + datetime.timedelta(seconds=value) + ).replace(microsecond=0) + else: + self._delay = None + self.expiration = None + + @property + def last_triggered(self): + """Get last time occurrence of alarm trigger.""" + return self._last_triggered + + @property + def extra_state_attributes(self): + """Return the data of the entity.""" + return { + "arm_mode": self.arm_mode, + "next_state": self.next_state, + "open_sensors": self.open_sensors, + "bypassed_sensors": self.bypassed_sensors, + "delay": self.delay, + "last_triggered": self.last_triggered, + } + + async def _validate_code(self, code, to_state): # noqa PLR0911 + """Validate code and user permissions for a requested state change. + + Returns a (success, error_event) tuple. When success is True, + error_event is None. + """ + # check bypass rules + if ( + to_state == AlarmControlPanelState.DISARMED + and not self._config[const.ATTR_CODE_DISARM_REQUIRED] + ): + _LOGGER.debug( + "Code is not required for disarming, bypassing code validation." + ) + self._changed_by = None + return True, None + + if ( + to_state != AlarmControlPanelState.DISARMED + and self._state == AlarmControlPanelState.DISARMED + and not self._config[ATTR_CODE_ARM_REQUIRED] + ): + _LOGGER.debug("Code is not required for arming, bypassing code validation.") + self._changed_by = None + return True, None + + if ( + AlarmControlPanelState.DISARMED not in {to_state, self._state} + and not self._config[const.ATTR_CODE_MODE_CHANGE_REQUIRED] + ): + _LOGGER.debug( + "Code is not required for mode change, bypassing code validation." + ) + self._changed_by = None + return True, None + + if not code or len(code) < 1: + _LOGGER.debug("No code provided, but code is required. Rejecting command.") + return False, const.EVENT_NO_CODE_PROVIDED + # resolve user + user = await self.hass.data[const.DOMAIN][ + "coordinator" + ].async_authenticate_user(code) + if not user: + return False, const.EVENT_INVALID_CODE_PROVIDED + + # area permission check + allowed_areas = user.get(const.ATTR_AREA_LIMIT) + if allowed_areas: + target_areas = ( + [self.area_id] + if self.area_id + else list(self.hass.data[const.DOMAIN]["areas"].keys()) + ) + if not all(area in allowed_areas for area in target_areas): + # user is not allowed to operate this area + _LOGGER.debug( + "User %s has no permission to arm/disarm this area.", + user[ATTR_NAME], + ) + return False, const.EVENT_INVALID_CODE_PROVIDED + + # action permission check + if to_state == AlarmControlPanelState.DISARMED: + if not user.get("can_disarm", False): + # user is not allowed to disarm the alarm + _LOGGER.debug( + "User %s has no permission to disarm the alarm.", + user.get(ATTR_NAME, "unknown user"), + ) + return False, const.EVENT_INVALID_CODE_PROVIDED + elif to_state in const.ARM_MODES: + if not user.get("can_arm", False): + # user is not allowed to arm the alarm + _LOGGER.debug( + "User %s has no permission to arm the alarm.", + user.get(ATTR_NAME, "unknown user"), + ) + return False, const.EVENT_INVALID_CODE_PROVIDED + else: + return False, const.EVENT_INVALID_CODE_PROVIDED + + # success + self._changed_by = user[ATTR_NAME] + return True, None + + async def async_service_disarm_handler(self, code, context_id=None): + """Handle external disarm request from alarmo.disarm service.""" + _LOGGER.debug("Service alarmo.disarm was called") + + await self.async_alarm_disarm(code=code, context_id=context_id) + + async def async_alarm_disarm(self, code, **kwargs): + """Send disarm command.""" + _LOGGER.debug("alarm_disarm") + skip_code = kwargs.get("skip_code", False) + context_id = kwargs.get("context_id", None) + + if self._state == AlarmControlPanelState.DISARMED or not self._config: + if not self._config: + _LOGGER.warning( + "Cannot process disarm command, alarm is not initialized yet." + ) + else: + _LOGGER.warning( + "Cannot go to state %s from state %s.", + AlarmControlPanelState.DISARMED, + self._state, + ) + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_COMMAND_NOT_ALLOWED, + self.area_id, + { + "state": self._state, + "command": const.COMMAND_DISARM, + const.ATTR_CONTEXT_ID: context_id, + }, + ) + return + if not skip_code: + (res, info) = await self._validate_code( + code, AlarmControlPanelState.DISARMED + ) + else: + res, info = (True, None) + + if not res and not skip_code: + dispatcher_send( + self.hass, + "alarmo_event", + info, + self.area_id, + { + const.ATTR_CONTEXT_ID: context_id, + "command": const.COMMAND_DISARM, + }, + ) + _LOGGER.warning("Wrong code provided.") + return + else: + self.open_sensors = None + self.bypassed_sensors = None + self.async_update_state(AlarmControlPanelState.DISARMED) + if self.changed_by: + _LOGGER.info( + "Alarm '%s' is disarmed by %s.", + self.name, + self.changed_by, + ) + else: + _LOGGER.info( + "Alarm '%s' is disarmed.", + self.name, + ) + + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_DISARM, + self.area_id, + {const.ATTR_CONTEXT_ID: context_id}, + ) + return True + + @callback + def alarm_disarm(self, code=None, **kwargs): + """Send disarm command (sync wrapper).""" + self.hass.async_create_task(self.async_alarm_disarm(code=code, **kwargs)) + + async def async_service_arm_handler( + self, code, mode, skip_delay, force, context_id=None + ): + """Handle external arm request from alarmo.arm service.""" + _LOGGER.debug("Service alarmo.arm was called") + + if mode in const.ARM_MODE_TO_STATE: + mode = const.ARM_MODE_TO_STATE[mode] + + await self.async_handle_arm_request( + mode, + code=code, + skip_delay=skip_delay, + bypass_open_sensors=force, + context_id=context_id, + ) + + async def async_handle_arm_request(self, arm_mode, **kwargs): + """Check if conditions are met for starting arm procedure.""" + code = kwargs.get(const.CONF_CODE, "") + skip_code = kwargs.get("skip_code", False) + skip_delay = kwargs.get(const.ATTR_SKIP_DELAY, False) + bypass_open_sensors = kwargs.get("bypass_open_sensors", False) + context_id = kwargs.get("context_id", None) + + if ( + not (const.MODES_TO_SUPPORTED_FEATURES[arm_mode] & self.supported_features) + or ( + self._state != AlarmControlPanelState.DISARMED + and self._state not in const.ARM_MODES + ) + or not self._config + ): + if not self._config or not self._state: + _LOGGER.warning( + "Cannot process arm command, alarm is not initialized yet." + ) + elif not ( + const.MODES_TO_SUPPORTED_FEATURES[arm_mode] & self.supported_features + ): + _LOGGER.warning( + "Mode %s is not supported, ignoring.", + arm_mode, + ) + else: + _LOGGER.warning( + "Cannot go to state %s from state %s.", + arm_mode, + self._state, + ) + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_COMMAND_NOT_ALLOWED, + self.area_id, + { + "state": self._state, + "command": arm_mode.replace("armed", "arm"), + const.ATTR_CONTEXT_ID: context_id, + }, + ) + return False + elif self._state in const.ARM_MODES and self._arm_mode == arm_mode: + _LOGGER.debug( + "Alarm is already set to %s, ignoring command.", + arm_mode, + ) + return False + + if not skip_code: + (res, info) = await self._validate_code(code, arm_mode) + if not res: + dispatcher_send( + self.hass, + "alarmo_event", + info, + self.area_id, + { + "command": arm_mode.replace("armed", "arm"), + const.ATTR_CONTEXT_ID: context_id, + }, + ) + _LOGGER.warning("Wrong code provided.") + if self.open_sensors: + self.open_sensors = None + self.schedule_update_ha_state() + return False + elif info and info[const.ATTR_IS_OVERRIDE_CODE]: + bypass_open_sensors = True + else: + self._changed_by = None + + if self._state in const.ARM_MODES: + # we are switching between arm modes + self._revert_state = self._state + else: + self._revert_state = AlarmControlPanelState.DISARMED + self.open_sensors = None + self.bypassed_sensors = None + + self.async_arm( + arm_mode, + skip_delay=skip_delay, + bypass_open_sensors=bypass_open_sensors, + context_id=context_id, + ) + + @callback + def async_service_skip_delay_handler(self): + """Service handler for alarmo.skip_delay.""" + _LOGGER.debug("Service alarmo.skip_delay was called") + + if self._state not in [ + AlarmControlPanelState.ARMING, + AlarmControlPanelState.PENDING, + ]: + raise HomeAssistantError( + f"Entity has state '{self._state}', " + f"but must be in state '{AlarmControlPanelState.ARMING}' " + f"or '{AlarmControlPanelState.PENDING}'." + ) + + elif self._state == AlarmControlPanelState.ARMING: + self.async_arm(self.arm_mode, skip_delay=True) + elif self._state == AlarmControlPanelState.PENDING: + self.async_trigger(entry_delay=0) + + @abstractmethod + @callback + def async_update_state(self, state: str | None = None) -> None: + """Update the state or refresh state attributes.""" + + @abstractmethod + @callback + def async_trigger( + self, entry_delay: int | None = None, open_sensors: dict[str, str] | None = None + ): + """Trigger the alarm.""" + + async def async_alarm_arm_away( + self, code=None, skip_code=False, bypass_open_sensors=False, skip_delay=False + ): + """Send arm away command.""" + _LOGGER.debug("alarm_arm_away") + await self.async_handle_arm_request( + AlarmControlPanelState.ARMED_AWAY, + code=code, + skip_code=skip_code, + bypass_open_sensors=bypass_open_sensors, + skip_delay=skip_delay, + ) + + async def async_alarm_arm_home( + self, code=None, skip_code=False, bypass_open_sensors=False, skip_delay=False + ): + """Send arm home command.""" + _LOGGER.debug("alarm_arm_home") + await self.async_handle_arm_request( + AlarmControlPanelState.ARMED_HOME, + code=code, + skip_code=skip_code, + bypass_open_sensors=bypass_open_sensors, + skip_delay=skip_delay, + ) + + async def async_alarm_arm_night( + self, code=None, skip_code=False, bypass_open_sensors=False, skip_delay=False + ): + """Send arm night command.""" + _LOGGER.debug("alarm_arm_night") + await self.async_handle_arm_request( + AlarmControlPanelState.ARMED_NIGHT, + code=code, + skip_code=skip_code, + bypass_open_sensors=bypass_open_sensors, + skip_delay=skip_delay, + ) + + async def async_alarm_arm_custom_bypass( + self, code=None, skip_code=False, bypass_open_sensors=False, skip_delay=False + ): + """Send arm custom_bypass command.""" + _LOGGER.debug("alarm_arm_custom_bypass") + await self.async_handle_arm_request( + AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + code=code, + skip_code=skip_code, + bypass_open_sensors=bypass_open_sensors, + skip_delay=skip_delay, + ) + + async def async_alarm_arm_vacation( + self, code=None, skip_code=False, bypass_open_sensors=False, skip_delay=False + ): + """Send arm vacation command.""" + _LOGGER.debug("alarm_arm_vacation") + await self.async_handle_arm_request( + AlarmControlPanelState.ARMED_VACATION, + code=code, + skip_code=skip_code, + bypass_open_sensors=bypass_open_sensors, + skip_delay=skip_delay, + ) + + async def async_alarm_trigger(self, code=None) -> None: + """Send alarm trigger command.""" + _LOGGER.debug("async_alarm_trigger") + self.async_trigger(entry_delay=0) + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + _LOGGER.debug( + "%s is added to hass", + self.entity_id, + ) + await super().async_added_to_hass() + + state = await self.async_get_last_state() + + # restore previous state + if state: + # restore attributes + if "arm_mode" in state.attributes: + self._arm_mode = state.attributes["arm_mode"] + if "changed_by" in state.attributes: + self._changed_by = state.attributes["changed_by"] + if "open_sensors" in state.attributes: + self.open_sensors = state.attributes["open_sensors"] + if "bypassed_sensors" in state.attributes: + self._bypassed_sensors = state.attributes["bypassed_sensors"] + if "last_triggered" in state.attributes: + self._last_triggered = state.attributes["last_triggered"] + + async def async_will_remove_from_hass(self): + """Disconnect entity object when removed.""" + await super().async_will_remove_from_hass() + _LOGGER.debug( + "%s is removed from hass", + self.entity_id, + ) + + +class AlarmoAreaEntity(AlarmoBaseEntity): + """Defines a base alarm_control_panel entity.""" + + def __init__( + self, hass: HomeAssistant, name: str, entity_id: str, area_id: str + ) -> None: + """Initialize the alarm_control_panel entity.""" + super().__init__(hass, name, entity_id) + + self.area_id = area_id + self._timer = None + coordinator = self.hass.data[const.DOMAIN]["coordinator"] + self._config = coordinator.store.async_get_config() + self._config.update(coordinator.store.async_get_area(self.area_id)) + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + if not self._config or const.ATTR_MODES not in self._config: + return 0 + else: + supported_features = AlarmControlPanelEntityFeature.TRIGGER + for mode, mode_config in self._config[const.ATTR_MODES].items(): + if mode_config[const.ATTR_ENABLED]: + supported_features = ( + supported_features | const.MODES_TO_SUPPORTED_FEATURES[mode] + ) + + return supported_features + + @property + def next_state(self): + """Return the state after transition (countdown) state.""" + next_state = self.state + if self._state == AlarmControlPanelState.ARMING: + next_state = self.arm_mode + elif self._state == AlarmControlPanelState.PENDING: + next_state = AlarmControlPanelState.TRIGGERED + elif self._state == AlarmControlPanelState.TRIGGERED: + if ( + not self._config + or not self._arm_mode + or not self._config[const.ATTR_MODES][self._arm_mode]["trigger_time"] + ): + next_state = AlarmControlPanelState.TRIGGERED + elif self._config[const.ATTR_DISARM_AFTER_TRIGGER] or not self.arm_mode: + next_state = AlarmControlPanelState.DISARMED + else: + next_state = self.arm_mode + return next_state + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + await super().async_added_to_hass() + + # make sure that the config is reloaded on changes + @callback + def async_update_config(area_id: str | None = None): + _LOGGER.debug("async_update_config") + coordinator = self.hass.data[const.DOMAIN]["coordinator"] + self._config = coordinator.store.async_get_config() + self._config.update(coordinator.store.async_get_area(self.area_id)) + self.schedule_update_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, "alarmo_config_updated", async_update_config + ) + ) + + # restore previous state + state = await self.async_get_last_state() + if state and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN): + initial_state = state.state + _LOGGER.debug( + "Initial state for %s is %s", + self.entity_id, + initial_state, + ) + if initial_state == AlarmControlPanelState.ARMING: + self.async_arm(self.arm_mode) + elif initial_state == AlarmControlPanelState.PENDING: + self.async_trigger() + elif initial_state == AlarmControlPanelState.TRIGGERED: + self.async_trigger(entry_delay=0) + else: + self.async_update_state(initial_state) + else: + self.async_update_state(AlarmControlPanelState.DISARMED) + + self.async_write_ha_state() + + @callback + def async_update_state(self, state: str | None = None): + """Update the state or refresh state attributes.""" + if state == self._state: + return + + old_state = self._state + self._state = state + + _LOGGER.debug( + "entity %s was updated from %s to %s", + self.entity_id, + old_state, + state, + ) + + if state in (*const.ARM_MODES, AlarmControlPanelState.DISARMED): + # cancel a running timer that is possibly running + # when transitioning from states arming, pending, or triggered + self.async_clear_timer() + + if self.state not in [ + AlarmControlPanelState.ARMING, + AlarmControlPanelState.PENDING, + ]: + self.delay = None + + if state in const.ARM_MODES: + self._arm_mode = state + self._revert_state = None + elif ( + old_state == AlarmControlPanelState.DISARMED + and state == AlarmControlPanelState.TRIGGERED + ): + self._arm_mode = None + + # perform state update of entity prior to executing dispatcher callbacks + # such that automations can use the updated state + self.schedule_update_ha_state() + + dispatcher_send( + self.hass, "alarmo_state_updated", self.area_id, old_state, state + ) + + def async_arm_failure(self, open_sensors: dict, context_id=None): + """Handle arm failure.""" + self._open_sensors = open_sensors + command = self._arm_mode.replace("armed", "arm") + + if self._state != self._revert_state and self._revert_state: + self.async_update_state(self._revert_state) + else: + # when disarmed, only update the attributes + if self._revert_state in const.ARM_MODES: + prev_arm_mode = self._arm_mode + self._arm_mode = self._revert_state + self._revert_state = prev_arm_mode + + self.schedule_update_ha_state() + + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_FAILED_TO_ARM, + self.area_id, + { + "open_sensors": open_sensors, + "command": command, + const.ATTR_CONTEXT_ID: context_id, + }, + ) + + @callback + def async_arm(self, arm_mode, **kwargs): + """Arm the alarm or switch between arm modes.""" + skip_delay = kwargs.get("skip_delay", False) + skip_validation = kwargs.get("skip_validation", False) + self._bypass_open_sensors = kwargs.get( + "bypass_open_sensors", self._bypass_open_sensors + ) + context_id = kwargs.get("context_id", None) + + self._arm_mode = arm_mode + exit_delay = int(self._config[const.ATTR_MODES][arm_mode]["exit_time"] or 0) + + if skip_delay or not exit_delay: + # immediate arm event + + (open_sensors, bypassed_sensors) = self.hass.data[const.DOMAIN][ + "sensor_handler" + ].validate_arming_event( + area_id=self.area_id, + target_state=arm_mode, + bypass_open_sensors=self._bypass_open_sensors, + ) + + if open_sensors and not skip_validation: + # there where errors -> abort the arm + _LOGGER.warning( + "Cannot transition from state %s to state %s, there are open sensors", # noqa: E501 + self._state, + arm_mode, + ) + self.async_arm_failure(open_sensors, context_id=context_id) + return False + else: + # proceed the arm + if bypassed_sensors: + self.bypassed_sensors = bypassed_sensors + self.open_sensors = open_sensors if open_sensors else None + if self.changed_by: + _LOGGER.info( + "Alarm '%s' is armed (%s) by %s.", + self.name, + arm_mode, + self.changed_by, + ) + else: + _LOGGER.info( + "Alarm '%s' is armed (%s).", + self.name, + arm_mode, + ) + if self._state and self._state != AlarmControlPanelState.ARMING: + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_ARM, + self.area_id, + { + "arm_mode": arm_mode, + "delay": 0, + const.ATTR_CONTEXT_ID: context_id, + }, + ) + self.async_update_state(arm_mode) + return True + + else: # normal arm event (from disarmed via arming) + (open_sensors, _bypassed_sensors) = self.hass.data[const.DOMAIN][ + "sensor_handler" + ].validate_arming_event( + area_id=self.area_id, + target_state=arm_mode, + use_delay=True, + bypass_open_sensors=self._bypass_open_sensors, + ) + + if open_sensors and not skip_validation: + # there where errors -> abort the arm + _LOGGER.warning("Cannot arm right now, there are open sensors") + self.async_arm_failure(open_sensors, context_id=context_id) + return False + else: + # proceed the arm + _LOGGER.info( + "Alarm is now arming. Waiting for %s seconds.", + exit_delay, + ) + + @callback + def async_leave_timer_finished(now): + """Update state at a scheduled point in time.""" + _LOGGER.debug("async_leave_timer_finished") + self.async_clear_timer() + self.async_arm(self.arm_mode, skip_delay=True) + + self.async_set_timer(exit_delay, async_leave_timer_finished) + self.delay = exit_delay + self.open_sensors = open_sensors if open_sensors else None + + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_ARM, + self.area_id, + { + "arm_mode": arm_mode, + "delay": exit_delay, + const.ATTR_CONTEXT_ID: context_id, + }, + ) + self.async_update_state(AlarmControlPanelState.ARMING) + + return True + + @callback + def async_trigger( # noqa PLR0912, PLR0915 + self, entry_delay: int | None = None, open_sensors: dict[str, str] | None = None + ): + """Trigger request. Can be called multiple times for timer shortening or immediate triggers.""" # noqa: E501 + if not self.arm_mode: + effective_entry_delay = 0 + else: + # Resolve entry_delay to actual value (None means use area default) + if entry_delay is None: + entry_delay = int( + self._config[const.ATTR_MODES][self.arm_mode]["entry_time"] or 0 + ) + + if entry_delay == 0: + # Immediate trigger (was skip_delay=True) + effective_entry_delay = 0 + elif self._state == AlarmControlPanelState.PENDING: + # Already pending - check for timer shortening + current_remaining = ( + (self.expiration - dt_util.utcnow()).total_seconds() + if self.expiration + else 0 + ) + if entry_delay < current_remaining: + # TIMER SHORTENING: Clear current timer, restart with shorter delay + _LOGGER.debug( + f"Timer shortened {current_remaining:.0f}s -> {entry_delay}s" + ) + # setting effective_entry_delay to provided delay + # timer will be updated below with async_set_timer + effective_entry_delay = entry_delay + else: + # Ignore longer delay while pending + # don't interfere with existing timer + _LOGGER.debug( + f"Ignoring longer delay {entry_delay}s while pending (current: {current_remaining:.0f}s remaining)" # noqa: E501 + ) + return + else: + # First trigger: use provided delay + effective_entry_delay = entry_delay + + if self.arm_mode: + trigger_time = int( + self._config[const.ATTR_MODES][self.arm_mode]["trigger_time"] or 0 + ) + else: + # if the alarm is not armed, take the maximum trigger_time of all modes + trigger_times = [] + for mode_config in self._config[const.ATTR_MODES].values(): + if mode_config[const.ATTR_ENABLED]: + trigger_times.append(int(mode_config["trigger_time"] or 0)) + trigger_time = 0 if 0 in trigger_times else max(trigger_times) + + if self._state and ( + self._state != AlarmControlPanelState.PENDING + or ( + self._state == AlarmControlPanelState.PENDING + and entry_delay == 0 + and open_sensors != self.open_sensors + ) + ): + # send event on trigger (includes timer shortening scenarios) + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_TRIGGER, + self.area_id, + { + "open_sensors": open_sensors + if open_sensors + else self._open_sensors, + "delay": effective_entry_delay, + }, + ) + + if open_sensors: + self.open_sensors = open_sensors + + if not effective_entry_delay: + # countdown finished or immediate trigger event + + if trigger_time: + # there is a max. trigger time configured + + @callback + def async_trigger_timer_finished(now): + """Update state at a scheduled point in time.""" + _LOGGER.debug("async_trigger_timer_finished") + self._changed_by = None + self.async_clear_timer() + if ( + self._config[const.ATTR_DISARM_AFTER_TRIGGER] + or not self.arm_mode + ): + self.bypassed_sensors = None + self.async_update_state(AlarmControlPanelState.DISARMED) + elif self._config[const.ATTR_IGNORE_BLOCKING_SENSORS_AFTER_TRIGGER]: + self.open_sensors = None + self.async_arm( + self.arm_mode, skip_validation=True, skip_delay=True + ) + else: + self.open_sensors = None + self._revert_state = AlarmControlPanelState.DISARMED + self.async_arm( + self.arm_mode, bypass_open_sensors=False, skip_delay=True + ) + + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_TRIGGER_TIME_EXPIRED, + self.area_id, + {}, + ) + + self.async_set_timer(trigger_time, async_trigger_timer_finished) + else: + # clear previous timer when transitioning from pending state + self.async_clear_timer() + + _LOGGER.warning("Alarm is triggered!") + self.async_update_state(AlarmControlPanelState.TRIGGERED) + self._last_triggered = dt_util.now().strftime("%Y-%m-%d %H:%M:%S") + + else: # to pending state + + @callback + def async_entry_timer_finished(now): + """Update state at a scheduled point in time.""" + self.async_clear_timer() + + _LOGGER.debug("async_entry_timer_finished") + self.async_trigger(entry_delay=0) + + self.async_set_timer(effective_entry_delay, async_entry_timer_finished) + entry_delay_changed = self.delay and self.delay != effective_entry_delay + self.delay = effective_entry_delay + _LOGGER.info( + "Alarm will be triggered after %s seconds.", + effective_entry_delay, + ) + + if self._state == AlarmControlPanelState.PENDING and entry_delay_changed: + # trigger HA entity state+attributes refresh + # as async_update_state will not have any effect + self.schedule_update_ha_state() + else: + self.async_update_state(AlarmControlPanelState.PENDING) + + def async_clear_timer(self): + """Clear a running timer.""" + if self._timer: + self._timer() + self._timer = None + + def async_set_timer(self, delay: int | datetime.timedelta, cb_func: callable): + """Set a timer to call the provided callback after the specified delay.""" + self.async_clear_timer() + now = dt_util.utcnow() + + if not isinstance(delay, datetime.timedelta): + delay = datetime.timedelta(seconds=delay) + + self._timer = async_track_point_in_time(self.hass, cb_func, now + delay) + + def update_ready_to_arm_modes(self, value): + """Set arm modes which are ready for arming (no blocking sensors).""" + if value == self._ready_to_arm_modes: + return + _LOGGER.debug( + "ready_to_arm_modes for %s updated to %s", + self.name, + ", ".join(value).replace("armed_", ""), + ) + self._ready_to_arm_modes = value + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_READY_TO_ARM_MODES_CHANGED, + self.area_id, + {const.ATTR_MODES: value}, + ) + + +class AlarmoMasterEntity(AlarmoBaseEntity): + """Defines a base alarm_control_panel entity.""" + + def __init__(self, hass: HomeAssistant, name: str, entity_id: str) -> None: + """Initialize the alarm_control_panel entity.""" + super().__init__(hass, name, entity_id) + self.area_id = None + self._target_state = None + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + supported_features = [ + item.supported_features or 0 + for item in self.hass.data[const.DOMAIN]["areas"].values() + ] + return functools.reduce(operator.and_, supported_features) + + @property + def next_state(self): + """Return the state after transition (countdown) state.""" + next_states = list( + set( + [ + item.next_state + for item in self.hass.data[const.DOMAIN]["areas"].values() + ] + ) + ) + + next_state = self.state + if len(next_states) == 1: + next_state = next_states[0] + elif AlarmControlPanelState.TRIGGERED in next_states: + next_state = AlarmControlPanelState.TRIGGERED + + return next_state + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + await super().async_added_to_hass() + + # load the configuration and make sure that it is reloaded on changes + @callback + def async_update_config(area_id=None): + if area_id and area_id in self.hass.data[const.DOMAIN]["areas"]: + # wait for update of the area entity, to refresh the supported_features + async_call_later(self.hass, 1, async_update_config) + return + + coordinator = self.hass.data[const.DOMAIN]["coordinator"] + self._config = coordinator.store.async_get_config() + + self.async_update_state() + self.schedule_update_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.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 area_id: + return + self.async_update_state() + + 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 area_id or event not in [ + const.EVENT_FAILED_TO_ARM, + const.EVENT_TRIGGER, + const.EVENT_TRIGGER_TIME_EXPIRED, + const.EVENT_READY_TO_ARM_MODES_CHANGED, + ]: + return + if event == const.EVENT_FAILED_TO_ARM and self._target_state is not None: + open_sensors = args["open_sensors"] + self.async_arm_failure(open_sensors) + if event == const.EVENT_TRIGGER and ( + self._state + not in [ + AlarmControlPanelState.TRIGGERED, + AlarmControlPanelState.PENDING, + ] + or ( + self._state == AlarmControlPanelState.PENDING + and self.delay + and self.delay > args.get("delay", 0) + ) + ): + # only pass initial trigger event + # or while trigger with shorter entry delay occurs during entry time + dispatcher_send( + self.hass, "alarmo_event", const.EVENT_TRIGGER, self.area_id, args + ) + if event == const.EVENT_TRIGGER_TIME_EXPIRED: + if ( + self.hass.data[const.DOMAIN]["areas"][area_id].state + == AlarmControlPanelState.DISARMED + ): + self.alarm_disarm(skip_code=True) + if event == const.EVENT_READY_TO_ARM_MODES_CHANGED: + self.update_ready_to_arm_modes() + + async_dispatcher_connect(self.hass, "alarmo_event", async_handle_event) + + state = await self.async_get_last_state() + if state and state.state: + self._state = state.state + else: + self._state = AlarmControlPanelState.DISARMED + self.async_write_ha_state() + + @callback + def async_update_state(self, state: str | None = None): # noqa PLR0912, PLR0915 + """Update the state or refresh state attributes.""" + if state: + # do not allow updating the state directly + return + + states = [item.state for item in self.hass.data[const.DOMAIN]["areas"].values()] + state = None + if AlarmControlPanelState.TRIGGERED in states: + state = AlarmControlPanelState.TRIGGERED + elif AlarmControlPanelState.PENDING in states: + state = AlarmControlPanelState.PENDING + elif AlarmControlPanelState.ARMING in states and all( + el in const.ARM_MODES or el == AlarmControlPanelState.ARMING + for el in states + ): + state = AlarmControlPanelState.ARMING + elif all(el == AlarmControlPanelState.ARMED_AWAY for el in states): + state = AlarmControlPanelState.ARMED_AWAY + elif all(el == AlarmControlPanelState.ARMED_HOME for el in states): + state = AlarmControlPanelState.ARMED_HOME + elif all(el == AlarmControlPanelState.ARMED_NIGHT for el in states): + state = AlarmControlPanelState.ARMED_NIGHT + elif all(el == AlarmControlPanelState.ARMED_CUSTOM_BYPASS for el in states): + state = AlarmControlPanelState.ARMED_CUSTOM_BYPASS + elif all(el == AlarmControlPanelState.ARMED_VACATION for el in states): + state = AlarmControlPanelState.ARMED_VACATION + elif all(el == AlarmControlPanelState.DISARMED for el in states): + state = AlarmControlPanelState.DISARMED + + arm_modes = [ + item._arm_mode for item in self.hass.data[const.DOMAIN]["areas"].values() + ] + arm_mode = arm_modes[0] if len(set(arm_modes)) == 1 else None + + if state == self._target_state: + # we are transitioning to an armed state and target state is reached + self._target_state = None + + if state in [AlarmControlPanelState.ARMING, AlarmControlPanelState.PENDING]: + # one or more areas went to arming/pending state, recalculate the delay time + + area_filter = dict( + filter( + lambda el: el[1].state == state, + self.hass.data[const.DOMAIN]["areas"].items(), + ) + ) + delays = [el.delay for el in area_filter.values()] + + # use maximum of all areas when arming, minimum of all areas when pending + delay = ( + max(delays) + if state == AlarmControlPanelState.ARMING + else min(delays) + if delays + else None + ) + else: + delay = None + + # take open sensors by combining areas having same state + open_sensors = {} + area_filter = dict( + filter( + lambda el: el[1].state == state, + self.hass.data[const.DOMAIN]["areas"].items(), + ) + ) + for item in area_filter.values(): + if item.open_sensors: + open_sensors.update(item.open_sensors) + + if ( + arm_mode == self._arm_mode + and (state == self._state or not state) + and delay == self.delay + and open_sensors == self.open_sensors + ): + # do not update if state and properties remain unchanged + return + + self._arm_mode = arm_mode + self.delay = delay + self.open_sensors = open_sensors + + old_state = self._state + new_state = state if state != self._state else None + + if new_state: + self._state = new_state + _LOGGER.debug( + "entity %s was updated from %s to %s", + self.entity_id, + old_state, + new_state, + ) + + if new_state == AlarmControlPanelState.TRIGGERED: + self._last_triggered = dt_util.now().strftime("%Y-%m-%d %H:%M:%S") + + # take bypassed sensors by combining all areas + bypassed_sensors = [] + for item in self.hass.data[const.DOMAIN]["areas"].values(): + if item.bypassed_sensors: + bypassed_sensors.extend(item.bypassed_sensors) + self.bypassed_sensors = bypassed_sensors + + self.update_ready_to_arm_modes() + + self.schedule_update_ha_state() + + # perform state update of entity prior to executing dispatcher callbacks + # such that automations can use the updated state + if new_state: + dispatcher_send( + self.hass, "alarmo_state_updated", None, old_state, new_state + ) + + async def async_alarm_disarm(self, code=None, **kwargs): + """Send disarm command.""" + skip_code = kwargs.get("skip_code", False) + context_id = kwargs.get("context_id", None) + + # when skip_code is False, validate the code before disarming + if not skip_code: + (res, info) = await self._validate_code( + code, AlarmControlPanelState.DISARMED + ) + if not res: + dispatcher_send( + self.hass, + "alarmo_event", + info, + self.area_id, + { + const.ATTR_CONTEXT_ID: context_id, + "command": const.COMMAND_DISARM, + }, + ) + _LOGGER.warning("Wrong code provided.") + return False + if skip_code or res: + # code has been validated or skip_code is True, proceed with disarm + if not await super().async_alarm_disarm( + code=code, skip_code=True, context_id=context_id + ): + _LOGGER.error("Failed to disarm master alarm.") + return False + for item in self.hass.data[const.DOMAIN]["areas"].values(): + if item.state != AlarmControlPanelState.DISARMED: + await item.async_alarm_disarm( + code=code, skip_code=True, context_id=context_id + ) + + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_DISARM, + self.area_id, + {const.ATTR_CONTEXT_ID: context_id}, + ) + return True + return False + + def async_arm(self, arm_mode, **kwargs): + """Arm the alarm or switch between arm modes.""" + skip_delay = kwargs.get("skip_delay", False) + bypass_open_sensors = kwargs.get("bypass_open_sensors", False) + context_id = kwargs.get("context_id", None) + self._target_state = arm_mode + + open_sensors = {} + for item in self.hass.data[const.DOMAIN]["areas"].values(): + if ( + (item.state in const.ARM_MODES and item.arm_mode != arm_mode) + or item.state == AlarmControlPanelState.DISARMED + or (item.state == AlarmControlPanelState.ARMING and skip_delay) + ): + item._revert_state = ( + item._state + if item._state in const.ARM_MODES + else AlarmControlPanelState.DISARMED + ) + res = item.async_arm( + arm_mode, + skip_delay=skip_delay, + bypass_open_sensors=bypass_open_sensors, + ) + if not res: + open_sensors.update(item.open_sensors) + + if open_sensors: + self.async_arm_failure(open_sensors, context_id=context_id) + else: + delay = 0 + area_config = self.hass.data[const.DOMAIN][ + "coordinator" + ].store.async_get_areas() + for area_id, entity in self.hass.data[const.DOMAIN]["areas"].items(): + if entity.state == AlarmControlPanelState.ARMING: + t = area_config[area_id][const.ATTR_MODES][arm_mode]["exit_time"] + delay = t if int(t or 0) > delay else delay + + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_ARM, + self.area_id, + { + "arm_mode": arm_mode, + "delay": delay, + const.ATTR_CONTEXT_ID: context_id, + }, + ) + + def async_arm_failure(self, open_sensors: dict, context_id=None): + """Handle arm failure.""" + self.open_sensors = open_sensors + command = self._target_state.replace("armed", "arm") + + for item in self.hass.data[const.DOMAIN]["areas"].values(): + if item.state != self._revert_state and self._revert_state: + item.async_update_state(self._revert_state) + + self._revert_state = self._target_state + self._target_state = None + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_FAILED_TO_ARM, + None, + { + "open_sensors": open_sensors, + "command": command, + const.ATTR_CONTEXT_ID: context_id, + }, + ) + self.schedule_update_ha_state() + + @callback + def async_trigger( + self, + entry_delay: int | None = None, + open_sensors: dict[str, str] | None = None, + ): + """Handle triggering via service call.""" + for item in self.hass.data[const.DOMAIN]["areas"].values(): + if item.state != self._revert_state: + item.async_trigger(entry_delay=entry_delay, open_sensors=open_sensors) + + def update_ready_to_arm_modes(self): + """Set arm modes which are ready for arming (no blocking sensors).""" + modes_list = const.ARM_MODES + modes_list = list(filter(lambda x: x != self._state, modes_list)) + for item in self.hass.data[const.DOMAIN]["areas"].values(): + modes_list = list( + filter( + lambda x: x in item._ready_to_arm_modes or x == item.state, + modes_list, + ) + ) + if modes_list == self._ready_to_arm_modes: + return + self._ready_to_arm_modes = modes_list + _LOGGER.debug( + "ready_to_arm_modes for master updated to %s", + ", ".join(modes_list).replace("armed_", ""), + ) + dispatcher_send( + self.hass, + "alarmo_event", + const.EVENT_READY_TO_ARM_MODES_CHANGED, + self.area_id, + {const.ATTR_MODES: modes_list}, + ) diff --git a/custom_components/alarmo/automations.py b/custom_components/alarmo/automations.py new file mode 100644 index 0000000..1fd3482 --- /dev/null +++ b/custom_components/alarmo/automations.py @@ -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 "" diff --git a/custom_components/alarmo/card.py b/custom_components/alarmo/card.py new file mode 100644 index 0000000..96f6cee --- /dev/null +++ b/custom_components/alarmo/card.py @@ -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) diff --git a/custom_components/alarmo/config_flow.py b/custom_components/alarmo/config_flow.py new file mode 100644 index 0000000..9de783d --- /dev/null +++ b/custom_components/alarmo/config_flow.py @@ -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={}) diff --git a/custom_components/alarmo/const.py b/custom_components/alarmo/const.py new file mode 100644 index 0000000..fad961b --- /dev/null +++ b/custom_components/alarmo/const.py @@ -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, + } +) diff --git a/custom_components/alarmo/event.py b/custom_components/alarmo/event.py new file mode 100644 index 0000000..b8e1d46 --- /dev/null +++ b/custom_components/alarmo/event.py @@ -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) diff --git a/custom_components/alarmo/frontend/dist/alarm-panel.js b/custom_components/alarmo/frontend/dist/alarm-panel.js new file mode 100644 index 0000000..31c9f90 --- /dev/null +++ b/custom_components/alarmo/frontend/dist/alarm-panel.js @@ -0,0 +1,3390 @@ +var e=function(a,t){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,a){e.__proto__=a}||function(e,a){for(var t in a)Object.prototype.hasOwnProperty.call(a,t)&&(e[t]=a[t])},e(a,t)};function a(a,t){if("function"!=typeof t&&null!==t)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");function i(){this.constructor=a}e(a,t),a.prototype=null===t?Object.create(t):(i.prototype=t.prototype,new i)}var t=function(){return t=Object.assign||function(e){for(var a,t=1,i=arguments.length;t=0;o--)(n=e[o])&&(r=(s<3?n(r):s>3?n(a,t,r):n(a,t))||r);return s>3&&r&&Object.defineProperty(a,t,r),r}function n(e,a,t){if(t||2===arguments.length)for(var i,n=0,s=a.length;n{const t=1===e.length?e[0]:a.reduce((a,t,i)=>a+(e=>{if(!0===e._$cssResult$)return e.cssText;if("number"==typeof e)return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(t)+e[i+1],e[0]);return new l(t,e,o)},m=r?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let a="";for(const t of e.cssRules)a+=t.cssText;return(e=>new l("string"==typeof e?e:e+"",void 0,o))(a)})(e):e,{is:h,defineProperty:p,getOwnPropertyDescriptor:g,getOwnPropertyNames:u,getOwnPropertySymbols:v,getPrototypeOf:_}=Object,b=globalThis,f=b.trustedTypes,y=f?f.emptyScript:"",k=b.reactiveElementPolyfillSupport,w=(e,a)=>e,z={toAttribute(e,a){switch(a){case Boolean:e=e?y:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,a){let t=e;switch(a){case Boolean:t=null!==e;break;case Number:t=null===e?null:Number(e);break;case Object:case Array:try{t=JSON.parse(e)}catch(e){t=null}}return t}},A=(e,a)=>!h(e,a),j={attribute:!0,type:String,converter:z,reflect:!1,useDefault:!1,hasChanged:A}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */Symbol.metadata??=Symbol("metadata"),b.litPropertyMetadata??=new WeakMap;let $=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,a=j){if(a.state&&(a.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((a=Object.create(a)).wrapped=!0),this.elementProperties.set(e,a),!a.noAccessor){const t=Symbol(),i=this.getPropertyDescriptor(e,t,a);void 0!==i&&p(this.prototype,e,i)}}static getPropertyDescriptor(e,a,t){const{get:i,set:n}=g(this.prototype,e)??{get(){return this[a]},set(e){this[a]=e}};return{get:i,set(a){const s=i?.call(this);n?.call(this,a),this.requestUpdate(e,s,t)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??j}static _$Ei(){if(this.hasOwnProperty(w("elementProperties")))return;const e=_(this);e.finalize(),void 0!==e.l&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(w("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(w("properties"))){const e=this.properties,a=[...u(e),...v(e)];for(const t of a)this.createProperty(t,e[t])}const e=this[Symbol.metadata];if(null!==e){const a=litPropertyMetadata.get(e);if(void 0!==a)for(const[e,t]of a)this.elementProperties.set(e,t)}this._$Eh=new Map;for(const[e,a]of this.elementProperties){const t=this._$Eu(e,a);void 0!==t&&this._$Eh.set(t,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){const a=[];if(Array.isArray(e)){const t=new Set(e.flat(1/0).reverse());for(const e of t)a.unshift(m(e))}else void 0!==e&&a.push(m(e));return a}static _$Eu(e,a){const t=a.attribute;return!1===t?void 0:"string"==typeof t?t:"string"==typeof e?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),void 0!==this.renderRoot&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){const e=new Map,a=this.constructor.elementProperties;for(const t of a.keys())this.hasOwnProperty(t)&&(e.set(t,this[t]),delete this[t]);e.size>0&&(this._$Ep=e)}createRenderRoot(){const e=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((e,a)=>{if(r)e.adoptedStyleSheets=a.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(const t of a){const a=document.createElement("style"),i=s.litNonce;void 0!==i&&a.setAttribute("nonce",i),a.textContent=t.cssText,e.appendChild(a)}})(e,this.constructor.elementStyles),e}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,a,t){this._$AK(e,t)}_$ET(e,a){const t=this.constructor.elementProperties.get(e),i=this.constructor._$Eu(e,t);if(void 0!==i&&!0===t.reflect){const n=(void 0!==t.converter?.toAttribute?t.converter:z).toAttribute(a,t.type);this._$Em=e,null==n?this.removeAttribute(i):this.setAttribute(i,n),this._$Em=null}}_$AK(e,a){const t=this.constructor,i=t._$Eh.get(e);if(void 0!==i&&this._$Em!==i){const e=t.getPropertyOptions(i),n="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==e.converter?.fromAttribute?e.converter:z;this._$Em=i;const s=n.fromAttribute(a,e.type);this[i]=s??this._$Ej?.get(i)??s,this._$Em=null}}requestUpdate(e,a,t){if(void 0!==e){const i=this.constructor,n=this[e];if(t??=i.getPropertyOptions(e),!((t.hasChanged??A)(n,a)||t.useDefault&&t.reflect&&n===this._$Ej?.get(e)&&!this.hasAttribute(i._$Eu(e,t))))return;this.C(e,a,t)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,a,{useDefault:t,reflect:i,wrapped:n},s){t&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,s??a??this[e]),!0!==n||void 0!==s)||(this._$AL.has(e)||(this.hasUpdated||t||(a=void 0),this._$AL.set(e,a)),!0===i&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[e,a]of this._$Ep)this[e]=a;this._$Ep=void 0}const e=this.constructor.elementProperties;if(e.size>0)for(const[a,t]of e){const{wrapped:e}=t,i=this[a];!0!==e||this._$AL.has(a)||void 0===i||this.C(a,void 0,t,i)}}let e=!1;const a=this._$AL;try{e=this.shouldUpdate(a),e?(this.willUpdate(a),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(a)):this._$EM()}catch(a){throw e=!1,this._$EM(),a}e&&this._$AE(a)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};$.elementStyles=[],$.shadowRootOptions={mode:"open"},$[w("elementProperties")]=new Map,$[w("finalized")]=new Map,k?.({ReactiveElement:$}),(b.reactiveElementVersions??=[]).push("2.1.1"); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const T=globalThis,E=T.trustedTypes,S=E?E.createPolicy("lit-html",{createHTML:e=>e}):void 0,C="$lit$",O=`lit$${Math.random().toFixed(9).slice(2)}$`,M="?"+O,x=`<${M}>`,N=document,L=()=>N.createComment(""),H=e=>null===e||"object"!=typeof e&&"function"!=typeof e,D=Array.isArray,P="[ \t\n\f\r]",B=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,q=/-->/g,V=/>/g,I=RegExp(`>|${P}(?:([^\\s"'>=/]+)(${P}*=${P}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),R=/'/g,U=/"/g,G=/^(?:script|style|textarea|title)$/i,K=(e=>(a,...t)=>({_$litType$:e,strings:a,values:t}))(1),F=Symbol.for("lit-noChange"),Z=Symbol.for("lit-nothing"),Q=new WeakMap,Y=N.createTreeWalker(N,129);function W(e,a){if(!D(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==S?S.createHTML(a):a}const X=(e,a)=>{const t=e.length-1,i=[];let n,s=2===a?"":3===a?"":"",r=B;for(let a=0;a"===d[0]?(r=n??B,l=-1):void 0===d[1]?l=-2:(l=r.lastIndex-d[2].length,o=d[1],r=void 0===d[3]?I:'"'===d[3]?U:R):r===U||r===R?r=I:r===q||r===V?r=B:(r=I,n=void 0);const m=r===I&&e[a+1].startsWith("/>")?" ":"";s+=r===B?t+x:l>=0?(i.push(o),t.slice(0,l)+C+t.slice(l)+O+m):t+O+(-2===l?a:m)}return[W(e,s+(e[t]||"")+(2===a?"":3===a?"":"")),i]};class J{constructor({strings:e,_$litType$:a},t){let i;this.parts=[];let n=0,s=0;const r=e.length-1,o=this.parts,[d,l]=X(e,a);if(this.el=J.createElement(d,t),Y.currentNode=this.el.content,2===a||3===a){const e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;null!==(i=Y.nextNode())&&o.length0){i.textContent=E?E.emptyScript:"";for(let t=0;tD(e)||"function"==typeof e?.[Symbol.iterator])(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==Z&&H(this._$AH)?this._$AA.nextSibling.data=e:this.T(N.createTextNode(e)),this._$AH=e}$(e){const{values:a,_$litType$:t}=e,i="number"==typeof t?this._$AC(e):(void 0===t.el&&(t.el=J.createElement(W(t.h,t.h[0]),this.options)),t);if(this._$AH?._$AD===i)this._$AH.p(a);else{const e=new ae(i,this),t=e.u(this.options);e.p(a),this.T(t),this._$AH=e}}_$AC(e){let a=Q.get(e.strings);return void 0===a&&Q.set(e.strings,a=new J(e)),a}k(e){D(this._$AH)||(this._$AH=[],this._$AR());const a=this._$AH;let t,i=0;for(const n of e)i===a.length?a.push(t=new te(this.O(L()),this.O(L()),this,this.options)):t=a[i],t._$AI(n),i++;i2||""!==t[0]||""!==t[1]?(this._$AH=Array(t.length-1).fill(new String),this.strings=t):this._$AH=Z}_$AI(e,a=this,t,i){const n=this.strings;let s=!1;if(void 0===n)e=ee(this,e,a,0),s=!H(e)||e!==this._$AH&&e!==F,s&&(this._$AH=e);else{const i=e;let r,o;for(e=n[0],r=0;r{const i=t?.renderBefore??a;let n=i._$litPart$;if(void 0===n){const e=t?.renderBefore??null;i._$litPart$=n=new te(a.insertBefore(L(),e),e,void 0,t??{})}return n._$AI(e),n})(a,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return F}};ce._$litElement$=!0,ce.finalized=!0,le.litElementHydrateSupport?.({LitElement:ce});const me=le.litElementPolyfillSupport;me?.({LitElement:ce}),(le.litElementVersions??=[]).push("4.2.1"); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const he=e=>(a,t)=>{void 0!==t?t.addInitializer(()=>{customElements.define(e,a)}):customElements.define(e,a)},pe={attribute:!0,type:String,converter:z,reflect:!1,hasChanged:A},ge=(e=pe,a,t)=>{const{kind:i,metadata:n}=t;let s=globalThis.litPropertyMetadata.get(n);if(void 0===s&&globalThis.litPropertyMetadata.set(n,s=new Map),"setter"===i&&((e=Object.create(e)).wrapped=!0),s.set(t.name,e),"accessor"===i){const{name:i}=t;return{set(t){const n=a.get.call(this);a.set.call(this,t),this.requestUpdate(i,n,e)},init(a){return void 0!==a&&this.C(i,void 0,e,a),a}}}if("setter"===i){const{name:i}=t;return function(t){const n=this[i];a.call(this,t),this.requestUpdate(i,n,e)}}throw Error("Unsupported decorator location: "+i)}; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function ue(e){return(a,t)=>"object"==typeof t?ge(e,a,t):((e,a,t)=>{const i=a.hasOwnProperty(t);return a.constructor.createProperty(t,e),i?Object.getOwnPropertyDescriptor(a,t):void 0})(e,a,t)} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */function ve(e){return ue({...e,state:!0,attribute:!1})} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +function _e(e,a){return(a,t,i)=>{const n=a=>a.renderRoot?.querySelector(e)??null;{const{get:e,set:s}="object"==typeof t?a:i??(()=>{const e=Symbol();return{get(){return this[e]},set(a){this[e]=a}}})();return((e,a,t)=>(t.configurable=!0,t.enumerable=!0,Reflect.decorate&&"object"!=typeof a&&Object.defineProperty(e,a,t),t))(a,t,{get(){let a=e.call(this);return void 0===a&&(a=n(this),(null!==a||this.hasUpdated)&&s.call(this,a)),a}})}}}const be=async()=>{if(customElements.get("ha-checkbox")&&customElements.get("ha-slider")&&customElements.get("ha-panel-config"))return;await customElements.whenDefined("partial-panel-resolver");const e=document.createElement("partial-panel-resolver");e.hass={panels:[{url_path:"tmp",component_name:"config"}]},e._updateRoutes(),await e.routerOptions.routes.tmp.load(),await customElements.whenDefined("ha-panel-config");const a=document.createElement("ha-panel-config");await a.routerOptions.routes.automation.load()},fe=async()=>{var e,a,t,i,n,s,r;if(customElements.get("ha-yaml-editor"))return;const o=document.createElement("partial-panel-resolver").getRoutes([{component_name:"developer-tools",url_path:"a"}]);await(null===(t=null===(a=null===(e=null==o?void 0:o.routes)||void 0===e?void 0:e.a)||void 0===a?void 0:a.load)||void 0===t?void 0:t.call(a));const d=document.createElement("developer-tools-router");await(null===(r=null===(s=null===(n=null===(i=null==d?void 0:d.routerOptions)||void 0===i?void 0:i.routes)||void 0===n?void 0:n.service)||void 0===s?void 0:s.load)||void 0===r?void 0:r.call(s))},ye=e=>e.callWS({type:"alarmo/config"}),ke=e=>e.callWS({type:"alarmo/sensors"}),we=e=>e.callWS({type:"alarmo/users"}),ze=e=>e.callWS({type:"alarmo/automations"}),Ae=e=>e.callWS({type:"alarmo/sensor_groups"}),je=(e,a)=>e.callApi("POST","alarmo/config",a),$e=(e,a)=>e.callApi("POST","alarmo/sensors",a),Te=(e,a)=>e.callApi("POST","alarmo/users",a),Ee=(e,a)=>e.callApi("POST","alarmo/automations",a),Se=(e,a)=>e.callApi("POST","alarmo/automations",{automation_id:a,remove:!0}),Ce=e=>e.callWS({type:"alarmo/areas"}),Oe=(e,a)=>e.callApi("POST","alarmo/area",a),Me=e=>{class a extends e{connectedCallback(){super.connectedCallback(),this.__checkSubscribed()}disconnectedCallback(){if(super.disconnectedCallback(),this.__unsubs){for(;this.__unsubs.length;){const e=this.__unsubs.pop();e instanceof Promise?e.then(e=>e()):e()}this.__unsubs=void 0}}updated(e){super.updated(e),e.has("hass")&&this.__checkSubscribed()}hassSubscribe(){return[]}__checkSubscribed(){void 0===this.__unsubs&&this.isConnected&&void 0!==this.hass&&(this.__unsubs=this.hassSubscribe())}}return i([ue({attribute:!1})],a.prototype,"hass",void 0),a};var xe={modes_short:{armed_away:"Weg",armed_home:"Tuis",armed_night:"Nag",armed_custom_bypass:"Pasgemaak",armed_vacation:"Vakansie"},enabled:"Gestel",disabled:"Afgesit"},Ne={time_picker:{seconds:"sekondes",minutes:"minute"},editor:{ui_mode:"Na UI",yaml_mode:"Na YAML",edit_in_yaml:"Werk by in YAML"},table:{filter:{label:"Filter items",item:"Filter volgens {name}",hidden_items:"{number} {number, plural,\n one {item is}\n other {items is}\n} versteek"}}},Le="Alarm-paneel",He={general:{title:"Algemeen",cards:{general:{description:"Hierdie paneel definieer globale verstellings vir die alarm.",fields:{disarm_after_trigger:{heading:"Sit af na voorval",description:"Sit die alarm outomaties af na 'n voorval, eerder as om terug te gaan na die geaktiveerde status."},ignore_blocking_sensors_after_trigger:{heading:"Ignoreer aktiewe sensors voor aktivering",description:"Aktiveer weer die alarm sonder om seker te maak dat daar nog aktiewe sensors is."},enable_mqtt:{heading:"Aktiveer MQTT",description:"Laat toe dat die alarm-paneel deur MQTT beheer kan word."},enable_master:{heading:"Aktiveer meester alarm",description:"Skep 'n entiteit om alle areas gelyk te beheer."}},actions:{setup_mqtt:"MQTT-opstelling",setup_master:"Meester-opstelling"}},modes:{title:"Modusse",description:"Hierdie paneel kan gebruik word om verskeie modusse te aktiveer.",modes:{armed_away:"Gestel | Weg sal gebruik word wanneer niemand tuis is nie. Alle deure en vensters wat toegang tot die huis toelaat sal aktief wees, asook enige bewegingsensors binne die huis.",armed_home:"Gestel | Tuis sal gebruik word as die alarm geaktiveer word terwyl daar mense in die huis is. Alle deure en vensters wat toegang tot die huis toelaat sal aktief wees, maar geen bewegingsensors binne die huis nie.",armed_night:"Gestel | Nag sal gebruik word wanneer die alarm geaktiveer word voor slapenstyd. Al die deure en vensters wat toegang tot die huis toelaat sal aktief wees, asook sekere bewegingsensors in die huis.",armed_vacation:"Gestel | Vakansie sal gebruik word as uitbreiding van Gestel | Weg-modus in die geval dat die huis vir langer tye leeg is. Die wagperiodes en sneller-responses kan daarvolgens aangepas word.",armed_custom_bypass:"An extra mode for defining your own security perimeter."},number_sensors_active:"{number} {number, plural,\n one {sensor}\n other {sensors}\n} aktief",fields:{status:{heading:"Status",description:"Bevestig of die alarm geaktiveer kan word in die betrokke modus."},exit_delay:{heading:"Aktiveer-wagperiode",description:"Wanneer die alarm geaktiveer word, sal die sensors binne hierdie periode nie aktief wees nie."},entry_delay:{heading:"Afsit-wagpeiode",description:"Wagperiode voordat die alarm 'n voorval registreer nadat 'n sensor aktief is."},trigger_time:{heading:"Voorval-tydperk",description:"Periode waartydens die alarm in voorval-modues sal bly nadat 'n voorval geregistreer is."}}},mqtt:{title:"MQTT-opstelling",description:"Hierdie paneel kan gebruik word om MQTT op te stel.",fields:{state_topic:{heading:"State topic",description:"Topic waarop statusse gepubliseer word"},event_topic:{heading:"Event topic",description:"Topic waarop events gepubliseer word"},command_topic:{heading:"Command topic",description:"Topic waarop commands gepubliseer word"},require_code:{heading:"Vereis kode",description:"Vereis die kode om saam met die command gestuur te word"},state_payload:{heading:"Stel payload per status op",item:"Definieer 'n payload vir status ''{state}''"},command_payload:{heading:"Stel payload op per command",item:"Definieer 'n payload vir command ''{command}''"}}},areas:{title:"Areas",description:"Areas kan gebruik word om jou huis is afdelings op te deel.",no_items:"Daar is nog geen areas gedefinieer nie.",table:{remarks:"Opmerkings",summary:"Hierdie area bevat {summary_sensors} en {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {sensor}\n other {sensors}\n}",summary_automations:"{number} {number, plural,\n one {outomatisasie}\n other {outomatisasies}\n}"},actions:{add:"Voeg by"}}},dialogs:{create_area:{title:"Nuwe area",fields:{copy_from:"Dupliseer verstellings vanaf"}},edit_area:{title:"Bywerk van area ''{area}''",name_warning:"Let wel! Deur die naam te verander gaan die enditeit-id ook verander"},remove_area:{title:"Verwyder area?",description:"Is jy seker jy wil die area verwyder? Hierdie area bevat {sensors} sensors en {automations} automations, wat ook verwyder gaan word."},edit_master:{title:"Meester-opstelling"},disable_master:{title:"Sit meester af?",description:"Is jy seker jy wil meester verwyder? Hierdie area bevat {automations} automations, wat ook verwyder gaan word."}}},sensors:{title:"Sensors",cards:{sensors:{description:"Huidig-opgestelde sensors. Kies 'n item om veranderinge te maak.",table:{no_items:"Daar is geen sensors wat hier gewys kan word nie.",no_area_warning:"Sensor is aan geen area gekoppel nie.",arm_modes:"Aktiveer-modusse",always_on:"(Altyd)"}},add_sensors:{title:"Boeg sensors by",description:"Voeg nog sensors by. Maak seker dat jou sensors se name gepas is sodat jy hulle kan identifiseer.",no_items:"Daar is geen HA-entiteite war vir die alarm opgestel kan word nie. Maak seker om entiteit-tipe, binary_sensor in te sluit.",table:{type:"Tipe opgetel"},actions:{add_to_alarm:"Voeg by die alarm",filter_supported:"Versteek items van onbekende tipes."}},editor:{title:"Verstel Sensor",description:"Stel die sensor-verstellings op vir ''{entity}''.",fields:{entity:{heading:"Entiteit",description:"Entiteit geassosieer met die sensor"},area:{heading:"Area",description:"Kies die area waar die sensor is."},group:{heading:"Groep",description:"Groepeer saam met ander sensors vir gekombineerde voorvalregistrasie."},device_type:{heading:"Toestel-tipe",description:"Kies die tipe toestel om die opstelling van beste gepas aan te wend.",choose:{door:{name:"Deur",description:"'n Deur, hek of ander toegangspunt vir die huis."},window:{name:"Venster",description:"'n Venster of deur wat nie vir toegang gebruik word nie, soos balkon skuifdeure"},motion:{name:"Beweging",description:"Bewegingsensor of soortgelyk, wat 'n wagperiode het tussen voorvalle."},tamper:{name:"Peuter",description:"Sensor om die verwyder van bedekking, breek van glas ens. op te tel."},environmental:{name:"Omgewing",description:"Rook/gas-sensor, optel van waterlekke ens."},other:{name:"Generies"}}},always_on:{heading:"Altyd aan",description:"Sensor should always trigger the alarm."},modes:{heading:"Aktiewe modusse",description:"Alarm modes in which this sensor is active."},arm_on_close:{heading:"Aktiveer na toemaak",description:"After deactivation of this sensor, the remaining exit delay will automatically be skipped."},use_exit_delay:{heading:"Gebruik aktivering-wagperiode",description:"Sensor is allowed to be active when the exit delay starts."},use_entry_delay:{heading:"Gebruik afsit-wagperiode",description:"Sensor activation triggers the alarm after the entry delay rather than directly."},entry_delay:{heading:"Afsit-wagperiode",description:"Vervang die vertraging (soos ingestel vir die sekuriteitsmodus) met 'n spesifieke waarde."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Laat toe om aanvanklik oop te wees",description:"Open state while arming is ignored (subsequent sensor activation will trigger alarm)."},auto_bypass:{heading:"Ignoreer outomaties",description:"Exclude this sensor from the alarm if it is open while arming.",modes:"Modusse waarin die sensor geïgnoreer moet word."},trigger_unavailable:{heading:"Registreer voorvan wanneer nie beskikbaar",description:"When the sensor state becomes 'unavailable', this will activate the sensor."}},actions:{toggle_advanced:"Gevorderde verstellings",remove:"Verwyder",setup_groups:"Stel groepe op"},errors:{description:"Stel die volgende foute reg:",no_area:"Geen area is gekies nie",no_modes:"Geen modusse is gekies waarvoor die sensor aktief moet wees nie.",no_auto_bypass_modes:"Geen modusse is gekies vir die sensor om outomaties geïgnoreer te word nie."}}},dialogs:{manage_groups:{title:"Vestuur sensor-groepe",description:"In 'n sensor-groep, moet verskeie sensors geaktiveer word binne 'n gegewe tydperk vir die alarm om 'n voorval te registreer.",no_items:"Nog geen groepe nie",actions:{new_group:"Nuwe groep"}},create_group:{title:"Nuwe sensor-groep",fields:{name:{heading:"Name",description:"Naam van die sensor-groep"},timeout:{heading:"Tyd op",description:"Tydperiode waatydens opeenvolgende sensoraktiverings 'n voorval registreer."},event_count:{heading:"Telling",description:"Hoeveelheid verskillende sensors wat aktief moet wees om 'n voorval te registreer."},sensors:{heading:"Sensors",description:"Kies die sensors vir die groep."}},errors:{invalid_name:"Ongeldige naam",insufficient_sensors:"Ten minste 2 sensors moet gekies word."}},edit_group:{title:"Werk sensorgroep by ''{name}''"}}},codes:{title:"Kodes",cards:{codes:{description:"Verander die kode-verstellings.",fields:{code_arm_required:{heading:"Vereis 'n kode om te aktiveer",description:"'n Geldige kode word vereis om die alarm te aktiveer."},code_disarm_required:{heading:"Vereis 'n kode om af te sit",description:"'n Geldige kode word vereis om die alarm af te sit."},code_mode_change_required:{heading:"Require code for switching mode",description:"'n Geldige kode word vereis om die alarm se aktiewe modus te verander."},code_format:{heading:"Kode-formaat",description:"Sets the input type for Lovelace alarm card.",code_format_number:"Pinkode",code_format_text:"Wagwoord"}}},user_management:{title:"Bestuur gebruikers",description:"Elke gebruik het hulle eie kode om die alarm aan en af te skakel.",no_items:"Daar is nog geen gebruikers nie",actions:{new_user:"Nuwe gebruiker"}},new_user:{title:"Skep nuwe wagwoord",description:"Users can be created for providing access to operating the alarm.",fields:{name:{heading:"Naam",description:"Naam van die gebruiker."},code:{heading:"Kode",description:"Kode vir die gebruiker."},confirm_code:{heading:"Bevestig kode",description:"Bevestig die kode."},can_arm:{heading:"Laat kode toe om die alarm aan te sit",description:"Deur die kode in te tik sal die alarm geaktiveer word"},can_disarm:{heading:"Laat die kode toe om die alarm af te sit",description:"Deur die kode in te voeg sal die alarm afgesit word."},is_override_code:{heading:"Oorheers die kode",description:"Deur die kode in te tik sal die alarm geforseer wees om aan te sit"},area_limit:{heading:"Beperkte areas",description:"Beperk die gebruiker tot sekere areas"}},errors:{no_name:"Die naam is nie voorsien nie.",no_code:"Die kode moet ten minste 4 karakters bevat.",code_mismatch:"Die kodes verskil."}},edit_user:{title:"Werk gebruiker by",description:"Verander verstelling vir gebruiker, ''{name}''.",fields:{old_code:{heading:"Huidige kode",description:"Huidige kode, los leeg om die kode dieselfde te hou."}}}}},actions:{title:"Aksies",cards:{notifications:{title:"Kennisgewings",description:"Deur die paneel te gebruik, kan jy die kennisgewings bestuur wat gestuur word na sekere aksies.",table:{no_items:"Daar is nog geen kennisgewings nie.",no_area_warning:"Aksie is nie aan enige area toegeken nie."},actions:{new_notification:"Nuwe kennisgewing"}},actions:{description:"Hierdie paneel kan gebruik word om 'n toestel te skakel wanneer die alarm-modus verander.",table:{no_items:"Daar is nog geen nuwe aksies nie."},actions:{new_action:"Nuwe aksie"}},new_notification:{title:"Stel kennisgewing",description:"Ontvang 'n kennisgewing wanneer die alarm aan- of afgesit word.",trigger:"Voorvereiste",action:"Taak",options:"Opsies",fields:{event:{heading:"Voorval",description:"Wanneer moet die kennisgewing gestuur word.",choose:{armed:{name:"Alarm is gestel",description:"Die alarm is uksesvol gestel."},disarmed:{name:"Alarm is af",description:"Die alarm is afgesit."},triggered:{name:"'n Voorval vind plaas'",description:"Die alarm het 'n voorval geregistreer."},untriggered:{name:"Voorval het gestop",description:"Die voorval vind nie meer plaas nie."},arm_failure:{name:"Die alarm is nie gestel nie",description:"Die alarm kan nie gestel word nie, as gevolg van een of meer oop sensors."},arming:{name:"Uitgang-wagperiode het begin",description:"Die uitgang-wagperiode het begin. Maak reg om uit te gaan."},pending:{name:"Die inkom-wagperiode het begin",description:"Die inkom-wagperiode het begin en die alarm gaan binnekort afgaan."}}},mode:{heading:"Modus",description:"Beperk die aksie tot sekere modusse (opsioneel)"},title:{heading:"Titel",description:"Titel vir die kennisgewing-boodskap"},message:{heading:"Boodskap",description:"Inhou dvan die kennisgewing-boodskap",insert_wildcard:"Voeg wildcard in",placeholders:{armed:"Die alarm is gestel op {{arm_mode}}",disarmed:"Die alarm is nou af",triggered:"'n Voorval het plaasgevind, as gevolg van {{open_sensors}}.",untriggered:"Die voorval is gestop.",arm_failure:"Die alarm kan nie aangesit word nie, as gevolg van {{open_sensors}}.",arming:"Die alarm gaan binnekort aangesit word, gaan uit.",pending:"'n Voorval gaan binnekort registreer, sit vinnig die alarm af!"}},open_sensors_format:{heading:"Formaat vir die open_sensors wildcard",description:"Kies watter sensor-inligting in die boodskap gevoeg moet word",options:{default:"Name en statusse",short:"Slegs name"}},arm_mode_format:{heading:"Vertaling van arm_mode wildcard",description:"Kies die taal waarin die modus in die boodskap gevoeg moet word"},target:{heading:"Teiken",description:"Toestel om die kennisgewing heen te stuur"},media_player_entity:{heading:"Media-speler entiteit",description:"Media-speler om die kennisgewing op te speel"},name:{heading:"Naam",description:"Beskrywing vir die kennisgewing",placeholders:{armed:"Stel {target} in kennis wanneer die alarm aangesit word",disarmed:"Stel {target} in kennis wanneer die alarm afgesit word",triggered:"Stel {target} in kennis wanneer 'n voorval plaasvind",untriggered:"Stel {target} in kennis wanneer die voorval stop",arm_failure:"Stel {target} in kennis wanneer die alarm nie geaktiveer kan word nie",arming:"Stel {target} in kennis wanneer uitgaan",pending:"Stel {target} in kennis wanneer terugkom"}},delete:{heading:"Skrap automation",description:"Skrap die automation permanent"}},actions:{test:"Probeer dit nou"}},new_action:{title:"Stel aksie",description:"Skakel ligte of toestelle, soos sirenes, wanneer die alarm aan- of afgesit word, met aktivering ens.",fields:{event:{heading:"Voorval",description:"Wanneer moet die aksie plaasvind?"},area:{heading:"Area",description:"Area waar die voorval plaasvind"},mode:{heading:"Modus",description:"Beperk die aksie tot spesifieke alarm-modusse (opsioneel)"},entity:{heading:"Entiteit",description:"Entiteit waarop die aksie plaasvind"},action:{heading:"Aksie",description:"Aksie om op die entiteit uit te voer",no_common_actions:"Aksies vir die gesellekteerde entiteite kan slegs deur YAML bygewerk word."},name:{heading:"Naam",description:"Beskrywing vir die aksie",placeholders:{armed:"Stel {entity} na {state} wanneer die alarm aangesit word",disarmed:"Stel {entity} na {state} wanneer die alarm afgesit word",triggered:"Stel {entity} na {state} wanneer 'n voorval plaasvind",untriggered:"Stel {entity} na {state} wanneer die voorval stop",arm_failure:"Stel {entity} na {state} as die alarm nie aangesit kan word nie",arming:"Stel {entity} na {state} wanneer uitgaan",pending:"Stel {entity} na {state} wanneer terugkom"}}}}}}},De={common:xe,components:Ne,title:Le,panels:He},Pe=Object.freeze({__proto__:null,common:xe,components:Ne,default:De,panels:He,title:Le}),Be={modes_short:{armed_away:"Fora",armed_home:"Casa",armed_night:"Nit",armed_custom_bypass:"Personalitzat",armed_vacation:"Vacation"},enabled:"Activat",disabled:"Desactivat"},qe={time_picker:{seconds:"segons",minutes:"minuts"},editor:{ui_mode:"Canvia a UI",yaml_mode:"Canvia a YAML",edit_in_yaml:"Edit in YAML"},table:{filter:{label:"Filter items",item:"Filter by {name}",hidden_items:"{number} {number, plural,\n one {item is}\n other {items are}\n} hidden"}}},Ve="Tauler alarma",Ie={general:{title:"General",cards:{general:{description:"Aquest tauler defineix alguns paràmetres globals de l'alarma.",fields:{disarm_after_trigger:{heading:"Desactivar després del disparador",description:"Quan hagi transcorregut el temps d’activació, desactiveu l’alarma en lloc de tornar a l’estat armat."},ignore_blocking_sensors_after_trigger:{heading:"Ignora els sensors de bloqueig en rearmar",description:"Torna a l'estat armat sense comprovar si hi ha sensors que encara puguin estar actius."},enable_mqtt:{heading:"Activa MQTT",description:"Permet controlar el tauler d'alarma mitjançant MQTT."},enable_master:{heading:"Activa l'alarma mestra",description:"Crea una entitat per controlar totes les àrees simultàniament."}},actions:{setup_mqtt:"Configuració MQTT",setup_master:"Configuració mestra"}},modes:{title:"Modes",description:"Aquest tauler es pot utilitzar per configurar els modes d'activació de l'alarma.",modes:{armed_away:"El mode fora de casa s'utilitzarà quan totes les persones surtin de casa. Es controlen totes les portes i finestres que permeten l'accés a la casa, així com els sensors de moviment dins de la casa.",armed_home:"El mode a casa (també conegut com mode casa) s'utilitzarà quan configureu l'alarma mentre hi hagi persones a la casa. Es controlen totes les portes i finestres que permetin l'accés a la casa, però no els sensors de moviment a l'interior de la casa.",armed_night:"El mode nit s'utilitzarà quan configureu l'alarma abans d'anar a dormir. Es controlaran totes les portes i finestres que permetin l'accés a la casa i es seleccionaran els sensors de moviment (per exemple, a la planta baixa) de la casa.",armed_vacation:"Armed vacation can be used as an extension to the armed away mode in case of absence for longer duration. The delay times and trigger responses can be adapted (as desired) to being distant from home.",armed_custom_bypass:"Un mode addicional per definir el vostre propi perímetre de seguretat."},number_sensors_active:"{number} {number, plural,\n one {sensor}\n other {sensors}\n} activa",fields:{status:{heading:"Status",description:"Controls whether the alarm can be armed in this mode."},exit_delay:{heading:"Retard de sortida",description:"Quan activeu l'alarma, en aquest període de temps els sensors encara no activaran l'alarma."},entry_delay:{heading:"Retard d'entrada",description:"Temps de retard fins que s'activi l'alarma després que s'activi un dels sensors."},trigger_time:{heading:"Temps d'activació",description:"Temps durant el qual sonarà la sirena"}}},mqtt:{title:"Configuració MQTT",description:"Aquest tauler es pot utilitzar per configurar la interfície MQTT.",fields:{state_topic:{heading:"Tema d'estat",description:"Tema sobre el qual es publiquen les actualitzacions d'estat"},event_topic:{heading:"Tema d'esdeveniment",description:"Tema sobre el qual es publiquen els esdeveniments d'alarma"},command_topic:{heading:"Tama d'ordre",description:"Tema sobre el qual s'envien les ordres d'activació/desactivació."},require_code:{heading:"Requereix codi",description:"Requereix l'enviament d'un codi amb l'ordre."},state_payload:{heading:"Configura la càrrega útil per estat",item:"Definiu una càrrega útil per a l'estat ''{state}''"},command_payload:{heading:"Configura la càrrega útil per ordre",item:"Definiu una càrrega útil per a l'ordre ''{command}''"}}},areas:{title:"Àrees",description:"Les àrees es poden utilitzar per dividir el sistema d'alarma en diversos compartiments.",no_items:"Encara no hi ha àrees definides.",table:{remarks:"Observacions",summary:"Aquesta àrea conté {summary_sensors} i {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {sensor}\n other {sensors}\n}",summary_automations:"{number} {number, plural,\n one {automatisme}\n other {automatismes}\n}"},actions:{add:"Afegeix"}}},dialogs:{create_area:{title:"Àrea nova",fields:{copy_from:"Copia la configuració de"}},edit_area:{title:"Edita l'àrea ''{area}''",name_warning:"Nota: si canvieu el nom, es canviarà l'identificador d'entitat"},remove_area:{title:"Voleu eliminar l'àrea?",description:"Confirmeu que voleu eliminar aquesta àrea? Aquesta àrea conté {sensors} sensors i {automatismes} automatismes, que també s'eliminaran."},edit_master:{title:"Configuració mestra"},disable_master:{title:"Voleu desactivar l'alarma mestra?",description:"Confirmeu que voleu eliminar l'alarma mestra? Aquesta àrea conté automatismes {automatismes}, que s'eliminaran amb aquesta acció."}}},sensors:{title:"Sensors",cards:{sensors:{description:"Sensors configurats actualment. Feu clic a una entitat per fer canvis.",table:{no_items:"No hi ha cap sensor per mostrar",arm_modes:"Modes d'armat",always_on:"(Sempre)",no_area_warning:"Sensor is not assigned to any area."}},add_sensors:{title:"Afegeix sensors",description:"Afegiu més sensors. Assegureu-vos que els vostres sensors tinguin un friendly_name perquè pugueu identificar-los.",no_items:"No hi ha entitats HA disponibles que es puguin configurar per a l'alarma. Assegureu-vos d'incloure entitats del tipus binary_sensor.",table:{type:"Detected type"},actions:{add_to_alarm:"Afegeix a l'alarma",show_all:"Mostra-ho tot"}},editor:{title:"Edita el sensor",description:"Edita la configuració del sensor de ''{entity}''.",fields:{entity:{heading:"Entidad",description:"Entidad asociada a este sensor"},area:{heading:"Àrea",description:"Seleccioneu una àrea que contingui aquest sensor."},group:{heading:"Group",description:"Group with other sensors for combined triggering."},device_type:{heading:"Tipus de dispositiu",description:"Trieu un tipus de dispositiu per aplicar automàticament la configuració adequada.",choose:{door:{name:"Porta",description:"Porta, porta de garatge o altra entrada que s'utilitzi per entrar/sortir de casa."},window:{name:"Finestra",description:"Finestra o una porta que no s'utilitza per entrar a la casa, com ara un balcó."},motion:{name:"Moviment",description:"Sensor de presència o dispositiu similar que té un retard entre les activacions."},tamper:{name:"Antisabotatge",description:"Detector de retirada de la coberta del sensor, sensor de trencament de vidre, etc."},environmental:{name:"Ambiental",description:"Sensor de fum o gas, detector de fuites, etc. (no relacionat amb la protecció antirobatori)."},other:{name:"Genèric"}}},always_on:{heading:"Sempre activat",description:"El sensor sempre ha de disparar l'alarma."},modes:{heading:"Modes habilitats",description:"Modes d'alarma en què aquest sensor està actiu."},arm_on_close:{heading:"Arma després de tancar",description:"Després de la desactivació d'aquest sensor, s'omet automàticament el temps de retard de sortida restant."},use_exit_delay:{heading:"Use exit delay",description:"Sensor is allowed to be active when the exit delay starts."},use_entry_delay:{heading:"Use entry delay",description:"Sensor activation triggers the alarm after the entry delay rather than directly."},entry_delay:{heading:"Entry delay",description:"Override the entry delay (as configured for the arm mode) with a delay specific to the sensor."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Permet obrir mentre s'arma l'alarma",description:"Permeteu que aquest sensor estigui actiu poc després de configurar-lo de manera que no bloquegi l'activació de l'alarma."},auto_bypass:{heading:"Omet automàticament",description:"Excloeu aquest sensor de l'alarma si està obert mentre s'arma l'alarma.",modes:"Modes in which sensor may be bypassed"},trigger_unavailable:{heading:"Activador quan no estigui disponible",description:"Quan l'estat del sensor no estigui disponible, això activarà el sensor."}},actions:{toggle_advanced:"Configuració avançada",remove:"Elimina",setup_groups:"Setup groups"},errors:{description:"Corregiu els errors següents:",no_area:"No s'ha seleccionat cap àrea",no_modes:"No s'han seleccionat modes per als quals el sensor hauria d'estar actiu",no_auto_bypass_modes:"No modes are selected for the sensor may be automatically bypassed"}}},dialogs:{manage_groups:{title:"Manage sensor groups",description:"In a sensor group multiple sensors must be activated within a time period before the alarm is triggered.",no_items:"No groups yet",actions:{new_group:"New group"}},create_group:{title:"New sensor group",fields:{name:{heading:"Name",description:"Name for sensor group"},timeout:{heading:"Time-out",description:"Time period during which consecutive sensor activations triggers the alarm."},event_count:{heading:"Nombre",description:"Quantitat de sensors diferents que cal activar per activar l'alarma."},sensors:{heading:"Sensors",description:"Select the sensors which are contained by this group."}},errors:{invalid_name:"Invalid name provided.",insufficient_sensors:"At least 2 sensors need to be selected."}},edit_group:{title:"Edit sensor group ''{name}''"}}},codes:{title:"Codis",cards:{codes:{description:"Canvieu la configuració del codi.",fields:{code_arm_required:{heading:"Utilitzeu un codi d'activació",description:"Requereix un codi per activar l'alarma."},code_disarm_required:{heading:"Utilitzeu un codi de desactivació",description:"Requereix un codi per desactivar l'alarma."},code_mode_change_required:{heading:"Requerir un codi para cambiar de modo",description:"Se necesita un codi válido para cambiar el modo de armado que está activo."},code_format:{heading:"Format del codi",description:"Estableix el tipus de codi per a la targeta d'alarma Lovelace.",code_format_number:"Codi PIN",code_format_text:"Contrasenya"}}},user_management:{title:"Gestió d'usuaris",description:"Cada usuari té el seu propi codi per activar/desactivar l'alarma.",no_items:"Encara no hi ha usuaris",actions:{new_user:"Usuari nou"}},new_user:{title:"Crea un usuari nou",description:"Es poden crear usuaris per proporcionar accés al funcionament de l'alarma.",fields:{name:{heading:"Nom",description:"Nom de l'usuari."},code:{heading:"Codi",description:"Codi d'aquest usuari."},confirm_code:{heading:"Confirmeu el codi",description:"Repetiu el codi."},can_arm:{heading:"Permetre que el codi active l'alarma",description:"Entering this code activates the alarm"},can_disarm:{heading:"Permetre que el codi desactive l'alarma",description:"Entering this code deactivates the alarm"},is_override_code:{heading:"És un codi de sobreescriptura",description:"Si introduïu aquest codi, es forçarà l'estat d'activació de l'alarma"},area_limit:{heading:"Restricted areas",description:"Limit user to control only the selected areas"}},errors:{no_name:"No s'ha proporcionat cap nom.",no_code:"El codi ha de tenir 4 caràcters o números com a mínim.",code_mismatch:"Els codis no coincideixen."}},edit_user:{title:"Edita l'usuari",description:"Canvia la configuració de l'usuari ''{name}''.",fields:{old_code:{heading:"Codi actual",description:"Codi actual, deixeu-lo en blanc per deixar-lo sense canvis."}}}}},actions:{title:"Accions",cards:{notifications:{title:"Notificacions",description:"Utilitzant aquest tauler, podeu gestionar les notificacions que s'envien quan es produeix un determinat esdeveniment d'alarma.",table:{no_items:"Encara no s'han creat notificacions.",no_area_warning:"Action is not assigned to any area."},actions:{new_notification:"Nova notificació"}},actions:{description:"Aquest tauler es pot utilitzar per canviar un dispositiu quan l'estat d'alarma canvia.",table:{no_items:"Encara no s'han creat accions."},actions:{new_action:"Nova acció"}},new_notification:{title:"Crea una notificació",description:"Crea una nova notificació.",trigger:"Condition",action:"Task",options:"Options",fields:{event:{heading:"Esdeveniment",description:"Quan s'ha d'enviar la notificació",choose:{armed:{name:"L'alarma està activada",description:"L'alarma s'ha activat correctament"},disarmed:{name:"L'alarma està desactivada",description:"L'alarma està desactivada"},triggered:{name:"L'alarma s'activat per esdeveniment",description:"L'alarma s'activat per esdeveniment"},untriggered:{name:"Alarm not longer triggered",description:"The triggered state of the alarm has ended"},arm_failure:{name:"No s'ha pogut activar l'alarma",description:"L'activació de l'alarma ha fallat a causa d'un o més sensors estan oberts"},arming:{name:"S'ha iniciat el retard de sortida",description:"S'ha iniciat el retard de sortida, a punt per sortir de casa."},pending:{name:"S'ha iniciat el retard d'entrada",description:"El retard d'entrada s'ha iniciat, l'alarma s'activarà aviat."}}},mode:{heading:"Mode",description:"Limita l'acció a modes específics d'activació (opcional)"},title:{heading:"Títol",description:"Títol del missatge de notificació"},message:{heading:"Missatge",description:"Contingut del missatge de notificació",insert_wildcard:"Insert wildcard",placeholders:{armed:"The alarm is set to {{arm_mode}}",disarmed:"The alarm is now OFF",triggered:"The alarm is triggered! Cause: {{open_sensors}}.",untriggered:"The alarm is not longer triggered.",arm_failure:"The alarm could not be armed right now, due to: {{open_sensors}}.",arming:"The alarm will be armed soon, please leave the house.",pending:"The alarm is about to trigger, disarm it quickly!"}},open_sensors_format:{heading:"Format for open_sensors wildcard",description:"Choose which sensor information in inserted in the message",options:{default:"Names and states",short:"Names only"}},arm_mode_format:{heading:"Translation for arm_mode wildcard",description:"Choose in which language the arm mode is inserted in the message"},target:{heading:"Destinatari",description:"Dispositiu al qual enviar el missatge"},media_player_entity:{heading:"Entitat de reproductor multimèdia",description:"Reproductor multimèdia a reproduir el missatge."},name:{heading:"Nom",description:"Descripció d'aquesta notificació",placeholders:{armed:"Notify {target} upon arming",disarmed:"Notify {target} upon disarming",triggered:"Notify {target} when triggered",untriggered:"Notify {target} when triggering stops",arm_failure:"Notify {target} on failure",arming:"Notify {target} when leaving",pending:"Notify {target} when arriving"}},delete:{heading:"Delete automation",description:"Permanently remove this automation"}},actions:{test:"Prova-ho"}},new_action:{title:"Crea una acció",description:"Aquest tauler es pot utilitzar per canviar un dispositiu quan l'estat d'alarma canvia.",fields:{event:{heading:"Esdeveniment",description:"Quan s'ha d'executar l'acció"},area:{heading:"Àrea",description:"Àrea per a la qual s'aplica l'esdeveniment."},mode:{heading:"Mode",description:"Limita l'acció a modes específics d'activació (opcional)"},entity:{heading:"Entitat",description:"Entitat en què es realitzarà l'acció"},action:{heading:"Acció",description:"Acció a realitzar a l'entitat",no_common_actions:"Actions can only be assigned in YAML mode for the selected entities."},name:{heading:"Nom",description:"Descripció d'aquesta acció",placeholders:{armed:"Set {entity} to {state} upon arming",disarmed:"Set {entity} to {state} upon disarming",triggered:"Set {entity} to {state} when triggered",untriggered:"Set {entity} to {state} when triggering stops",arm_failure:"Set {entity} to {state} on failure",arming:"Set {entity} to {state} when leaving",pending:"Set {entity} to {state} when arriving"}}}}}}},Re={common:Be,components:qe,title:Ve,panels:Ie},Ue=Object.freeze({__proto__:null,common:Be,components:qe,default:Re,panels:Ie,title:Ve}),Ge={modes_short:{armed_away:"Pryč",armed_home:"Doma",armed_night:"Noc",armed_custom_bypass:"Vlastní",armed_vacation:"Dovolená"},enabled:"Povoleno",disabled:"Zakázáno"},Ke={time_picker:{seconds:"sekundy",minutes:"minuty"},editor:{ui_mode:"Do UI",yaml_mode:"Do YAML",edit_in_yaml:"Upravit v YAML"},table:{filter:{label:"Filtrovat položky",item:"Filtrovat podle {name}",hidden_items:"{number} {number, plural,\n one {položka je}\n other {položky jsou}\n} skryté"}}},Fe="Alarm panel",Ze={general:{title:"Obecné",cards:{general:{description:"Tento panel definuje obecné nastavení alarmu.",fields:{disarm_after_trigger:{heading:"Deaktivovat alarm po spuštění",description:"Po vypršení času spuštěného alarmu, deatkivovat alarm místo návratu do zajištěného stavu."},ignore_blocking_sensors_after_trigger:{heading:"Ignorovat blokující senzory při opětovném zapnutí",description:"Návrat do zapnutého stavu bez kontroly senzorů, které by mohly být stále aktivní."},enable_mqtt:{heading:"Povolit MQTT",description:"Povolení kontroly alarmu přes MQTT."},enable_master:{heading:"Povolit centrální alarm",description:"Vytvoří entitu pro kontrolu alarmu pro všechny zóny."}},actions:{setup_mqtt:"Nastavení MQTT",setup_master:"Nastavení centrálního alarmu"}},modes:{title:"Režimy",description:"Tento panel slouží k nastavení režimů alarmu.",modes:{armed_away:"Zajištěno Pryč se používá v případě, že nikdo není doma. Veškeré dveře a okna jsou hlídaná proti otevření a pohybové senzory kontrolují uvnitř",armed_home:"Zajištěno Doma se používá v případě, že se někdo v domě pohybuje. Veškeré dveře a okna jsou hlídaná proti otevření, ale pohybové senzory hlídané nejsou.",armed_night:"Zajištěno Noc se používá v případe, že chceme zajistit při spánku. Můžete vybrat které dveře a pohybové senzory spustí alarm a které ne. (například v přízemí domu)",armed_vacation:"Zajištěno Dovolená se používá jako rošíření Zajištěno Pryč, pro nastavení různého chování alarmu. Například delší doba sirény, odeslání notifikace ...",armed_custom_bypass:"Speciální režim pro kompletní kontrolu nad nastavením alarmu."},number_sensors_active:"{number} {number, plural,\n one {senzor}\n other {senzorů}\n} aktivní",fields:{status:{heading:"Stav",description:"Určuje, zda v tomto režimu je možné alarm zajistit."},exit_delay:{heading:"Čekání na odchod",description:"V případě zajištění alarmu, po tuto dobu nebudou vyhodnoceny změny seznorů (například otevření hlavních dveří)"},entry_delay:{heading:"Čekání při příchodu",description:"Zpoždění spuštění alarmu pro možnost zadání kódu při příchodu domů."},trigger_time:{heading:"Délka spuštění alarmu",description:"Doba po kterou alarm zůstane ve spuštěném stavu."}}},mqtt:{title:"Nastavení MQTT",description:"Tento panel slouží pro nastavení MQTT komunikace.",fields:{state_topic:{heading:"State topic",description:"Topic on which state updates are published"},event_topic:{heading:"Event topic",description:"Topic on which alarm events are published"},command_topic:{heading:"Command topic",description:"Topic which Alarmo listens to for arm/disarm commands."},require_code:{heading:"Require code",description:"Require the code to be sent with the command."},state_payload:{heading:"Configure payload per state",item:"Define a payload for state ''{state}''"},command_payload:{heading:"Configure payload per command",item:"Define a payload for command ''{command}''"}}},areas:{title:"Zóny",description:"Zóny mohou být použity pro rozdělení alarmu do více oblastí.",no_items:"Zatím nejsou definované žádné zóny.",table:{remarks:"Poznámka",summary:"Tato zóna obsahuje {summary_sensors} a {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {senzor}\n other {senzorů}\n}",summary_automations:"{number} {number, plural,\n one {automatizace}\n other {automatiací}\n}"},actions:{add:"Přidat"}}},dialogs:{create_area:{title:"Nové zóna",fields:{copy_from:"Zkopírovat nastavení z"}},edit_area:{title:"Úprava zóny ''{area}''",name_warning:"Poznámka: změna jména zárověň změní i ID entity"},remove_area:{title:"Odebrat zónu?",description:"Jste si jistí? Tato zóna obsahuje {sensors} senzory a {automations} automatizace, které budou odstraněny také."},edit_master:{title:"Centrální nastavení"},disable_master:{title:"Zakázat centrální ovládání?",description:"Jste si jistí? Tato zóna obsahuje {sensors} senzory a {automations} automatizace, které budou odstraněny také."}}},sensors:{title:"Senzory",cards:{sensors:{description:"Aktuálně nastavené senzory. Pro změnu klikněte na položku.",table:{no_items:"Žádné senzory k zobrazení.",no_area_warning:"Senzor není přiřazen k žádné zóně",arm_modes:"Režimy alarmu",always_on:"(Vždy)"}},add_sensors:{title:"Přidat Senzor",description:"Přidat další senzory. Ujistěte se, že vaše senzory jsou správně pojmenovány.",no_items:"Nejsou žádné dostupné HA entity, které mohou být nastaveny pro alarm. Přidejte prosím pouze entity typu binary_sensor.",table:{type:"Zjištěný typ"},actions:{add_to_alarm:"Přidat do alarmu",filter_supported:"Skrýt položky neznámého typu"}},editor:{title:"Upravit Senzor",description:"Nastavení senzoru entity ''{entity}''.",fields:{entity:{heading:"Entita",description:"Entidad asociada a este sensor"},area:{heading:"Zóna",description:"Vyberte zónu do které má senzor patřit."},group:{heading:"Skupina",description:"Seskupit senzory pro kombinované spuštění alarmu."},device_type:{heading:"Typ zařízení",description:"Vyberte typ zařízení pro automatické předvyplnění parametrů.",choose:{door:{name:"Dveře",description:"Dveře, brána nebo jiný prostředek pro vstup/opuštění domu."},window:{name:"Okno",description:"Okno nebo balkonové dveře, které neslouží pro vstup do domu."},motion:{name:"Pohyb",description:"Pohybový senzor nebo podobné zařízeni pro zjištění přítomnosti osob."},tamper:{name:"Manipulace",description:"Detektor manipulace se senzorem, senzor rozbitého okna, atd."},environmental:{name:"Prostředí",description:"Senzor kouře/plynu, detektor úniku vody, atd. (neslouží k ochraně před zloději)."},other:{name:"Obecné"}}},always_on:{heading:"Vždy zapnuto",description:"Senzor vždy spustí alarm."},modes:{heading:"Povolené režimy",description:"Režimy alarmu, pro které se má senzor vyhodnocovat."},arm_on_close:{heading:"Zajistit po zavření",description:"Po deaktivaci tohoto senzoru, přeskočit čekání na odchod."},use_exit_delay:{heading:"Použít při čekání na odchod",description:"Senzor může být aktivní když začne čekání na odchod."},use_entry_delay:{heading:"Použíy čekání na vstup",description:"Aktivace senzoru spustí alarm až uplyne doba čekání na vstup."},entry_delay:{heading:"Zpoždění při příjezdu",description:"Přepsat vstupní zpoždění (nakonfigurované pro režim zapnutí) zpožděním specifickým pro daný senzor."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Povolit aktivní senzor po zajištění",description:"Pokud je senzor stále aktivní i po čekání na odchod, nezpůsobí chybu zajištění."},auto_bypass:{heading:"Automaticky vyřadit senzor",description:"Pokud je senzor v době zajištění aktivní, bude automaticky vyřazen z alarmu.",modes:"Režimy, ve kterých může být senzor automaticky vyřazen"},trigger_unavailable:{heading:"Spustit alarm při nedostupnosti",description:"Pokud stav senzoru není dostupný, spustí alarm."}},actions:{toggle_advanced:"Rozšířené nastavení",remove:"Odebrat",setup_groups:"Nastavit skupiny"},errors:{description:"Prosím opravte následující chyby:",no_area:"Není vybrána žádná zóna",no_modes:"Není vybrán žádný režim, ve kterém má být seznor aktivní",no_auto_bypass_modes:"Není vybrán žádný režim, ve kterém má být senzor automaticky vyřazen"}}},dialogs:{manage_groups:{title:"Spravovat skupiny senzorů",description:"Ve skupině senzorů musí byt aktivováno více senzorů v určitém časovém úseku pro spuštění alarmu.",no_items:"Nejsou žádné skupiny",actions:{new_group:"Nová skupina"}},create_group:{title:"Nová skupina senzorů",fields:{name:{heading:"Název",description:"Název skupiny senzorů"},timeout:{heading:"Časový úsek",description:"Časový úsek ve kterém musí být aktivovány senzory aby byl alarm spuštěn."},event_count:{heading:"Počet",description:"Množství různých senzorů, které je třeba aktivovat ke spuštění poplachu."},sensors:{heading:"Senzory",description:"Vyberte senzory, které mají být v této skupině."}},errors:{invalid_name:"Neplatné jméno.",insufficient_sensors:"Musí být vybrány alespoň 2 senzory."}},edit_group:{title:"Upravit skupinu senzorů ''{name}''"}}},codes:{title:"Kódy",cards:{codes:{description:"Změnit nastavení kódu.",fields:{code_arm_required:{heading:"Použít kód k zajištění",description:"Vyžadovat kód při zajištění alarmu"},code_disarm_required:{heading:"Použít kód k deaktivaci alarmu",description:"Vyžadovat kód pro deaktivaci alarmu"},code_mode_change_required:{heading:"Vyžadovat kód pro přepínání režimu",description:"Pro změnu aktivního režimu aktivace je nutné zadat platný kód."},code_format:{heading:"Formát kódu",description:"Nastaví typ klávesnice pro kartu alarmu v Lovelace.",code_format_number:"Pin",code_format_text:"Heslo"}}},user_management:{title:"Správa uživatelů",description:"Každý uživatel má svůj vlastní kód pro zajištění/deaktivaci alarmu.",no_items:"Neexistují žádní uživatelé",actions:{new_user:"Nový uživatel"}},new_user:{title:"Vytvořit nového uživatele",description:"Uživatelé mohou být vytvořeni pro práci s alarmem.",fields:{name:{heading:"Jméno",description:"Jméno uživatele."},code:{heading:"Kód",description:"Kód uživatele."},confirm_code:{heading:"Ověření kódu",description:"Zopakujte kód."},can_arm:{heading:"Povolit kód pro zajištění",description:"Zadání tohoto kódu zajistí alarm"},can_disarm:{heading:"Povolit kód pro deaktivaci",description:"Zadání tohoto kódu deaktivuje alarm"},is_override_code:{heading:"Je to override kód",description:"Zadání tohoto kódu zajistí alarm i přes otevřené senzory"},area_limit:{heading:"Povolené zóny",description:"Omezení uživatele ovládat pouze vybrané zóny alarmu"}},errors:{no_name:"Není zadáno jméno.",no_code:"Kód by měl mít minimálně 4 znaky.",code_mismatch:"Kódy se neshodují."}},edit_user:{title:"Upravit uživatele",description:"ZMěnit nastavení uživatele ''{name}''.",fields:{old_code:{heading:"Aktuální kód",description:"Aktuální kód, nechte prázdné pokud nechcete měnit."}}}}},actions:{title:"Akce",cards:{notifications:{title:"Notifikace",description:"Tento panel slouží k nastavení notifikací, které mají být odeslány v případě určitých událostí alarmu.",table:{no_items:"Nejsou žádné vytvořené notifikace.",no_area_warning:"Akce není přiřazena k žádné zóně."},actions:{new_notification:"Nová notifikace"}},actions:{description:"Tento panel slouží k nastavení změny zařízení v případě změny stavu alarmu.",table:{no_items:"Nejsou žádné vytvořené akce."},actions:{new_action:"Nová akce"}},new_notification:{title:"Nastavení notifikací",description:"Odeslání notifikace při zajištění/deaktivaci alarmu, při spuštění alarmu, atd.",trigger:"Podmínka",action:"Akce",options:"Možnosti",fields:{event:{heading:"Událost",description:"Kdy by měla být notifikace odeslána",choose:{armed:{name:"Alarm je zajištěný",description:"Zajištění alarmu proběhlo úspěšně"},disarmed:{name:"Alarm je deaktivovaný",description:"Deaktivace alarmu proběhla úspěšně"},triggered:{name:"Alarm je spuštěný",description:"Byl spustěný alarm"},untriggered:{name:"Spuštěný alarm skončil",description:"Skončilo spustění alarmu (například vypršením nastavené doby nebo deaktivací)"},arm_failure:{name:"Zajištění se nepodařilo",description:"Zajištění alarmu se nepodařilo díky jendomu nebo více aktivním senzorům"},arming:{name:"Čas na odchod začal",description:"Začal odpočet času pro odchod."},pending:{name:"Čas na příchod",description:"Začal odpočet času na příchod."}}},mode:{heading:"Režim",description:"Omezit notifikaci na specifický režim alarmu (nepovinné)"},title:{heading:"Nadpis",description:"Nadpis notifikace"},message:{heading:"Zpráva",description:"Obsah zprávy v notifikaci",insert_wildcard:"Vložit proměnnou",placeholders:{armed:"Alarm je nastaven na {{arm_mode}}",disarmed:"Alarm je deaktovovaný",triggered:"Alarm je spuštěný! Příčina: {{open_sensors}}.",untriggered:"Alarm byl ukončen.",arm_failure:"Alarm nemohl být zajištěný v tuto chvíli, kvůli: {{open_sensors}}.",arming:"Probíhá zajištění alarmu, můžete opustit dům.",pending:"Alarm bude brzy spuštěný, rychle ho deaktivujte!"}},open_sensors_format:{heading:"Formát pro open_sensors proměnnou",description:"Vyberte, které informace o senzoru budou do zprávy přidány",options:{default:"Jména a stavy",short:"Pouze jména"}},arm_mode_format:{heading:"Překlad pro arm_mode proměnnou",description:"Vyberte ve kterém jazyce se má do zprávy přidat režim alarmu"},target:{heading:"Přijemce",description:"Na které zařízení má být notifikace odeslána"},media_player_entity:{heading:"Entita přehrávače médií",description:"Přehrávače médií pro přehrání zprávy."},name:{heading:"Jméno",description:"Popis této notifikace",placeholders:{armed:"Upozornit {target} při zajištění",disarmed:"Upozornit {target} při deaktivaci",triggered:"Upozornit {target} při spuštění",untriggered:"Upozornit {target} když se alarm ukončí",arm_failure:"Upozornit {target} při chybě v zajištění",arming:"Upozornit {target} při odchodu",pending:"Upozornit {target} při příchodu"}},delete:{heading:"Odstranit automatizaci",description:"Trvale odstranit tuto automatizaci"}},actions:{test:"Vyzkoušet"}},new_action:{title:"Nastavení akce",description:"Rozsvítit světla, spustit sirénu například při spuštění alarmu.",fields:{event:{heading:"Událost",description:"Kdy by měla být akce provedena"},area:{heading:"Zóna",description:"Pro kterou zónu se má akce provést."},mode:{heading:"Režim",description:"Omezit akci na specifický režim alarmu (nepovinné)"},entity:{heading:"Entita",description:"Na které entitě má být akce provedena"},action:{heading:"Akce",description:"Akce, která má být provedena na entitě",no_common_actions:"Pro vybrané entity může být akce nastavena pouze v YAML."},name:{heading:"Název",description:"Popis této akce",placeholders:{armed:"Nastav {entity} na {state} při zajištění",disarmed:"Nastav {entity} na {state} při deaktivaci",triggered:"Nastav {entity} na {state} při spuštění",untriggered:"Nastav {entity} na {state} když se alarm ukončí",arm_failure:"Nastav {entity} na {state} při chybě v zajištění",arming:"Nastav {entity} na {state} při odchod",pending:"Nastav {entity} na {state} při příchodu"}}}}}}},Qe={common:Ge,components:Ke,title:Fe,panels:Ze},Ye=Object.freeze({__proto__:null,common:Ge,components:Ke,default:Qe,panels:Ze,title:Fe}),We={modes_short:{armed_away:"Ude",armed_home:"Hjemme",armed_night:"Nat",armed_custom_bypass:"Tilpasset",armed_vacation:"Ferie"},enabled:"Aktiveret",disabled:"deaktiveret"},Xe={time_picker:{seconds:"sekunder",minutes:"minutter"},editor:{ui_mode:"Til UI",yaml_mode:"Til YAML",edit_in_yaml:"Ret i YAML"},table:{filter:{label:"Filtrer genstande",item:"Filtrer efter {name}",hidden_items:"{number} {number, plural,\n one {enhed er}\n other {enheder er}\n} skjult"}}},Je="Alarm panel",ea={general:{title:"Generelt",cards:{general:{description:"Dette panel definerer nogle globale indstillinger for alarmen.",fields:{disarm_after_trigger:{heading:"Frakobling efter alarm",description:"Efter udløsningstid er udløbet, deaktiver alarmen i stedet for at vende tilbage til aktiveret tilstand."},ignore_blocking_sensors_after_trigger:{heading:"Ignorer blokeringssensorer ved genaktivering",description:"Gå tilbage til aktiveret tilstand uden at kontrollere for sensorer, der stadig kan være aktive."},enable_mqtt:{heading:"Aktiver MQTT",description:"Tillad at alarmpanelet blive styret igennem MQTT."},enable_master:{heading:"Aktiver alarmmaster",description:"Opretter en enhed til at kontrollere alle områder samtidigt."}},actions:{setup_mqtt:"MQTT indstillinger",setup_master:"Master indstillinger"}},modes:{title:"Tilstande",description:"Dette panel kan bruges til at indstille alarmens tilkoblingstilstande.",modes:{armed_away:"Tilkobling ude bliver brugt når alle mennesker forlader huset. Alle døre og vinduer der giver adgang til huset vil blive overvåget, samt bevægelsessensorer inde i huset.",armed_home:"Tilkobling hjemme (også kendt som tilkoblet ophold), bliver brugt når alarmen aktiveret når der er mennesker i huset. Alle døre og vinduer der giver adgang til huset vil være overvåget, men ikke bevægelsessensorer inde i huset.",armed_night:"Tilkobling nat vil blive brugt når du aktiveret alarmen før du går i seng. Alle døre og vinduer der giver adgang til huset vil være overvåget og udvalgte bevægelsessensorer i huset.",armed_vacation:"Tilkobling ferie kan bruges som en forlængelse af tilkobet udetilstand i tilfælde af længerevarende fravær. Forsinkelsestider og udløsningstider kan tilpasses (efter ønske) til at være lang tid hjemmefra.",armed_custom_bypass:"En ekstra tilstand til at definere din egen sikkerhedsperimeter."},number_sensors_active:"{number} {number, plural,\n one {sensor}\n other {sensorer}\n} aktive",fields:{status:{heading:"Status",description:"Styrer om alarmen kan aktiveres i denne tilstand."},exit_delay:{heading:"Udgangstid",description:"Når alarmen aktiveres, vil sensorerne ikke udløse alarmen inden for denne tidsperiode."},entry_delay:{heading:"Indgangstid",description:"Forsinkelsestid indtil alarmen udløses, efter at en af sensorerne er aktiveret."},trigger_time:{heading:"Sirenetid",description:"Tid hvor sirenen er aktiv efter udløsning."}}},mqtt:{title:"MQTT indstillinger",description:"Dette panel kan bruges til indstilling af MQTT-grænsefladen.",fields:{state_topic:{heading:"Status topic",description:"Topic hvor status sendes til."},event_topic:{heading:"Event topic",description:"Topic hvor alarm events sendes til."},command_topic:{heading:"Kommando topic",description:"Topic som alarmo lytter efter tilkobling/frakobling kommandoer."},require_code:{heading:"Kræv kode",description:"Kræv at koden bliver sendt med kommandoen."},state_payload:{heading:"Indstil last pr tilstand",item:"Definerer en last for tilstanden ''{state}''"},command_payload:{heading:"Indstil last pr kommando",item:"Definerer en last for kommando ''{command}''"}}},areas:{title:"Områder",description:"Områder kan bruges til at opdele dit alarmsystem i flere rum.",no_items:"Der er ikke defineret områder endnu.",table:{remarks:"Kommentarer",summary:"Dette område indeholder {summary_sensors} og {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {sensor}\n other {sensorer}\n}",summary_automations:"{number} {number, plural,\n one {automation}\n other {automationer}\n}"},actions:{add:"Tilføj"}}},dialogs:{create_area:{title:"Nyt område",fields:{copy_from:"Kopier indstillinger fra"}},edit_area:{title:"Redigerer område ''{area}''",name_warning:"Note: ændring af navnet ændrer også enheds id"},remove_area:{title:"Fjern område?",description:"Er du sikker på at du ønsker at fjerne området? Dette område indeholder {sensors} sensorer og {automations} automationer, hvilket også vil blive fjernet."},edit_master:{title:"Master indstillinger"},disable_master:{title:"Deaktiver master?",description:"Er du sikker på du ønsker at fjerne alarm master? Dette område indeholder {automations} automationer, hvilket vil blive fjernet."}}},sensors:{title:"Sensorer",cards:{sensors:{description:"Aktuelt konfigurerede sensorer. Klik på et element for at foretage ændringer.",table:{no_items:"Der er ingen sensorer der kan vises her.",no_area_warning:"Sensoren er ikke tildelt noget område.",arm_modes:"Tilkoblings tilstande",always_on:"(Altid)"}},add_sensors:{title:"Tilføj Sensorer",description:"Tilføj flere sensorer. Sørg for din sensor har en passende navn, så du senere kan identificere dem.",no_items:"Der er ingen tilgængelige HA-enheder, der kan konfigureres til alarmen. Sørg for at inkludere enheder af typen binary_sensor.",table:{type:"Registreret type"},actions:{add_to_alarm:"Tilføj til alarm",filter_supported:"Gem enheder af ukendt type"}},editor:{title:"Rediger Sensor",description:"Konfigurer sensor indstillinger for ''{entity}''.",fields:{entity:{heading:"Entitet",description:"Entitet tilknyttet denne sensor"},area:{heading:"Område",description:"Vælg det område som indeholder denne sensor."},group:{heading:"Gruppe",description:"Gruppe med andre sensorer for kombineret udløsning."},device_type:{heading:"enheds type",description:"Vælg enheds typen for automatisk at tilføje de korrekte indstillinger.",choose:{door:{name:"Dør",description:"En dør, åbning eller anden indgang der bruges til at komme ind/ud af huset."},window:{name:"Vindue",description:"Et vindue, eller en dør som ikke bruges til at komme ind/ud af huset (f.eks. en balkon/altan)."},motion:{name:"Bevægelsessensor",description:"Tilstedeværelses sensorer eller lignende enheder som har en forsinkelse imellem aktiveringer."},tamper:{name:"Tamper",description:"Detektor for fjernelse af sensordæksel, glasbrudssensor mv."},environmental:{name:"Miljømæssige",description:"Røg-/gassensor, lækagedetektor osv. (ikke relateret til tyverisikring)."},other:{name:"Generisk"}}},always_on:{heading:"Altid til",description:"Sensor skal altid udløse alarmen."},modes:{heading:"Aktiveret tilstande",description:"Alarmtilstande hvor denne sensor er aktiv."},arm_on_close:{heading:"Tilkoble efter lukning",description:"Efter deaktivering af denne sensor, springes den resterende udgangstid automatisk over."},use_exit_delay:{heading:"Brug udgangstid",description:"Sensorer må være aktiv når udgangstid starter."},use_entry_delay:{heading:"Brug indgangstid",description:"Sensor aktivering udløser alarmen efter indgangstiden i stedet for med det samme."},entry_delay:{heading:"Indgangstid",description:"Tilsidesæt forsinkelsen (som konfigureret for aktiveringstilstand) med en specifik værdi."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Tillad åbning efter tilkobling",description:"Oprindelige tilstand på sensoren ignoreres ved tilkobling."},auto_bypass:{heading:"Omgå automatisk",description:"Udeluk denne sensor fra alarmen, hvis den er åben under aktivering.",modes:"Tilstande hvor sensoren kan omgås"},trigger_unavailable:{heading:"Udløs når den ikke er tilgængelig",description:"Når sensortilstanden bliver 'utilgængelig', vil dette aktivere sensoren."}},actions:{toggle_advanced:"Avancerede indstillinger",remove:"Fjern",setup_groups:"Opsæt grupper"},errors:{description:"Ret venligst følgende fejl:",no_area:"Intet område er valgt",no_modes:"Der er ikke valgt tilstande, som sensoren skal være aktiv for",no_auto_bypass_modes:"Ingen tilstande er valgt for sensoren som automatisk kan omgås"}}},dialogs:{manage_groups:{title:"Administrer sensorgrupper",description:"I en sensorgruppe skal flere sensorer aktiveres inden for et tidsrum, før alarmen udløses.",no_items:"Ingen gruppe endnu",actions:{new_group:"Ny gruppe"}},create_group:{title:"Ny sensorgruppe",fields:{name:{heading:"Navn",description:"Sensorgruppe navn"},timeout:{heading:"Tiden er gået",description:"Tidsperioden i hvilken på hinanden følgende sensoraktiveringer udløser alarmen."},event_count:{heading:"Tælle",description:"Antal forskellige sensorer, der skal aktiveres for at udløse alarmen."},sensors:{heading:"Sensorer",description:"Vælg de sensorer, som denne gruppe indeholder."}},errors:{invalid_name:"Ugyldigt navn angivet.",insufficient_sensors:"Der skal vælges mindst 2 sensorer."}},edit_group:{title:"Rediger sensorgruppe ''{name}''"}}},codes:{title:"Koder",cards:{codes:{description:"Skift indstillinger for koden.",fields:{code_arm_required:{heading:"Brug kode ved tilkobling",description:"Kræv kode for at tilkoble alarmen"},code_disarm_required:{heading:"Brug kode ved frakobling",description:"Kræv kode for at frakoble alarmen"},code_mode_change_required:{heading:"Kræv kode for at skifte tilstand",description:"En gyldig kode skal angives for at ændre aktiveringstilstanden."},code_format:{heading:"Kode format",description:"Indstil input typen for lovelace alarm kortet.",code_format_number:"Pinkode",code_format_text:"Kodeord"}}},user_management:{title:"Brugeradministration",description:"Hver bruger har sin egen kode til at til/fra-koble alarmen.",no_items:"Der er ingen brugere endnu",actions:{new_user:"Ny bruger"}},new_user:{title:"Opret ny bruger",description:"Brugere kan oprettes for at give adgang til at styre alarmen.",fields:{name:{heading:"Navn",description:"Brugeren navn."},code:{heading:"Kode",description:"Kode til denne bruger."},confirm_code:{heading:"Bekræft kode",description:"Gentag koden."},can_arm:{heading:"Tillad at bruge til tilkobling",description:"Indtastning af koden tilkobler alarmen"},can_disarm:{heading:"Tillad kode for frakobling",description:"Indtastning af koden frakobler alarmen"},is_override_code:{heading:"Overstyringskode",description:"Indtastning af denne kode vil tilkoble alarmen uanset tilstande"},area_limit:{heading:"Begrænset område",description:"Begræns brugeren til kun at styre det valgte område"}},errors:{no_name:"Ingen navn givet.",no_code:"Koden skal være på mindst 4 karakterer/numre.",code_mismatch:"Koderne er ikke ens."}},edit_user:{title:"Rediger bruger",description:"Ændre indstillinger for brugeren ''{name}''.",fields:{old_code:{heading:"Nuværende kode",description:"Nuværende kode, lad være tomt hvis uændret."}}}}},actions:{title:"Handlinger",cards:{notifications:{title:"Meddelelser",description:"Ved hjælp af dette panel kan du administrere meddelelser, der skal sendes når en bestemt alarmhændelse opstår.",table:{no_items:"Der er ingen meddelelser oprettet endnu.",no_area_warning:"Handlingen er ikke tildelt noget område."},actions:{new_notification:"Ny meddelelse"}},actions:{description:"Dette panel kan bruges til at ændre en enhed, når alarmtilstanden ændres.",table:{no_items:"Der er ingen handlinger oprettet endnu."},actions:{new_action:"Ny handling"}},new_notification:{title:"Konfigurer meddelelse",description:"Modtag en besked ved til-/frakobling af alarmen, ved aktivering osv.",trigger:"Betingelse",action:"Opgave",options:"Valgmuligheder",fields:{event:{heading:"Event",description:"Hvornår skal meddelelsen sendes",choose:{armed:{name:"Alarm aktiveret",description:"Alarmen er aktiveret med succes"},disarmed:{name:"Alarm frakoblet",description:"Alarmen er frakoblet"},triggered:{name:"Alarm udløst",description:"Alarmen er blevet udløst"},untriggered:{name:"Alarm ikke længere udløst",description:"Udløst tilstanden på alarmen er slut"},arm_failure:{name:"Kunne ikke tilkoble",description:"Alarmen kunne ikke tilkobles pga en eller flere åbne sensorer"},arming:{name:"Udgangstid startet",description:"Udgangstiden tæller ned, klar til at forlade huset."},pending:{name:"Indgangstid startet",description:"Indgangstiden tæller ned, alarmen udløses snart."}}},mode:{heading:"Tilstand",description:"Begræns handlingen til specifikke til/fra-koblings tilstande (valgfrit)"},title:{heading:"Titel",description:"Titel for en meddelelse"},message:{heading:"Meddelelse",description:"Indhold på meddelelse",insert_wildcard:"Indsæt wildcard",placeholders:{armed:"Alarmen er nu {{arm_mode}}",disarmed:"Alarmen er nu frakoblet",triggered:"Alarmen er udløst! Årsag: {{open_sensors}}.",untriggered:"Alarmen er ikke længere udløst.",arm_failure:"Alarmen kan ikke tilkobles lige nu pga: {{open_sensors}}.",arming:"Alarmen til blive tilkoblet snart, venligst forlad huset.",pending:"Alarmen udløses snart, vær hurtig til at frakoble!"}},open_sensors_format:{heading:"Format for open_sensors wildcard",description:"Vælg hvilken sensorinformation der skal indsættes i meddelelsen",options:{default:"Navne og tilstande",short:"Kun navne"}},arm_mode_format:{heading:"Oversættelse for arm_mode wildcard",description:"Vælg på hvilket sprog tilkoblingstilstanden indsættes i beskeden"},target:{heading:"Target",description:"Enhed meddelelsen sendes til"},media_player_entity:{heading:"Medieafspillerenhed",description:"Medieafspiller til at afspille beskeden på"},name:{heading:"Navn",description:"Beskrivelse af denne meddelelse",placeholders:{armed:"Underret {target} ved tilkobling",disarmed:"Underret {target} ved frakobling",triggered:"Underret {target} når udløst",untriggered:"Underret {target} når udløst tilstant stoppes",arm_failure:"Underret {target} ved fejl",arming:"Underret {target} når tilkobling er i gang",pending:"Underret {target} når frakobling er i gang"}},delete:{heading:"Slet automatisering",description:"Fjern automatiseringen permanent"}},actions:{test:"Prøv det"}},new_action:{title:"Konfigurer handling",description:"Skift lys eller anden enhed (såsom sirener) ved til-/frakobling af alarmen, ved aktivering osv.",fields:{event:{heading:"Event",description:"Hvornår skal handlingen udføres"},area:{heading:"Område",description:"Område som hændelsen gælder for."},mode:{heading:"Tilstand",description:"Begræns handlingen til specifikke tilkonlingstilstande (valgfrit)"},entity:{heading:"Enhed",description:"Enhed handlingen udføres på"},action:{heading:"Handling",description:"Handling som skal udføres på enhed",no_common_actions:"Handlinger kan kun tildeles i YAML-tilstand for de valgte enheder."},name:{heading:"Navn",description:"Beskrivelse for denne handling",placeholders:{armed:"Sæt {entity} til {state} ved tilkobling",disarmed:"Sæt {entity} til {state} ved frakobling",triggered:"Sæt {entity} til {state} når alarm udløses",untriggered:"Sæt {entity} til {state} når udløst alarm stopper",arm_failure:"Sæt {entity} til {state} ved fejl",arming:"Sæt {entity} til {state} når tilkobling er i gang",pending:"Sæt {entity} til {state} når frakobling er i gang"}}}}}}},aa={common:We,components:Xe,title:Je,panels:ea},ta=Object.freeze({__proto__:null,common:We,components:Xe,default:aa,panels:ea,title:Je}),ia={modes_short:{armed_away:"Abwesend",armed_home:"Zuhause",armed_night:"Nacht",armed_custom_bypass:"Benutzerdefiniert",armed_vacation:"Urlaub"},enabled:"Aktiviert",disabled:"Deaktiviert"},na={time_picker:{seconds:"Sekunden",minutes:"Minuten"},editor:{ui_mode:"Zu UI",yaml_mode:"Zu YAML",edit_in_yaml:"In YAML bearbeiten"},table:{filter:{label:"Elemente filtern",item:"Filtern nach {name}",hidden_items:"{number} {number, plural,\n one {item is}\n other {items are}\n} versteckt"}}},sa="Alarm Panel",ra={general:{title:"Allgemein",cards:{general:{description:"Dieses Panel legt globale Einstellungen für den Alarm fest.",fields:{disarm_after_trigger:{heading:"Entschärfen nach Auslösung",description:"Nach Ablauf der Auslösezeit wird der Alarm entschärft, anstatt in den scharfen Zustand zurückzukehren."},ignore_blocking_sensors_after_trigger:{heading:"Blockierende Sensoren beim erneuten Scharfschalten ignorieren",description:"Kehren Sie in den scharfgeschalteten Zustand zurück, ohne zu prüfen, ob die Sensoren noch aktiv sind."},enable_mqtt:{heading:"MQTT aktivieren",description:"Erlaubt die Steuerung der Alarmzentrale über MQTT."},enable_master:{heading:"Alarm-Master aktivieren",description:"Erzeugt eine Entität zur gleichzeitigen Kontrolle aller Bereiche."}},actions:{setup_mqtt:"MQTT Konfiguration",setup_master:"Master Konfiguration"}},modes:{title:"Modi",description:"Mit diesem Panel können die Scharfschaltmodi des Alarms eingestellt werden.",modes:{armed_away:"Abwesend wird verwendet, wenn alle Personen das Haus verlassen haben. Alle Türen und Fenster, die den Zugang zum Haus ermöglichen, werden bewacht, ebenso wie die Bewegungsmelder im Haus.",armed_home:"Zuhause wird verwendet, wenn der Alarm ausgelöst wird, während sich Personen im Haus befinden. Alle Türen und Fenster, die den Zugang zum Haus ermöglichen, werden bewacht, aber nicht die Bewegungsmelder im Haus.",armed_night:"Nacht wird verwendet, wenn der Alarm vor dem Schlafengehen eingestellt wird. Alle Türen und Fenster, die den Zugang zum Haus ermöglichen, werden überwacht, und ausgewählte Bewegungssensoren (im Erdgeschoss) im Haus.",armed_vacation:"Urlaub kann als Erweiterung von Abwesend bei längerer Abwesenheit verwendet werden. Die Verzögerungszeiten und Auslösereaktionen können (wie gewünscht) an die Abwesenheit angepasst werden.",armed_custom_bypass:"Individuell: ein zusätzlicher Modus, um Ihren eigenen Sicherheitsbereich zu definieren."},number_sensors_active:"{number} {number, plural,\n one {Sensor}\n other {Sensoren}\n} aktiv",fields:{status:{heading:"Status",description:"Steuert, ob der Alarm in diesem Modus aktiviert werden kann."},exit_delay:{heading:"Aktivierungsverzögerung",description:"Beim Scharfschalten des Alarms lösen die Sensoren innerhalb dieser Zeitspanne noch nicht den Alarm aus."},entry_delay:{heading:"Auslöseverzögerung",description:"Verzögerungszeit bis zur Auslösung des Alarms, nachdem einer der Sensoren aktiviert wurde."},trigger_time:{heading:"Auslösezeit",description:"Zeit, in der der Alarm nach der Aktivierung im ausgelösten Zustand bleibt."}}},mqtt:{title:"MQTT Konfiguration",description:"Dieses Panel kann für die Konfiguration der MQTT-Schnittstelle verwendet werden.",fields:{state_topic:{heading:"Status-Topic",description:"Topic, unter dem Statusaktualisierungen veröffentlicht werden"},event_topic:{heading:"Ereignis-Topic",description:"Topic, unter dem Alarmereignisse veröffentlicht werden"},command_topic:{heading:"Kommando-Topic",description:"Topic, auf das Alarmo bei Scharf-/Unscharfschaltbefehlen hört"},require_code:{heading:"Code notwendig",description:"Code muss mit dem Befehl gesendet werden"},state_payload:{heading:"Konfiguriere Payload pro Zustand",item:"Definiere Payload für den Zustand ''{state}''"},command_payload:{heading:"Konfiguriere Payload pro Kommando",item:"Definiere Payload für das Kommando ''{command}''"}}},areas:{title:"Bereiche",description:"Bereiche können verwendet werden, um Ihr Alarmsystem zu unterteilen.",no_items:"Es sind noch keine Bereiche definiert.",table:{remarks:"Bemerkungen",summary:"Dieser Bereich enthält {summary_sensors} und {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {Sensor}\n other {Sensoren}\n}",summary_automations:"{number} {number, plural,\n one {Aktion}\n other {Aktionen}\n}"},actions:{add:"Hinzufügen"}}},dialogs:{create_area:{title:"Neuer Bereich",fields:{copy_from:"Einstellungen kopieren von"}},edit_area:{title:"Bereich ''{area}'' bearbeiten",name_warning:"Hinweis: Das Ändern des Namens ändert die Entity-ID!"},remove_area:{title:"Bereich entfernen?",description:"Sind Sie sicher, dass Sie diesen Bereich entfernen möchten? Dieser Bereich enthält {sensors} Sensoren und {automations} Aktionen, die ebenfalls entfernt werden."},edit_master:{title:"Master-Konfiguration"},disable_master:{title:"Master deaktivieren?",description:"Sind Sie sicher, dass Sie den Alarmmaster entfernen möchten? Dieser Bereich enthält {automations} Aktionen, die ebenfalls entfernt werden."}}},sensors:{title:"Sensoren",cards:{sensors:{description:"Derzeit konfigurierte Sensoren. Klicken Sie auf ein Element, um Änderungen vorzunehmen.",table:{no_items:"Hier gibt es keine Sensoren, die angezeigt werden sollen.",no_area_warning:"Der Sensor ist keinem Bereich zugeordnet.",arm_modes:"Aktivierungsmodi",always_on:"(Immer)"}},add_sensors:{title:"Sensoren hinzufügen",description:"Fügen Sie weitere Sensoren hinzu. Achten Sie darauf, dass Ihre Sensoren einen passenden Namen haben, damit Sie sie identifizieren können.",no_items:"Es gibt keine verfügbaren HA-Entitäten, die für den Alarm konfiguriert werden können. Stellen Sie sicher, dass Sie Entitäten des Typs binary_sensor einschließen.",table:{type:"Erkannter Typ"},actions:{add_to_alarm:"Zum Alarm hinzufügen",filter_supported:"Elemente mit unbekanntem Typ ausblenden"}},editor:{title:"Sensor bearbeiten",description:"Konfigurieren der Sensoreinstellungen von ''{entity}''.",fields:{entity:{heading:"Entität",description:"Entität, die diesem Sensor zugeordnet ist"},area:{heading:"Bereich",description:"Wählen Sie einen Bereich, der diesen Sensor enthält."},group:{heading:"Gruppieren",description:"Mit anderen Sensoren gruppieren für kombinierte Auslösung."},device_type:{heading:"Gerätetyp",description:"Wählen Sie einen Gerätetyp, um die entsprechenden Einstellungen automatisch anzuwenden.",choose:{door:{name:"Tür",description:"Eine Tür, ein Tor oder ein anderer Eingang, die/das/der zum Betreten/Verlassen der Wohnung verwendet wird."},window:{name:"Fenster",description:"Ein Fenster oder eine Tür, das/die nicht zum Betreten des Hauses verwendet wird, z. B. ein Balkon."},motion:{name:"Bewegung",description:"Anwesenheitssensor oder ähnliches Gerät mit einer Verzögerung zwischen den Aktivierungen."},tamper:{name:"Sabotagekontakt",description:"Detektor für das Entfernen der Sensorabdeckung, Glasbruchsensor usw."},environmental:{name:"Umwelt",description:"Rauch-/Gassensor, Leckdetektor usw. (nicht im Zusammenhang mit Einbruchschutz)."},other:{name:"Allgemein"}}},always_on:{heading:"Immer aktiv",description:"Der Sensor soll immer den Alarm auslösen."},modes:{heading:"Aktivierte Modi",description:"Alarmmodi, in denen dieser Sensor aktiv ist."},arm_on_close:{heading:"Scharfschalten nach Schließen",description:"Nach der Deaktivierung dieses Sensors wird die verbleibende Ausgangsverzögerung automatisch übersprungen."},use_exit_delay:{heading:"Aktivierungsverzögerung verwenden",description:"Der Sensor darf aktiv sein, wenn die Aktivierungsverzögerung beginnt."},use_entry_delay:{heading:"Auslöseverzögerung verwenden",description:"Die Sensoraktivierung löst den Alarm nach der Auslöseverzögerung aus und nicht direkt."},entry_delay:{heading:"Auslöseverzögerung",description:"Ersetzt die Verzögerung (wie für den Scharfschaltmodus konfiguriert) durch einen spezifischer Wert."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Offen bei Scharfschaltung zulassen",description:"Der Zustand OFFEN während der Scharfschaltung wird ignoriert (eine nachfolgende Sensoraktivierung löst den Alarm aus)."},auto_bypass:{heading:"Automatische Umgehung",description:"Diesen Sensor vom Alarm ausschließen, wenn er während des Scharfschaltens offen ist.",modes:"Modi, in denen der Sensor umgangen werden kann"},trigger_unavailable:{heading:"Auslösen, wenn nicht verfügbar",description:"Wenn der Sensorstatus 'nicht verfügbar' wird, wird der Sensor aktiviert."}},actions:{toggle_advanced:"Erweiterte Einstellungen",remove:"Entfernen",setup_groups:"Gruppen einrichten"},errors:{description:"Bitte korrigieren Sie die folgenden Fehler:",no_area:"Es ist kein Bereich ausgewählt",no_modes:"Es sind keine Modi ausgewählt, für die der Sensor aktiv sein sollte",no_auto_bypass_modes:"Es sind keine Modi ausgewählt, für die der Sensor automatisch umgangen werden kann"}}},dialogs:{manage_groups:{title:"Sensorgruppen verwalten",description:"In einer Sensorgruppe müssen mehrere Sensoren innerhalb eines Zeitraums aktiviert werden, bevor der Alarm ausgelöst wird.",no_items:"Noch keine Gruppen",actions:{new_group:"Neue Gruppe"}},create_group:{title:"Neue Sensorgruppe",fields:{name:{heading:"Name",description:"Name der Sensorgruppe"},timeout:{heading:"Time-out",description:"Zeitspanne, in der aufeinanderfolgende Sensoraktivierungen den Alarm auslösen."},event_count:{heading:"Menge",description:"Anzahl verschiedener Sensoren, die aktiviert werden müssen, um den Alarm auszulösen."},sensors:{heading:"Sensoren",description:"Wählen Sie die Sensoren aus, die in dieser Gruppe enthalten sind."}},errors:{invalid_name:"Ungültiger Name angegeben.",insufficient_sensors:"Es müssen mindestens 2 Sensoren ausgewählt werden."}},edit_group:{title:"Sensorgruppe ''{name}'' bearbeiten"}}},codes:{title:"Codes",cards:{codes:{description:"Einstellungen für den Code ändern.",fields:{code_arm_required:{heading:"Scharfschalt-Code verwenden",description:"Scharfschaltung erfordert einen Code"},code_disarm_required:{heading:"Entschärfungscode verwenden",description:"Unscharfschaltung erfordert einen Code"},code_mode_change_required:{heading:"Code verwenden zum Umschalten des Modus",description:"Um den aktiven Scharfschaltmodus zu ändern, ist ein gültiger Code erforderlich."},code_format:{heading:"Code-Format",description:"Legt den Eingabetyp für die Lovelace-Alarmkarte fest.",code_format_number:"Pincode",code_format_text:"Passwort"}}},user_management:{title:"Benutzerverwaltung",description:"Jeder Benutzer hat seinen eigenen Code zum Scharf-/Unscharfschalten des Alarms.",no_items:"Es sind noch keine Benutzer vorhanden",actions:{new_user:"Neuer Benutzer"}},new_user:{title:"Neuen Benutzer anlegen",description:"Es können Benutzer angelegt werden, die Zugriff auf die Bedienung des Alarms haben.",fields:{name:{heading:"Name",description:"Name des Benutzers."},code:{heading:"Code",description:"Code für diesen Benutzer."},confirm_code:{heading:"Code wiederholen",description:"Geben Sie den Code erneut ein."},can_arm:{heading:"Code für Scharfschaltung zulassen",description:"Durch Eingabe dieses Codes wird der Alarm aktiviert"},can_disarm:{heading:"Code zur Entschärfung zulassen",description:"Durch Eingabe dieses Codes wird der Alarm deaktiviert"},is_override_code:{heading:"Ist Übersteuerungs-Code",description:"Die Eingabe dieses Codes schaltet den Alarm zwangsweise scharf"},area_limit:{heading:"Eingeschränkte Bereiche",description:"Beschränkung der Kontrolle des Benutzers auf die ausgewählten Bereiche"}},errors:{no_name:"Kein Name angegeben.",no_code:"Der Code sollte mindestens 4 Zeichen/Zahlen enthalten.",code_mismatch:"Die Codes stimmen nicht überein."}},edit_user:{title:"Nutzer bearbeiten",description:"Ändere die Konfiguration für den Nutzer ''{name}''.",fields:{old_code:{heading:"Aktueller Code",description:"Aktueller Code (leer lassen, um Code nicht zu ändern)."}}}}},actions:{title:"Aktionen",cards:{notifications:{title:"Benachrichtigungen",description:"Mit diesem Panel können Sie Benachrichtigungen verwalten, die beim Auftreten eines bestimmten Alarmereignisses gesendet werden.",table:{no_items:"Es sind noch keine Benachrichtigungen erstellt worden.",no_area_warning:"Die Aktion ist keinem Bereich zugeordnet."},actions:{new_notification:"Neue Benachrichtigung"}},actions:{description:"Dieses Panel kann verwendet werden, um ein Gerät zu schalten, wenn sich der Alarmzustand ändert.",table:{no_items:"Es sind noch keine Aktionen erstellt worden."},actions:{new_action:"Neue Aktion"}},new_notification:{title:"Benachrichtigung konfigurieren",description:"Erhalten Sie eine Benachrichtigung beim Scharf-/Unscharfschalten des Alarms, bei Aktivierung usw.",trigger:"Bedingung",action:"Aktion",options:"Optionen",fields:{event:{heading:"Ereignis",description:"Wann soll die Benachrichtigung gesendet werden",choose:{armed:{name:"Alarm ist scharf",description:"Der Alarm wurde erfolgreich scharfgeschaltet"},disarmed:{name:"Alarm ist unscharf",description:"Der Alarm wurde unscharf"},triggered:{name:"Alarm ist ausgelöst",description:"Der Alarm wurde ausgelöst"},untriggered:{name:"Alarm ist nicht mehr ausgelöst",description:"Der ausgelöste Zustand des Alarms ist beendet"},arm_failure:{name:"Scharfschaltung fehlgeschlagen",description:"Die Scharfschaltung des Alarms ist aufgrund eines oder mehrerer offener Sensoren fehlgeschlagen"},arming:{name:"Aktivierungsverzögerung gestartet",description:"Aktivierungsverzögerung ist gestartet, bereit, das Haus zu verlassen."},pending:{name:"Auslöseverzögerung gestartet",description:"Auslöseverzögerung ist gestartet, der Alarm wird bald ausgelöst."}}},mode:{heading:"Modus",description:"Beschränkung der Aktion auf bestimmte Alarm-Modi (optional)"},title:{heading:"Titel",description:"Titel für die Benachrichtigungsmeldung"},message:{heading:"Nachricht",description:"Inhalt der Benachrichtigungsmeldung",insert_wildcard:"Platzhalter einfügen",placeholders:{armed:"Der Alarm ist auf {{arm_mode}} eingestellt",disarmed:"Der Alarm ist jetzt AUS",triggered:"Der Alarm wurde ausgelöst! Ursache: {{open_sensors}}.",untriggered:"Der Alarm ist nicht mehr ausgelöst.",arm_failure:"Der Alarm konnte im Moment nicht scharfgeschaltet werden, aufgrund von: {{open_sensors}}.",arming:"Der Alarm wird bald scharf geschaltet, bitte verlassen Sie das Haus.",pending:"Der Alarm wird in Kürze ausgelöst, bitte entschärfen Sie ihn schnell!"}},open_sensors_format:{heading:"Format für open_sensors Platzhalter",description:"Wählen Sie, welche Sensorinformationen in die Nachricht eingefügt werden",options:{default:"Namen und Zustände",short:"Nur Namen"}},arm_mode_format:{heading:"Übersetzung für arm_mode Platzhalter",description:"Wählen Sie, in welcher Sprache der Scharfschaltungsmodus in die Nachricht eingefügt wird"},target:{heading:"Ziel",description:"Gerät, an das die Benachrichtigung gesendet werden soll"},media_player_entity:{heading:"Mediaplayer-Entität",description:"Mediaplayer zum Abspielen der Nachricht."},name:{heading:"Name",description:"Beschreibung für diese Meldung",placeholders:{armed:"Benachrichtigt {target} beim Scharfschalten",disarmed:"Benachrichtigt {target} beim Entschärfen",triggered:"Benachrichtigt {target} bei Auslösung",untriggered:"Benachrichtigt {target}, wenn Auslösung beendet",arm_failure:"Benachrichtigt {target}, wenn Scharfschaltung nicht möglich",arming:"Benachrichtigt {target} bei Beginn Aktivierungsverzögerung",pending:"Benachrichtigt {target} bei Beginn Auslöseverzögerung"}},delete:{heading:"Automatisierung löschen",description:"Diese Automatisierung dauerhaft entfernen"}},actions:{test:"Testen"}},new_action:{title:"Aktion konfigurieren",description:"Schaltet Lichter oder Geräte (z. B. Sirenen) beim Scharf-/Unscharfschalten des Alarms, bei Aktivierung usw.",fields:{event:{heading:"Ereignis",description:"Wann soll die Aktion ausgeführt werden"},area:{heading:"Bereich",description:"Bereich, für den das Ereignis gilt."},mode:{heading:"Modus",description:"Beschränkung der Aktion auf bestimmte Alarm-Modi (optional)"},entity:{heading:"Entität",description:"Entität, für die eine Aktion durchgeführt werden soll"},action:{heading:"Aktion",description:"Aktion, die mit der Entität durchgeführt werden soll",no_common_actions:"Aktionen können nur im YAML-Modus für die ausgewählten Entitäten zugewiesen werden."},name:{heading:"Name",description:"Beschreibung für diese Aktion",placeholders:{armed:"Setzt {entity} beim Scharfschalten auf {state}",disarmed:"Setzt {entity} bei Entschärfung auf {state}",triggered:"Setzt {entity} bei Auslösung auf {state}",untriggered:"Setzt {entity} auf {state}, wenn die Auslösung endet",arm_failure:"Setzt {entity} im Fehlerfall auf {state}",arming:"Setzt {entity} bei Beginn Aktivierungsverzögerung auf {state}",pending:"Setzt {entity} bei Beginn Auslöseverzögerung auf {state}"}}}}}}},oa={common:ia,components:na,title:sa,panels:ra},da=Object.freeze({__proto__:null,common:ia,components:na,default:oa,panels:ra,title:sa}),la={modes_short:{armed_away:"Away",armed_home:"Home",armed_night:"Night",armed_custom_bypass:"Custom",armed_vacation:"Vacation"},enabled:"Enabled",disabled:"Disabled"},ca={time_picker:{seconds:"seconds",minutes:"minutes"},editor:{ui_mode:"To UI",yaml_mode:"To YAML",edit_in_yaml:"Edit in YAML"},table:{filter:{label:"Filter items",item:"Filter by {name}",hidden_items:"{number} {number, plural,\n one {item is}\n other {items are}\n} hidden"}}},ma="Alarm panel",ha={general:{title:"General",cards:{general:{description:"This panel defines some global settings for the alarm.",fields:{disarm_after_trigger:{heading:"Disarm after triggering",description:"Automatically disarm the alarm rather than returning to the armed state."},ignore_blocking_sensors_after_trigger:{heading:"Ignore blocking sensors when re-arming",description:"Return to armed state without checking for sensors that may still be active."},enable_mqtt:{heading:"Enable MQTT",description:"Allow the alarm panel to be controlled through MQTT."},enable_master:{heading:"Enable alarm master",description:"Creates an entity for controlling all areas simultaneously."}},actions:{setup_mqtt:"MQTT Configuration",setup_master:"Master Configuration"}},modes:{title:"Modes",description:"This panel can be used to set up the arm modes of the alarm.",modes:{armed_away:"Armed away will be used when all people left the house. All doors and windows allowing access to the house will be guarded, as well as motion sensors inside the house.",armed_home:"Armed home (also known as armed stay) will be used when setting the alarm while people are in the house. All doors and windows allowing access to the house will be guarded, but not motion sensors inside the house.",armed_night:"Armed night will be used when setting the alarm before going to sleep. All doors and windows allowing access to the house will be guarded, and selected motion sensors (downstairs) in the house.",armed_vacation:"Armed vacation can be used as an extension to the armed away mode in case of absence for longer duration. The delay times and trigger responses can be adapted (as desired) to being distant from home.",armed_custom_bypass:"An extra mode for defining your own security perimeter."},number_sensors_active:"{number} {number, plural,\n one {sensor}\n other {sensors}\n} active",fields:{status:{heading:"Status",description:"Controls whether the alarm can be armed in this mode."},exit_delay:{heading:"Exit delay",description:"When arming the alarm, within this time period the sensors will not trigger the alarm yet."},entry_delay:{heading:"Entry delay",description:"Delay time until the alarm is triggered after one of the sensors is activated."},trigger_time:{heading:"Trigger time",description:"Time during which the alarm will remain in the triggered state after activation."}}},mqtt:{title:"MQTT configuration",description:"This panel can be used for configuration of the MQTT interface.",fields:{state_topic:{heading:"State topic",description:"Topic on which state updates are published"},event_topic:{heading:"Event topic",description:"Topic on which alarm events are published"},command_topic:{heading:"Command topic",description:"Topic which Alarmo listens to for arm/disarm commands."},require_code:{heading:"Require code",description:"Require the code to be sent with the command."},state_payload:{heading:"Configure payload per state",item:"Define a payload for state ''{state}''"},command_payload:{heading:"Configure payload per command",item:"Define a payload for command ''{command}''"}}},areas:{title:"Areas",description:"Areas can be used for dividing your alarm system into multiple compartments.",no_items:"There are no areas defined yet.",table:{remarks:"Remarks",summary:"This area contains {summary_sensors} and {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {sensor}\n other {sensors}\n}",summary_automations:"{number} {number, plural,\n one {automation}\n other {automations}\n}"},actions:{add:"Add"}}},dialogs:{create_area:{title:"New area",fields:{copy_from:"Copy settings from"}},edit_area:{title:"Editing area ''{area}''",name_warning:"Note: changing the name will change the entity ID"},remove_area:{title:"Remove area?",description:"Are you sure you want to remove this area? This area contains {sensors} sensors and {automations} automations, which will be removed as well."},edit_master:{title:"Master configuration"},disable_master:{title:"Disable master?",description:"Are you sure you want to remove the alarm master? This area contains {automations} automations, which will be removed with this action."}}},sensors:{title:"Sensors",cards:{sensors:{description:"Currently configured sensors. Click on an item to make changes.",table:{no_items:"There are no sensors to be displayed here.",no_area_warning:"Sensor is not assigned to any area.",arm_modes:"Arm Modes",always_on:"(Always)"}},add_sensors:{title:"Add Sensors",description:"Add more sensors. Make sure that your sensors have a suitable name, so you can identify them.",no_items:"There are no available HA entities that can be configured for the alarm. Make sure to include entities of the type binary_sensor.",table:{type:"Detected type"},actions:{add_to_alarm:"Add to alarm",filter_supported:"Hide items with unknown type"}},editor:{title:"Edit Sensor",description:"Configuring the sensor settings of ''{entity}''.",fields:{entity:{heading:"Entity",description:"Entity associated with this sensor"},area:{heading:"Area",description:"Select an area which contains this sensor."},group:{heading:"Group",description:"Group with other sensors for combined triggering."},device_type:{heading:"Device Type",description:"Choose a device type to automatically apply appropriate settings.",choose:{door:{name:"Door",description:"A door, gate or other entrance that is used for entering/leaving the home."},window:{name:"Window",description:"A window, or a door not used for entering the house such as balcony."},motion:{name:"Motion",description:"Presence sensor or similar device having a delay between activations."},tamper:{name:"Tamper",description:"Detector of sensor cover removal, glass break sensor, etc."},environmental:{name:"Environmental",description:"Smoke/gas sensor, leak detector, etc. (not related to burglar protection)."},other:{name:"Generic"}}},always_on:{heading:"Always on",description:"Sensor should always trigger the alarm."},modes:{heading:"Enabled modes",description:"Alarm modes in which this sensor is active."},arm_on_close:{heading:"Arm after closing",description:"After deactivation of this sensor, the remaining exit delay will automatically be skipped."},use_exit_delay:{heading:"Use exit delay",description:"Sensor is allowed to be active when the exit delay starts."},use_entry_delay:{heading:"Use entry delay",description:"Sensor activation triggers the alarm after the entry delay rather than directly."},entry_delay:{heading:"Entry delay",description:"Override the entry delay (as configured for the arm mode) with a delay specific to the sensor."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Allow open initially",description:"Open state while arming is ignored (subsequent sensor activation will trigger alarm)."},auto_bypass:{heading:"Bypass automatically",description:"Exclude this sensor from the alarm if it is open while arming.",modes:"Modes in which sensor may be bypassed"},trigger_unavailable:{heading:"Trigger when unavailable",description:"When the sensor state becomes 'unavailable', this will activate the sensor."}},actions:{toggle_advanced:"Advanced settings",remove:"Remove",setup_groups:"Setup groups"},errors:{description:"Please correct the following errors:",no_area:"No area is selected",no_modes:"No modes are selected for which the sensor should be active",no_auto_bypass_modes:"No modes are selected for the sensor may be automatically bypassed"}}},dialogs:{manage_groups:{title:"Manage sensor groups",description:"In a sensor group multiple sensors must be activated within a time period before the alarm is triggered.",no_items:"No groups yet",actions:{new_group:"New group"}},create_group:{title:"New sensor group",fields:{name:{heading:"Name",description:"Name for sensor group"},timeout:{heading:"Time-out",description:"Time period during which consecutive sensor activations triggers the alarm."},event_count:{heading:"Count",description:"Amount of different sensors that need to be activated to trigger the alarm."},sensors:{heading:"Sensors",description:"Select the sensors which are contained by this group."}},errors:{invalid_name:"Invalid name provided.",insufficient_sensors:"At least 2 sensors need to be selected."}},edit_group:{title:"Edit sensor group ''{name}''"}}},codes:{title:"Codes",cards:{codes:{description:"Change settings for the code.",fields:{code_arm_required:{heading:"Require code for arming",description:"A valid code must be provided to arm the alarm."},code_disarm_required:{heading:"Require code for disarming",description:"A valid code must be provided to disarm the alarm."},code_mode_change_required:{heading:"Require code for switching mode",description:"A valid code must be provided to change the arm mode which is active."},code_format:{heading:"Code format",description:"Sets the input type for Lovelace alarm card.",code_format_number:"Pincode",code_format_text:"Password"}}},user_management:{title:"User management",description:"Each user has its own code to arm/disarm the alarm.",no_items:"There are no users yet",actions:{new_user:"New user"}},new_user:{title:"Create new user",description:"Users can be created for providing access to operating the alarm.",fields:{name:{heading:"Name",description:"Name of the user."},code:{heading:"Code",description:"Code for this user."},confirm_code:{heading:"Confirm code",description:"Repeat the code."},can_arm:{heading:"Allow code for arming",description:"Entering this code activates the alarm"},can_disarm:{heading:"Allow code for disarming",description:"Entering this code deactivates the alarm"},is_override_code:{heading:"Is override code",description:"Entering this code will arm the alarm in force"},area_limit:{heading:"Restricted areas",description:"Limit user to control only the selected areas"}},errors:{no_name:"No name provided.",no_code:"Code should have 4 characters/numbers minimum.",code_mismatch:"The codes don't match."}},edit_user:{title:"Edit User",description:"Change configuration for user ''{name}''.",fields:{old_code:{heading:"Current code",description:"Current code, leave empty to leave unchanged."}}}}},actions:{title:"Actions",cards:{notifications:{title:"Notifications",description:"Using this panel, you can manage notifications to be sent when a certain alarm event occurs.",table:{no_items:"There are no notifications created yet.",no_area_warning:"Action is not assigned to any area."},actions:{new_notification:"New notification"}},actions:{description:"This panel can be used to switch a device when the alarm state changes.",table:{no_items:"There are no actions created yet."},actions:{new_action:"New action"}},new_notification:{title:"Configure notification",description:"Receive a notification when arming/disarming the alarm, on activation, etc.",trigger:"Condition",action:"Task",options:"Options",fields:{event:{heading:"Event",description:"When should the notification be sent",choose:{armed:{name:"Alarm is armed",description:"The alarm is succesfully armed"},disarmed:{name:"Alarm is disarmed",description:"The alarm is disarmed"},triggered:{name:"Alarm is triggered",description:"The alarm is triggered"},untriggered:{name:"Alarm no longer triggered",description:"The triggered state of the alarm has ended"},arm_failure:{name:"Failed to arm",description:"The arming of the alarm failed due to one or more open sensors"},arming:{name:"Exit delay started",description:"Exit delay started, ready to leave the house."},pending:{name:"Entry delay started",description:"Entry delay started, the alarm will trigger soon."}}},mode:{heading:"Mode",description:"Limit the action to specific arm modes (optional)"},title:{heading:"Title",description:"Title for the notification message"},message:{heading:"Message",description:"Content of the notification message",insert_wildcard:"Insert wildcard",placeholders:{armed:"The alarm is set to {{arm_mode}}",disarmed:"The alarm is now OFF",triggered:"The alarm is triggered! Cause: {{open_sensors}}.",untriggered:"The alarm is no longer triggered.",arm_failure:"The alarm could not be armed right now, due to: {{open_sensors}}.",arming:"The alarm will be armed soon, please leave the house.",pending:"The alarm is about to trigger, disarm it quickly!"}},open_sensors_format:{heading:"Format for open_sensors wildcard",description:"Choose which sensor information is inserted in the message",options:{default:"Names and states",short:"Names only"}},arm_mode_format:{heading:"Translation for arm_mode wildcard",description:"Choose in which language the arm mode is inserted in the message"},target:{heading:"Target",description:"Device to send the notification to"},media_player_entity:{heading:"Media player entity",description:"Media player to play the message on"},name:{heading:"Name",description:"Description for this notification",placeholders:{armed:"Notify {target} upon arming",disarmed:"Notify {target} upon disarming",triggered:"Notify {target} when triggered",untriggered:"Notify {target} when triggering stops",arm_failure:"Notify {target} on failure",arming:"Notify {target} when leaving",pending:"Notify {target} when arriving"}},delete:{heading:"Delete automation",description:"Permanently remove this automation"}},actions:{test:"Try it"}},new_action:{title:"Configure action",description:"Switch lights or devices (such as sirens) when arming/disarming the alarm, on activation, etc.",fields:{event:{heading:"Event",description:"When should the action be executed"},area:{heading:"Area",description:"Alarm area for which the event applies."},mode:{heading:"Mode",description:"Limit the action to specific arm modes (optional)"},entity:{heading:"Entity",description:"Entity to perform action on"},action:{heading:"Action",description:"Action to perform on the entity",no_common_actions:"Actions can only be assigned in YAML mode for the selected entities."},name:{heading:"Name",description:"Description for this action",placeholders:{armed:"Set {entity} to {state} upon arming",disarmed:"Set {entity} to {state} upon disarming",triggered:"Set {entity} to {state} when triggered",untriggered:"Set {entity} to {state} when triggering stops",arm_failure:"Set {entity} to {state} on failure",arming:"Set {entity} to {state} when leaving",pending:"Set {entity} to {state} when arriving"}}}}}}},pa={common:la,components:ca,title:ma,panels:ha},ga=Object.freeze({__proto__:null,common:la,components:ca,default:pa,panels:ha,title:ma}),ua={modes_short:{armed_away:"Ausente",armed_home:"En casa",armed_night:"Nocturno",armed_custom_bypass:"Personalizado",armed_vacation:"Vacaciones"},enabled:"Habilitar",disabled:"Deshabilitar"},va={time_picker:{seconds:"segundos",minutes:"minutos"},editor:{ui_mode:"Editar en la UI",yaml_mode:"Editar en YAML",edit_in_yaml:"Editar en YAML"},table:{filter:{label:"Filtrar entidades",item:"Filtrar por {name}",hidden_items:"{number} {number, plural,\n one {entidas está}\n other {entidades están}\n} oculta"}}},_a="Panel de alarma",ba={general:{title:"General",cards:{general:{description:"Este panel define algunos ajustes globales para la alarma.",fields:{disarm_after_trigger:{heading:"Desarmar después de disparar",description:"Una vez transcurrido el tiempo de activación, desactivar la alarma en lugar de volver al estado de armada."},ignore_blocking_sensors_after_trigger:{heading:"Ignorar los sensores de bloqueo al rearmar",description:"Regresar al estado armado sin verificar si hay sensores que aún puedan estar activos."},enable_mqtt:{heading:"Habilitar MQTT",description:"Permitir que el panel de alarma se controle a través de MQTT."},enable_master:{heading:"Habilitar alarma maestra",description:"Crea una entidad para controlar todas las áreas simultáneamente."}},actions:{setup_mqtt:"Configuración MQTT",setup_master:"Configuración maestra"}},modes:{title:"Modos",description:"Este panel se puede utilizar para configurar los modos de armado de la alarma.",modes:{armed_away:"Armado ausente se utilizará cuando todas las personas salgan de la casa. Todas las puertas y ventanas que permitan el acceso a la casa estarán vigiladas, así como los sensores de movimiento dentro de la casa.",armed_home:"Armado en casa (también conocido como estancia armada) se utilizará cuando se active la alarma mientras haya personas en la casa. Todas las puertas y ventanas que permitan el acceso a la casa estarán protegidas, pero no los sensores de movimiento dentro de la casa.",armed_night:"Armado nocturno se usará al configurar la alarma antes de irse a dormir. Todas las puertas y ventanas que permitan el acceso a la casa estarán resguardadas y se seleccionarán sensores de movimiento en la casa.",armed_vacation:"Armado en vacaciones se puede usar como una extensión del modo armado ausente en caso de ausencia de mayor duración. Los tiempos de retardo y las respuestas de activación se pueden adaptar (como se desee) a estar lejos de casa.",armed_custom_bypass:"Un modo adicional para definir su propio perímetro de seguridad."},number_sensors_active:"{number} {number, plural,\n one {sensor}\n other {sensores}\n} activo",fields:{status:{heading:"Estado",description:"Controla si la alarma se puede armar en este modo."},exit_delay:{heading:"Retardo de salida",description:"Al armar la alarma, dentro de este período de tiempo, los sensores aún no dispararán la alarma."},entry_delay:{heading:"Retardo de entrada",description:"Tiempo de retardo hasta que se activa la alarma después de que se active alguno de los sensores."},trigger_time:{heading:"Tiempo de activación",description:"Tiempo durante el cual sonará la sirena."}}},mqtt:{title:"Configuración MQTT",description:"Este panel se puede utilizar para configurar la interfaz MQTT.",fields:{state_topic:{heading:"Tema del estado",description:"Tema sobre el que se publican las actualizaciones de estado."},event_topic:{heading:"Tema del evento",description:"Tema sobre el que se publican los eventos de alarma."},command_topic:{heading:"Tema del comando",description:"Tema sobre el que se envían los comandos de armado / desarmado."},require_code:{heading:"Requerir código",description:"Requiere que el código se envíe con el comando."},state_payload:{heading:"Configurar la carga útil por estado",item:"Defina una carga útil para el estado ''{state}''"},command_payload:{heading:"Configurar la carga útil por comando",item:"Defina una carga útil para el comando ''{command}''"}}},areas:{title:"Áreas",description:"Las áreas se pueden utilizar para dividir su sistema de alarma en varios compartimentos.",no_items:"Aún no hay áreas definidas.",table:{remarks:"Comentarios",summary:"Esta área contiene {summary_sensors} y {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {sensor}\n other {sensores}\n}",summary_automations:"{number} {number, plural,\n one {automatizacion}\n other {automatizaciones}\n}"},actions:{add:"Agregar"}}},dialogs:{create_area:{title:"Nueva área",fields:{copy_from:"Copiar la configuración de"}},edit_area:{title:"Editando área ''{area}''",name_warning:"Nota: cambiar el nombre cambiará el ID de la entidad."},remove_area:{title:"¿Eliminar área?",description:"¿Está seguro de que desea eliminar esta área? Esta área contiene {sensors} sensores y {automations} automatizaciones que también se eliminarán."},edit_master:{title:"Configuración maestra"},disable_master:{title:"¿Deshabilitar maestro?",description:"¿Está seguro de que desea eliminar la alarma maestra? Esta área contiene {sensors} sensores y {automations} automatizaciones que también se eliminarán."}}},sensors:{title:"Sensores",cards:{sensors:{description:"Sensores configurados actualmente. Haga clic en una entidad para realizar cambios.",table:{no_items:"No hay sensores para mostrar aquí.",no_area_warning:"El sensor no está asignado a ningún área.",arm_modes:"Modos de armado",always_on:"(Siempre)"}},add_sensors:{title:"Agregar sensores",description:"Agrega más sensores. Asegúrate de que tus sensores tengan un nombre amigable, para que puedas identificarlos.",no_items:"No hay entidades HA disponibles que se puedan configurar para la alarma. Asegúrese de incluir entidades del tipo sensor binario.",table:{type:"Tipo detectado"},actions:{add_to_alarm:"Agregar a la alarma",filter_supported:"Ocultar elementos con tipo desconocido"}},editor:{title:"Editar sensor",description:"Configurando los ajustes del sensor de ''{entity}''.",fields:{entity:{heading:"Entidad",description:"Entidad asociada a este sensor"},area:{heading:"Área",description:"Seleccione un área que contenga este sensor."},group:{heading:"Grupo",description:"Agrupar con otros sensores para un disparado combinado."},device_type:{heading:"Tipo de dispositivo",description:"Elija un tipo de dispositivo para aplicar automáticamente la configuración adecuada.",choose:{door:{name:"Puerta",description:"Una puerta, portón u otra entrada que se utilice para entrar / salir de la casa."},window:{name:"Ventana",description:"Una ventana o una puerta que no se use para entrar a la casa, como un balcón."},motion:{name:"Movimiento",description:"Sensor de presencia o dispositivo similar que tiene un retardo entre activaciones."},tamper:{name:"Sabotaje",description:"Detector de extracción de la cubierta del sensor, sensor de rotura de vidrio, etc."},environmental:{name:"Medioambiental",description:"Sensor de humo / gas, detector de fugas, etc. (no relacionado con la protección antirrobo)."},other:{name:"Genérico"}}},always_on:{heading:"Siempre encendido",description:"El sensor siempre debe activar la alarma."},modes:{heading:"Modos habilitados",description:"Modos de alarma en los que este sensor está activo."},arm_on_close:{heading:"Armar después de cerrar",description:"Después de la desactivación de este sensor, se saltará automáticamente el retardo de salida restante."},use_exit_delay:{heading:"Usar retardo de salida",description:"Se permite que el sensor esté activo cuando comienza el retardo de salida."},use_entry_delay:{heading:"Usar retardo de entrada",description:"La activación del sensor activa la alarma después del retardo de entrada en lugar de directamente."},entry_delay:{heading:"Retardo de entrada",description:"Reemplace el retraso (como se establece para el modo de seguridad) con un valor específico."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Permitir abrir mientras se arma",description:"Si el sensor aún está activo después del retardo de salida, esto no hará que falle el armado."},auto_bypass:{heading:"Omitir automáticamente",description:"Excluya este sensor de la alarma si está abierto mientras se arma.",modes:"Modos en los que se puede omitir el sensor"},trigger_unavailable:{heading:"Activar cuando no esté disponible",description:"Cuando el estado del sensor se vuelve 'no disponible', esto activará el sensor."}},actions:{toggle_advanced:"Configuración avanzada",remove:"Eliminar",setup_groups:"Configurar grupos"},errors:{description:"Por favor, corrija los siguientes errores:",no_area:"No se ha seleccionado ninguna área",no_modes:"No se han seleccionados modos para los que el sensor deba estar activo",no_auto_bypass_modes:"No se han seleccionados modos para los que el sensor pueda ser omitido"}}},dialogs:{manage_groups:{title:"Administrar grupos de sensores",description:"En un grupo de sensores, se deben activar varios sensores dentro de un período de tiempo antes de que se dispare la alarma.",no_items:"Todavía no hay grupos",actions:{new_group:"Nuevo grupo"}},create_group:{title:"Nuevo grupo de sensores",fields:{name:{heading:"Nombre",description:"Nombre del grupo de sensores"},timeout:{heading:"Tiempo muerto",description:"Período de tiempo durante el cual las activaciones consecutivas del sensor activan la alarma."},event_count:{heading:"Nombre",description:"Cantidad de sensores diferentes que deben activarse para activar la alarma."},sensors:{heading:"Sensores",description:"Seleccione los sensores que están contenidos en este grupo."}},errors:{invalid_name:"Nombre proporcionado no válido.",insufficient_sensors:"Se deben seleccionar al menos 2 sensores."}},edit_group:{title:"Editar grupo de sensores '{name}'"}}},codes:{title:"Códigos",cards:{codes:{description:"Cambiar la configuración del código.",fields:{code_arm_required:{heading:"Usar código de armado",description:"Requiere un código para armar la alarma."},code_disarm_required:{heading:"Usar código de desarmado",description:"Requiere un código para desarmar la alarma."},code_mode_change_required:{heading:"Requerir código para cambiar de modo",description:"Se necesita un código válido para cambiar el modo de armado que está activo."},code_format:{heading:"Formato del código",description:"Establece el tipo de entrada para la tarjeta de la alarma.",code_format_number:"Código PIN",code_format_text:"Contraseña"}}},user_management:{title:"Gestión de usuarios",description:"Cada usuario tiene su propio código para armar / desarmar la alarma.",no_items:"Aún no hay usuarios",actions:{new_user:"Nuevo usuario"}},new_user:{title:"Crear nuevo usuario",description:"Se pueden crear usuarios para proporcionar acceso a la operación de la alarma.",fields:{name:{heading:"Nombre",description:"Nombre del usuario."},code:{heading:"Código",description:"Código para este usuario."},confirm_code:{heading:"Confirmar código",description:"Repite el código."},can_arm:{heading:"Permitir código para armar",description:"Al ingresar este código se activa la alarma."},can_disarm:{heading:"Permitir código para desarmar",description:"Al ingresar este código se desactiva la alarma."},is_override_code:{heading:"Es un código de anulación",description:"Al ingresar este código se forzará el armado de la alarma."},area_limit:{heading:"Áreas restringidas",description:"Limitar al usuario a controlar solo las áreas seleccionadas"}},errors:{no_name:"No se proporcionó ningún nombre.",no_code:"El código debe tener 4 caracteres / números como mínimo.",code_mismatch:"Los códigos no coinciden."}},edit_user:{title:"Editar usuario",description:"Cambiar la configuración del usuario ''{name}''.",fields:{old_code:{heading:"Código actual",description:"Código actual, déjelo en blanco para no modificarlo."}}}}},actions:{title:"Acciones",cards:{notifications:{title:"Notificaciones",description:"Usando este panel, puede administrar las notificaciones que se enviarán durante un evento de alarma determinado.",table:{no_items:"Aún no se han creado notificaciones.",no_area_warning:"La acción no está asignada a ningún área."},actions:{new_notification:"Nueva notificación"}},actions:{description:"Este panel se puede utilizar para cambiar un dispositivo cuando cambia el estado de alarma.",table:{no_items:"Aún no se han creado acciones."},actions:{new_action:"Nueva acción"}},new_notification:{title:"Crear notificación",description:"Crear una nueva notificación.",trigger:"Condición",action:"Tarea",options:"Opciones",fields:{event:{heading:"Evento",description:"Cuándo debe enviarse la notificación.",choose:{armed:{name:"La alarma está armada",description:"La alarma está correctamente armada."},disarmed:{name:"La alarma está desarmada",description:"La alarma está desarmada."},triggered:{name:"Se ha disparado la alarma",description:"La alarma se ha disparado."},untriggered:{name:"Alarm not longer triggered",description:"The triggered state of the alarm has ended"},arm_failure:{name:"No se pudo armar",description:"El armado de la alarma falló debido a uno o más sensores abiertos."},arming:{name:"Se ha iniciado el retardo de salida",description:"Se ha iniciado el retardo de salida, listo para salir de la casa."},pending:{name:"Se ha iniciado el retardo de entrada",description:"Se ha iniciado el retardo de entrada, la alarma se disparará pronto."}}},mode:{heading:"Modo",description:"Limita la acción a modos de armado específicos (opcional)."},title:{heading:"Título",description:"Título del mensaje de notificación."},message:{heading:"Mensaje",description:"Contenido del mensaje de notificación.",insert_wildcard:"Insertar comodín",placeholders:{armed:"La alarma está configurada en {{arm_mode}}",disarmed:"Ahora la alarma está APAGADA",triggered:"¡Se ha disparado la alarma! Causa: {{open_sensors}}.",untriggered:"The alarm is not longer triggered.",arm_failure:"No se pudo armar la alarma en este momento debido a: {{open_sensors}}.",arming:"Se armará pronto la alarma, por favor, salga de la casa.",pending:"¡La alarma está a punto de dispararse, desarme rápidamente!"}},open_sensors_format:{heading:"Formato para el comodín open_sensors",description:"Elija qué información del sensor se inserta en el mensaje",options:{default:"Nombres y estados",short:"Solo nombres"}},arm_mode_format:{heading:"Traducción del comodín arm_mode",description:"Elija en qué idioma se inserta el modo de armado en el mensaje"},target:{heading:"Objetivo",description:"Dispositivo al que enviar el mensaje push."},media_player_entity:{heading:"Entidad de reproductor multimedia",description:"Reproductores multimedia para reproducir el mensaje."},name:{heading:"Nombre",description:"Descripción de esta notificación.",placeholders:{armed:"Notificar a {target} al armar",disarmed:"Notificar a {target} al desarmar",triggered:"Notificar a {target} cuando se dispare",untriggered:"Notify {target} when triggering stops",arm_failure:"Notificar a {target} si falla",arming:"Notificar a {target} cuando se vaya",pending:"Notificar a {target} cuando llegue"}},delete:{heading:"Eliminar automatización",description:"Eliminar esta automatización de forma permanente"}},actions:{test:"Pruébelo"}},new_action:{title:"Crear acción",description:"Este panel se puede utilizar para cambiar un dispositivo cuando cambia el estado de la alarma.",fields:{event:{heading:"Evento",description:"¿Cuándo debe ejecutarse la acción?"},area:{heading:"Área",description:"Área para la que se aplica el evento."},mode:{heading:"Modo",description:"Limita la acción a modos de armado específicos (opcional)"},entity:{heading:"Entidad",description:"Entidad sobre la que realizar la acción."},action:{heading:"Acción",description:"Acción a realizar en la entidad.",no_common_actions:"Las acciones solo se pueden asignar en modo YAML para las entidades seleccionadas."},name:{heading:"Nombre",description:"Descripción de esta acción.",placeholders:{armed:"Establecer {entity} en {state} al armar",disarmed:"Establecer {entity} en {state} al desarmar",triggered:"Establecer {entity} en {state} cuando se dispare",untriggered:"Set {entity} to {state} when triggering stops",arm_failure:"Establecer {entity} en {state} si falla",arming:"Establecer {entity} en {state} cuando se vaya",pending:"Establecer {entity} en {state} cuando llegue"}}}}}}},fa={common:ua,components:va,title:_a,panels:ba},ya=Object.freeze({__proto__:null,common:ua,components:va,default:fa,panels:ba,title:_a}),ka={modes_short:{armed_away:"Eemal",armed_home:"Kodus",armed_night:"Ööseks",armed_custom_bypass:"Valikuline",armed_vacation:"Vacation"},enabled:"Lubatud",disabled:"Keelatud"},wa={time_picker:{seconds:"sekundid",minutes:"minutid"},editor:{ui_mode:"Kasutajaliides",yaml_mode:"Koodiredaktor",edit_in_yaml:"Edit in YAML"},table:{filter:{label:"Filter items",item:"Filter by {name}",hidden_items:"{number} {number, plural,\n one {item is}\n other {items are}\n} hidden"}}},za="Alarm panel",Aa={general:{title:"Üldsätted",cards:{general:{description:"Need seaded kehtivad kõikides valve olekutes.",fields:{disarm_after_trigger:{heading:"Häire summutamine",description:"Peale häire lõppu võta valvest maha miite ära valvesta uuesti."},ignore_blocking_sensors_after_trigger:{heading:"Ignoreeri blokeerivaid andureid uuesti valve alla panemisel",description:"Naaske valvestatud olekusse ilma kontrollimata andureid, mis võivad endiselt aktiivsed olla."},enable_mqtt:{heading:"Luba MQTT juhtimine",description:"Luba nupustiku juhtimist MQTT abil."},enable_master:{heading:"Luba põhivalvestus",description:"Loob olemi mis haldab kõiki valvestamise alasid korraga."}},actions:{setup_mqtt:"MQTT seadistamine",setup_master:"Põhivalvestuse sätted"}},modes:{title:"Režiimid",description:"Selles vaates seadistatakse valvestamise režiime.",modes:{armed_away:"Täielik valvestamine kui kedagi pole kodus. Kasutusel on kõik andurid.",armed_home:"Valvestatud kodus ei kasuta liikumisandureid kuid väisuksed ja aknad on valve all.",armed_night:"Valvestatud ööseks ei kasuta määratud liikumisandureid, välisperimeeter on valve all.",armed_vacation:"Armed vacation can be used as an extension to the armed away mode in case of absence for longer duration. The delay times and trigger responses can be adapted (as desired) to being distant from home.",armed_custom_bypass:"Valikulise valvestuse puhul saab määrata kasutatavad andurid."},number_sensors_active:"{number} {number, plural,\n one {andur}\n other {andurit}\n} aktiiv",fields:{status:{heading:"Status",description:"Controls whether the alarm can be armed in this mode."},exit_delay:{heading:"Ooteaeg valvestamisel",description:"Viivitus enne valvestamise rakendumist."},entry_delay:{heading:"Sisenemise viivitus",description:"Viivitus sisenemisel enne häire rakendumist."},trigger_time:{heading:"Häire kestus",description:"Sireeni jne. aktiveerimise kestus."}}},mqtt:{title:"MQTT sätted",description:"MQTT parameetrite seadistamine.",fields:{state_topic:{heading:"Oleku teema (topic)",description:"Teema milles avaldatakse oleku muutused."},event_topic:{heading:"Event topic",description:"Topic on which alarm events are published"},command_topic:{heading:"Käskude teema (topic)",description:"Teema milles avaldatakse valvestamise käsud."},require_code:{heading:"Nõua PIN koodi",description:"Käskude edastamiseks on vajalik PIN kood."},state_payload:{heading:"Määra olekute toimeandmed",item:"Määra oleku ''{state}'' toimeandmed"},command_payload:{heading:"Määra käskude toimeandmed",item:"Määra käsu ''{command}'' toimeandmed"}}},areas:{title:"Alad",description:"Alasid kasutatakse elamise jagamiseks valvetsoonideks.",no_items:"Valvestamise alad on loomata.",table:{remarks:"Ala teave",summary:"See ala sisaldab {summary_sensors} ja {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {andur}\n other {andurit}\n}",summary_automations:"{number} {number, plural,\n one {automatiseering}\n other {automatiseeringut}\n}"},actions:{add:"Lisa"}}},dialogs:{create_area:{title:"Uus ala",fields:{copy_from:"Kopeeri sätted allikast:"}},edit_area:{title:"Ala ''{area}'' muutmine",name_warning:"NB! Nime muutmisel muutub ka olemi ID"},remove_area:{title:"Kas kustutada ala?",description:"Kas kustutada see ala? Ala kaasab andurid {sensors} ja automatiseeringud {automations} mis samuti eemaldatakse."},edit_master:{title:"Põhiala seaded"},disable_master:{title:"Kas keelata põhiala?",description:"Kas keelata põhiala? Ala kaasab andurid {sensors} ja automatiseeringud {automations} mis samuti eemaldatakse.."}}},sensors:{title:"Andurid",cards:{sensors:{description:"Kasutusel olevad andurid. Klõpsa olemil, et seadistada.",table:{no_items:"Andureid pole lisatud. Alustuseks lisa mõni andur.",no_area_warning:"Sensor is not assigned to any area.",arm_modes:"Valvestamise olek",always_on:"(alati)"}},add_sensors:{title:"Andurite lisamine",description:"Lisa veel andureid. Mõistlik on panna neile arusaadav nimi (friendly_name).",no_items:"Puuduvad valvestamiseks sobivad Home Assistanti olemid. Lisatavad olemid peavad olema olekuandurid (binary_sensor).",table:{type:"Detected type"},actions:{add_to_alarm:"Lisa valvesüsteemile",filter_supported:"Hide items with unknown type"}},editor:{title:"Andurite sätted",description:"Muuda olemi ''{entity}'' sätteid.",fields:{entity:{heading:"Olem",description:"Selle anduriga seotud olem"},area:{heading:"Ala",description:"Vali ala kus see andur asub."},group:{heading:"Group",description:"Group with other sensors for combined triggering."},device_type:{heading:"Seadme tüüp",description:"Vali anduri tüüp, et automaatselt rakendada sobivad sätted.",choose:{door:{name:"Uks",description:"Uks, värav või muu piire mida kasutatakse sisenemiseks või väljumiseks."},window:{name:"Aken",description:"Aken või uks mida ei kasutata sisenemiseks nagu rõduuks."},motion:{name:"Liikumisandur",description:"Kohaloleku andurid mille rakendumiste vahel on viide."},tamper:{name:"Terviklikkus",description:"Anduri muukimine või klaasipurustusandur jms."},environmental:{name:"Ohu andurid",description:"Suitsu või gaasilekke andur, veeleke jne. (ei ole seotud sissetungimisega)."},other:{name:"Tavaandur"}}},always_on:{heading:"Alati kasutusel",description:"Andur käivitab häire igas valve olekus."},modes:{heading:"Valve olekute valik",description:"Valve olekud kus seda andurit kasutatakse."},arm_on_close:{heading:"Valvesta sulgemisel",description:"Selle anduri rakendumisel valvestatakse kohe ilma viiveta."},use_exit_delay:{heading:"Use exit delay",description:"Sensor is allowed to be active when the exit delay starts."},use_entry_delay:{heading:"Use entry delay",description:"Sensor activation triggers the alarm after the entry delay rather than directly."},entry_delay:{heading:"Entry delay",description:"Override the entry delay (as configured for the arm mode) with a delay specific to the sensor."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Lahkumisviivitus",description:"See andur ei aktiveeru enne lahkumisviivituse lõppu."},auto_bypass:{heading:"Bypass automatically",description:"Exclude this sensor from the alarm if it is open while arming.",modes:"Modes in which sensor may be bypassed"},trigger_unavailable:{heading:"Andurite saadavus",description:"Käivita häire kui andur muutub kättesaamatuks."}},actions:{toggle_advanced:"Täpsemad sätted",remove:"Eemalda",setup_groups:"Setup groups"},errors:{description:"Palun paranda jägmised vead:",no_area:"Ala pole määratud",no_modes:"Anduri tüüp on määramata, ei tea kuida kasutada",no_auto_bypass_modes:"No modes are selected for the sensor may be automatically bypassed"}}},dialogs:{manage_groups:{title:"Manage sensor groups",description:"In a sensor group multiple sensors must be activated within a time period before the alarm is triggered.",no_items:"No groups yet",actions:{new_group:"New group"}},create_group:{title:"New sensor group",fields:{name:{heading:"Name",description:"Name for sensor group"},timeout:{heading:"Time-out",description:"Time period during which consecutive sensor activations triggers the alarm."},event_count:{heading:"Kogus",description:"Erinevate andurite arv, mis tuleb häire käivitamiseks aktiveerida."},sensors:{heading:"Sensors",description:"Select the sensors which are contained by this group."}},errors:{invalid_name:"Invalid name provided.",insufficient_sensors:"At least 2 sensors need to be selected."}},edit_group:{title:"Edit sensor group ''{name}''"}}},codes:{title:"Koodid",cards:{codes:{description:"Valvestuskoodide muutmine.",fields:{code_arm_required:{heading:"Valvestamine koodiga",description:"Valvestamiseks tuleb sisestada kood"},code_disarm_required:{heading:"Valvest vabastamise kood",description:"Valvest vabastamiseks tulem sisestada kood"},code_mode_change_required:{heading:"Nõua režiimi vahetamiseks koodi",description:"Aktiivse valverežiimi muutmiseks tuleb esitada kehtiv kood."},code_format:{heading:"Koodi vorming",description:"Kasutajaliidese koodi tüübid.",code_format_number:"PIN kood",code_format_text:"Salasõna"}}},user_management:{title:"Kasutajate haldus",description:"Igal kasutajal on oma juhtkood.",no_items:"Kasutajaid pole määratud",actions:{new_user:"Uus kasutaja"}},new_user:{title:"Lisa uus kasutaja",description:"Valvesüsteemi kasutaja lisamine.",fields:{name:{heading:"Nimi",description:"Kasutaja nimi."},code:{heading:"Valvestuskood",description:"Selle kasutaja kood."},confirm_code:{heading:"Koodi kinnitamine",description:"Sisesta sama kood uuesti."},can_arm:{heading:"Tohib valvestada",description:"Koodi sisestamine valvestab."},can_disarm:{heading:"Tohib valvest maha võtta",description:"Koodi sisestamine võtab valvest maha."},is_override_code:{heading:"Alistuskood",description:"Koodi sisestamine käivitab kohese häire"},area_limit:{heading:"Restricted areas",description:"Limit user to control only the selected areas"}},errors:{no_name:"Nimi puudub.",no_code:"Kood peab olema vhemalt 4 tärki.",code_mismatch:"Sisestatud koodid ei klapi."}},edit_user:{title:"Muuda kasutaja sätteid",description:"Muuda kasutaja ''{name}'' sätteid.",fields:{old_code:{heading:"Kehtiv kood",description:"Kehtiv kood, jäta tühjaks kui ei taha muuta."}}}}},actions:{title:"Toimingud",cards:{notifications:{title:"Teavitused",description:"Halda saadetavaid teavitusi",table:{no_items:"Teavitusi pole veel loodud.",no_area_warning:"Action is not assigned to any area."},actions:{new_notification:"Uus teavitus"}},actions:{description:"Arenduses, mõeldud seadmete lülitamiseks.",table:{no_items:"Toiminguid pole veel määratud."},actions:{new_action:"Uus toiming"}},new_notification:{title:"Loo teavitus",description:"Uue teavituse loomine.",trigger:"Condition",action:"Task",options:"Options",fields:{event:{heading:"Sündmus",description:"Mille puhul teavitada",choose:{armed:{name:"Valvestatud",description:"Valvestamine oli edukas"},disarmed:{name:"Valvest maas",description:"Valve mahavõtmine õnnestus"},triggered:{name:"Häire",description:"Valvesüsteem andis häire"},untriggered:{name:"Alarm not longer triggered",description:"The triggered state of the alarm has ended"},arm_failure:{name:"Valvestamine nurjus",description:"Valvestamine ei õnnestunud mõne anduri oleku või vea tõttu"},arming:{name:"Valvestamise eelne viivitus algas",description:"Algas valvestamise eelviide, majast võib lahkuda."},pending:{name:"Sisenemise viide rakendus",description:"Märgati sisenemist, häire rakendub peale viidet."}}},mode:{heading:"Olek",description:"Millises valve olekus teavitada (valikuline)"},title:{heading:"Päis",description:"Teavitussõnumi päis"},message:{heading:"Sisu",description:"Teavitussõnumi tekst",insert_wildcard:"Insert wildcard",placeholders:{armed:"The alarm is set to {{arm_mode}}",disarmed:"The alarm is now OFF",triggered:"The alarm is triggered! Cause: {{open_sensors}}.",untriggered:"The alarm is not longer triggered.",arm_failure:"The alarm could not be armed right now, due to: {{open_sensors}}.",arming:"The alarm will be armed soon, please leave the house.",pending:"The alarm is about to trigger, disarm it quickly!"}},open_sensors_format:{heading:"Format for open_sensors wildcard",description:"Choose which sensor information in inserted in the message",options:{default:"Names and states",short:"Names only"}},arm_mode_format:{heading:"Translation for arm_mode wildcard",description:"Choose in which language the arm mode is inserted in the message"},target:{heading:"Saaja",description:"Seade millele edastada teavitus"},media_player_entity:{heading:"Meediamängija olem",description:"Meediamängijad sõnumi esitamiseks."},name:{heading:"Nimi",description:"Teavituse kirjeldus",placeholders:{armed:"Notify {target} upon arming",disarmed:"Notify {target} upon disarming",triggered:"Notify {target} when triggered",untriggered:"Notify {target} when triggering stops",arm_failure:"Notify {target} on failure",arming:"Notify {target} when leaving",pending:"Notify {target} when arriving"}},delete:{heading:"Delete automation",description:"Permanently remove this automation"}},actions:{test:"Try it"}},new_action:{title:"Loo toiming",description:"Seadme oleku muutmine valve oleku muutmisel.",fields:{event:{heading:"Sündmus",description:"Millisel juhul käivitada toiming"},area:{heading:"Ala",description:"Ala millele sündmus rakendub."},mode:{heading:"Olek",description:"Millises valve olekus toiming käivitada (valikuline)"},entity:{heading:"Olem",description:"Toimingu olem"},action:{heading:"Toiming",description:"Olemi toiming",no_common_actions:"Actions can only be assigned in YAML mode for the selected entities."},name:{heading:"Nimi",description:"Toimingu kirjeldus",placeholders:{armed:"Set {entity} to {state} upon arming",disarmed:"Set {entity} to {state} upon disarming",triggered:"Set {entity} to {state} when triggered",untriggered:"Set {entity} to {state} when triggering stops",arm_failure:"Set {entity} to {state} on failure",arming:"Set {entity} to {state} when leaving",pending:"Set {entity} to {state} when arriving"}}}}}}},ja={common:ka,components:wa,title:za,panels:Aa},$a=Object.freeze({__proto__:null,common:ka,components:wa,default:ja,panels:Aa,title:za}),Ta={modes_short:{armed_away:"Absence",armed_home:"Présence",armed_night:"Nuit",armed_custom_bypass:"Personnalisé",armed_vacation:"Vacances"},enabled:"Actif",disabled:"Inactif"},Ea={time_picker:{seconds:"secondes",minutes:"minutes"},editor:{ui_mode:"Afficher l'éditeur visuel",yaml_mode:"Afficher l'éditeur de code",edit_in_yaml:"Editer en YAML"},table:{filter:{label:"Filtrer par items",item:"Filtrer par {name}",hidden_items:"{number} {number, plural,\n one { item est caché}\n other { items sont cachés}\n} "}}},Sa="Configuration de l'alarme",Ca={general:{title:"Généraux",cards:{general:{description:"Ce panneau définit les paramètres globaux de l'alarme.",fields:{disarm_after_trigger:{heading:"Désactivation après déclenchement",description:"Lorsque le temps de fonctionnement de la sirène est écoulé, désactive l'alarme au lieu de la réactiver."},ignore_blocking_sensors_after_trigger:{heading:"Ignorer les capteurs de blocage lors du réarmement",description:"Revenez à l'état armé sans vérifier les capteurs qui peuvent encore être actifs."},enable_mqtt:{heading:"Utilisation avec MQTT",description:"Permet au panneau d'alarme d'être contrôlé via MQTT."},enable_master:{heading:"Activation de commande centralisée",description:"Créer une entité pour piloter toutes les zones en même temps."}},actions:{setup_mqtt:"Configuration MQTT",setup_master:"Configuration pricipale"}},modes:{title:"Modes",description:"Ce panneau définit le mode de gestion pour chaque type d'activation.",modes:{armed_away:"Ce mode sera utilisé lorsque toutes les personnes auront quitté la maison. Toutes les portes et fenêtres permettant l'accès à la maison seront surveillées, les détecteurs de mouvement à l'intérieur de la maison seront opérationnels.",armed_home:"Ce mode sera utilisée lorsque des personnes sont dans la maison. Toutes les portes et fenêtres permettant l'accès à la maison seront surveillées (périmétrie), les détecteurs de mouvement à l'intérieur de la maison seront inopérants.",armed_night:"Ce mode sera utilisé lors du réglage de l'alarme avant de s'endormir. Toutes les portes et fenêtres permettant l'accès à la maison seront surveillées, et les capteurs de mouvement sélectionnés (ex : rez de chaussée) dans la maison seront opérationnels.",armed_vacation:"Ce mode peut être utilisé comme une extension du mode armé absent en cas d'absence pour une durée plus longue. Les temps de retard et les réponses de déclenchement peuvent être adaptés (au choix) à l'éloignement du domicile.",armed_custom_bypass:"Ce mode supplémentaire permet de définir votre propre périmètre de sécurité."},number_sensors_active:"{number} {number, plural,\n one {capteur actif}\n other {capteurs actifs}\n} ",fields:{status:{heading:"Statut",description:"Active l'alarme dans ce mode."},exit_delay:{heading:"Délai pour sortir",description:"Lors de l'activation, pendant cette période, les capteurs ne déclencheront pas l'alarme."},entry_delay:{heading:"Délai pour entrer",description:"Temps d'attente avant que l'alarme ne se déclenche après détection d'un des capteurs."},trigger_time:{heading:"Temps de fonctionnement avant réarmement",description:"Temps pendant lequel l'alarme restera dans l'état déclenché après intrusion."}}},mqtt:{title:"Configuration MQTT",description:"Ce panneau peut être utilisé pour la configuration de l'interface MQTT.",fields:{state_topic:{heading:"Etat des données",description:"Topic sur lequel les mises à jour d'état sont publiées."},event_topic:{heading:"Evènement de données",description:"Topic sur lequel les évènements d'état sont publiés."},command_topic:{heading:"Commande de données",description:"Topic sur lequel les commandes d'armement / désarmement sont envoyées."},require_code:{heading:"Code requis",description:"Exige que le code soit envoyé avec la commande."},state_payload:{heading:"Configurer une valeur par état",item:"Définir une valeur pour l'état ''{state}''."},command_payload:{heading:"Configurer une valeur par commande",item:"Définir une valeur pour la commande ''{command}''."}}},areas:{title:"Zones",description:"Les zones peuvent être utilisées pour diviser votre système d'alarme en plusieurs secteurs.",no_items:"Il n'y a pas encore de zone définie.",table:{remarks:"Remarque",summary:"Cette zone contient {summary_sensors} et {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {capteur}\n other {capteurs}\n}",summary_automations:"{number} {number, plural,\n one {automatisation}\n other {automatisations}\n}"},actions:{add:"Ajouter"}}},dialogs:{create_area:{title:"Nouvelle zone",fields:{copy_from:"Copier les paramètres"}},edit_area:{title:"Editer la zone ''{area}''",name_warning:"Note : Changer le nom, changera l'entity ID"},remove_area:{title:"Suppression de zone ?",description:"Êtes-vous sur de vouloir supprimer cette zone ? Cette zone contient {sensors} capteur(s) et {automations} automatisation(s), qui seront également supprimés."},edit_master:{title:"Configuration principale"},disable_master:{title:"Désactiver la configuration principale ?",description:"Êtes-vous sur de vouloir supprimer la configuration principale ? Cette zone contient {automations} automatisation(s), qui seront également supprimées."}}},sensors:{title:"Capteurs",cards:{sensors:{description:"Capteurs actuellement configurés. Cliquez sur une entité pour apporter des modifications.",table:{no_items:"Il n'y a pas encore de capteur ajouté à l'alarme. Assurez-vous de les ajouter d'abord.",no_area_warning:"Le capteur n'est affecté à aucune zone.",arm_modes:"Type d'activation",always_on:"(Toujours)"}},add_sensors:{title:"Ajouter un capteur",description:"Ajoutez plus de capteurs. Assurez-vous que vos capteurs ont un nom personnalisé afin de pouvoir les identifier.",no_items:"Aucune entité HA disponible ne peut être configurée pour l'alarme. Assurez-vous d'inclure les entités de type binary_sensor.",table:{type:"Type de détection"},actions:{add_to_alarm:"Ajouter à l'alarme",filter_supported:"Masquer les éléments de type inconnu"}},editor:{title:"Editer un capteur",description:"Configurer les paramètres du capteur ''{entity}''.",fields:{entity:{heading:"Entité",description:"Entité associée à ce capteur"},area:{heading:"Zone",description:"Sélectionner une zone contenant ce capteur."},group:{heading:"Groupe",description:"Grouper avec d'autres capteurs pour un déclenchement combiné."},device_type:{heading:"Type de détection",description:"Choisissez un type de détection pour appliquer automatiquement les paramètres appropriés.",choose:{door:{name:"Porte",description:"Une porte, un portail ou une autre entrée utilisée pour entrer / sortir de la maison."},window:{name:"Fenêtre",description:"Une fenêtre, ou une porte non utilisée pour entrer dans la maison comme un balcon."},motion:{name:"Mouvement",description:"Capteur de présence ou appareil similaire présentant un délai entre les activations."},tamper:{name:"Effraction",description:"Détection d'arrachage du capteur, capteur de bris de verre, etc."},environmental:{name:"Détecteur Environmental",description:"Détecteur de fumée / gaz, détecteur de fuite, etc. (non lié à la protection anti-effraction)."},other:{name:"Générique"}}},always_on:{heading:"Toujours en service",description:"Le capteur doit toujours déclencher l'alarme."},modes:{heading:"Mode possible",description:"Modes d'alarme dans lesquels ce capteur est actif."},arm_on_close:{heading:"Activer après fermeture",description:"Après la désactivation de ce capteur, le délai de sortie restant sera automatiquement ignoré."},use_exit_delay:{heading:"Utiliser le délai de sortie",description:"Le capteur sera actif à la fin du délai de sortie."},use_entry_delay:{heading:"Utiliser le délai d'entrée",description:"L'activation du capteur déclenche l'alarme après le délai d'entrée plutôt qu'instantanément."},entry_delay:{heading:"Délai pour entrer",description:"Remplacez le délai (tel que défini pour le mode de sécurité) par une valeur spécifique."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Autoriser l'ouverture lors de l'activation",description:"Permet à ce capteur d'être actif, peu de temps après votre départ afin qu'il ne bloque pas l'armement."},auto_bypass:{heading:"Bypass automatique",description:"Exclut ce capteur de l'alarme s'il est ouvert lors de l'armement.",modes:"Modes dans lesquels le capteur peut être ignoré"},trigger_unavailable:{heading:"Déclenchement lorsqu'il n'est pas disponible",description:"Lorsque l'état du capteur devient `` indisponible '', cela activera l'alarme."}},actions:{toggle_advanced:"Paramètres avancées",remove:"Supprimer",setup_groups:"Configuration de Groupe"},errors:{description:"Veuillez corriger les erreurs suivantes :",no_area:"Aucune zone n'est sélectionnée",no_modes:"Aucun mode sélectionné pour lequel le capteur doit être actif",no_auto_bypass_modes:"Aucun mode n'est sélectionné car le capteur peut être automatiquement ignoré"}}},dialogs:{manage_groups:{title:"Gérer les groupes de capteurs",description:"Dans un groupe de capteurs, plusieurs capteurs doivent être activés dans un laps de temps avant que l'alarme ne se déclenche.",no_items:"Aucun groupe",actions:{new_group:"Nouveau groupe"}},create_group:{title:"Nouveau groupe de capteurs",fields:{name:{heading:"Nom",description:"Nom du nouveau groupe de capteurs"},timeout:{heading:"Laps de temps",description:"Période de temps pendant laquelle les activations consécutives du capteur déclenchent l'alarme."},event_count:{heading:"nombre d'événements",description:"Nombre de capteurs différents qui doivent être activés pour déclencher l'alarme."},sensors:{heading:"Capteurs",description:"Sélectionnez les capteurs qui sont contenus dans ce groupe."}},errors:{invalid_name:"Nom fourni non valide.",insufficient_sensors:"Au moins 2 capteurs doivent être sélectionnés."}},edit_group:{title:"Editer le groupe de capteurs ''{name}''"}}},codes:{title:"Codes",cards:{codes:{description:"Gestion des paramètres des codes.",fields:{code_arm_required:{heading:"Utiliser un code pour l'activation",description:"Code requis pour l'activation de l'alarme"},code_disarm_required:{heading:"Utiliser un code pour la désactivation",description:"Code requis pour la désactivation de l'alarme"},code_mode_change_required:{heading:"Exiger un code pour changer de mode",description:"Un code valide doit être fourni pour changer le mode d'armement en cours."},code_format:{heading:"Format du code",description:"Définit le type d'entrée pour la carte d'alarme Lovelace.",code_format_number:"Pincode",code_format_text:"Password"}}},user_management:{title:"Gestion des utilisateurs",description:"Chaque utilisateur a son propre code pour activer / désactiver l'alarme.",no_items:"Il n'y a aucun utilisateur de défini",actions:{new_user:"Nouvel utilisateur"}},new_user:{title:"Créer un nouvel utilisateur",description:"Des utilisateurs peuvent être créés pour donner accès au fonctionnement de l'alarme.",fields:{name:{heading:"Nom",description:"Nom de l'utilisateur."},code:{heading:"Code",description:"Code personnel de l'utilisateur."},confirm_code:{heading:"Confirmation du code",description:"Répèter le code."},can_arm:{heading:"Demande de code pour l'activation",description:"Entrer ce code pour activer l'alarme."},can_disarm:{heading:"Demande de code pour désactivation",description:"Entrer ce code pour désactiver l'alarme."},is_override_code:{heading:"Code de sécurité",description:"La saisie de ce code forcera l'activation l'alarme."},area_limit:{heading:"Zones Restreintes",description:"L'utilisateur ne peut contrôler uniquement les zones sélectionnées."}},errors:{no_name:"Aucun nom saisi.",no_code:"Le code doit contenir 4 caractères/chiffres minimum.",code_mismatch:"Les codes sont différents."}},edit_user:{title:"Editer l'utilisateur",description:"Changer la configuration pour l'utilisateur ''{name}''.",fields:{old_code:{heading:"Code utilisé",description:"Code actuel, laissez vide pour ne rien changer."}}}}},actions:{title:"Actions",cards:{notifications:{title:"Notifications",description:"À l'aide de ce panneau, vous pouvez gérer les notifications à envoyer lors d'un évènement d'alarme.",table:{no_items:"Il n'y a aucune notification de créée.",no_area_warning:"L'action n'est affectée à aucune zone."},actions:{new_notification:"Nouvelle notification"}},actions:{description:"Ce panneau est utilisé pour changer d'état les appareils de votre choix.",table:{no_items:"Il n'y a aucune action de créée."},actions:{new_action:"Nouvelle action"}},new_notification:{title:"Créer une notification",description:"Créer une nouvelle notification.",trigger:"Condition",action:"Action",options:"Options",fields:{event:{heading:"Évènement",description:"Détermine quand la notification doit être envoyée.",choose:{armed:{name:"Alarme activée",description:"L'alarme s'est correctement activée."},disarmed:{name:"Alarme désactivée",description:"L'alarme est désactivée."},triggered:{name:"Alarme déclenchée",description:"L'alarme est déclenchée."},untriggered:{name:"L'alarme n'est plus déclenchée",description:"Le temps de déclenchement de l'alarme est terminé."},arm_failure:{name:"Armement impossible",description:"L'armement est impossible dû à un ou plusieurs capteurs."},arming:{name:"Délai de sortie activé",description:"Le délai de sortie est activé, vous devez quitter la maison."},pending:{name:"Délai d'entrée activé",description:"Le délai d'entrée est activé, sans action de désarmement, l'alarme va se déclencher."}}},mode:{heading:"Mode",description:"Limite la notification à un mode spécifique (optionnel)"},title:{heading:"Titre",description:"Titre du message de la notification"},message:{heading:"Message",description:"Contenu du message de la notification",insert_wildcard:"Inserer la wildcard",placeholders:{armed:"L'alarme est réglée sur {{arm_mode}}",disarmed:"L'alarme est maintenant désactivée",triggered:"L'alarme s'est déclenchée ! Cause : {{open_sensors}}.",untriggered:"L'alarme n'est plus déclenchée.",arm_failure:"L'alarme n'a pas pu être armée pour le moment, à cause de : {{open_sensors}}.",arming:"L'alarme sera bientôt armée, veuillez quitter la maison.",pending:"L'alarme est sur le point de se déclencher, désarmez-la rapidement !"}},open_sensors_format:{heading:"Format pour les 'open_sensors wildcard'",description:"Choisissez les informations du capteur à insérer dans le message",options:{default:"Noms et états",short:"Noms seulement"}},arm_mode_format:{heading:"Traduction pour 'arm_mode wildcard'",description:"Choisissez dans quelle langue le mode d'armement est inséré dans le message"},target:{heading:"Cible",description:"Appareil recevant le message"},media_player_entity:{heading:"Entité du lecteur multimédia",description:"Lecteurs multimédias pour lire le message."},name:{heading:"Nom",description:"Description de la notification",placeholders:{armed:"Notifie {target} à l'armement",disarmed:"Notifie {target} au désarmement",triggered:"Notifie {target} au déclenchement",untriggered:"Notifie {target} quand le temps de déclenchement est terminé",arm_failure:"Notifie {target} en cas d'échec de l'armement",arming:"Notifie {target} lors du départ de la maison",pending:"Notifie {target} lors du retour à la maison"}},delete:{heading:"Supprimer l'automatisme",description:"Supprimer définitivement cet automatisme"}},actions:{test:"Essai"}},new_action:{title:"Créer une action",description:"Ce panneau peut être utilisé pour commuter un appareil lorsque l'état de l'alarme change.",fields:{event:{heading:"Evènement",description:"Détermine quand l'action doit être exécutée."},area:{heading:"Zone",description:"Zone pour laquelle l'évènement s'applique."},mode:{heading:"Mode",description:"Limite l'action à un mode spécifique (optionnel)."},entity:{heading:"Entité",description:"Entité sur laquelle effectuer une action."},action:{heading:"Action",description:"Action à exécuter sur l'entité",no_common_actions:"Les actions ne peuvent être affectées qu'en mode YAML pour les entités sélectionnées."},name:{heading:"Nom",description:"Description de l'action",placeholders:{armed:"Mettre {entity} à {state} lors de l'armement",disarmed:"Mettre {entity} à {state} lors du désarmement",triggered:"Mettre {entity} à {state} lors du déclenchement de l'alarme",untriggered:"Mettre {entity} à {state} quand le temps de déclenchement s'arrête",arm_failure:"Mettre {entity} à {state} en cas d'échec de l'armement",arming:"Mettre {entity} à {state} lors du départ de la maison",pending:"Mettre {entity} à {state} lors du retour à la maison"}}}}}}},Oa={common:Ta,components:Ea,title:Sa,panels:Ca},Ma=Object.freeze({__proto__:null,common:Ta,components:Ea,default:Oa,panels:Ca,title:Sa}),xa={modes_short:{armed_away:"Teljes élesítés",armed_home:"Otthoni élesítés",armed_night:"Éjszakai élesítés",armed_custom_bypass:"Egyedi élesítés",armed_vacation:"Nyaralás mód"},enabled:"Engedélyezve",disabled:"Letiltva"},Na={time_picker:{seconds:"másodperc",minutes:"perc"},editor:{ui_mode:"UI mód",yaml_mode:"YAML mód",edit_in_yaml:"Szerkesztés YAML-ben"},table:{filter:{label:"Elemek szűrése",item:"Szűrés: {name}",hidden_items:"{number} {number, plural,\n one {item is}\n other {items are}\n} rejtve"}}},La="Riasztópanel",Ha={general:{title:"Általános",cards:{general:{description:"Ez a panel a riasztó néhány globális beállítását tartalmazza.",fields:{disarm_after_trigger:{heading:"Hatástalanítás riasztás után",description:"A riasztási idő lejárta után a rendszer hatástalanítódik ahelyett, hogy visszatérne élesített állapotba."},ignore_blocking_sensors_after_trigger:{heading:"Újraélesítéskor figyelmen kívül hagyja a blokkoló érzékelőket",description:"Térjen vissza élesített állapotba anélkül, hogy ellenőrizné az esetleg még aktív érzékelőket."},enable_mqtt:{heading:"MQTT engedélyezése",description:"Engedélyezi a riasztópanel MQTT-n keresztüli vezérlését."},enable_master:{heading:"Főriasztó engedélyezése",description:"Létrehoz egy entitást az összes zóna egyidejű vezérléséhez."}},actions:{setup_mqtt:"MQTT Beállítások",setup_master:"Főriasztó beállítások"}},modes:{title:"Módok",description:"Ez a panel használható a riasztó élesítési módjainak beállítására.",modes:{armed_away:"A 'Teljes élesítés' akkor használatos, amikor mindenki elhagyta az otthont. Minden ajtó és ablak, valamint a belső mozgásérzékelők is aktívak lesznek.",armed_home:"Az 'Otthoni élesítés' (más néven 'maradás mód') akkor használatos, amikor valaki otthon tartózkodik. Az ajtók és ablakok védettek, de a belső mozgásérzékelők nem aktívak.",armed_night:"Az 'Éjszakai élesítés' alvás előtt ajánlott. Védi a külső nyílászárókat, valamint kiválasztott (pl. földszinti) mozgásérzékelőket.",armed_vacation:"A 'Nyaralás mód' a teljes élesítés kiterjesztése hosszabb távollét esetére. A késleltetési idők és a riasztási válaszok ehhez igazíthatók.",armed_custom_bypass:"Egy extra mód, amelyben saját védelmi zónák határozhatók meg (például kivételek, kizárt érzékelők)."},number_sensors_active:"{number} {number, plural,\n one {sensor}\n other {sensors}\n} active",fields:{status:{heading:"Állapot",description:"Szabályozza, hogy a riasztó élesíthető-e ebben a módban."},exit_delay:{heading:"Kilépési késleltetés",description:"A riasztó élesítése után ennyi ideig nem vált ki riasztást az érzékelő, lehetőséget adva az elhagyásra."},entry_delay:{heading:"Belépési késleltetés",description:"Az az idő, ami alatt a rendszer még nem riaszt, miután egy érzékelő aktiválódik (pl. bejárat használatakor)."},trigger_time:{heading:"Riasztási idő",description:"Az az időtartam, ameddig a riasztás aktív marad, miután kiváltódott."}}},mqtt:{title:"MQTT beállítások",description:"Ez a panel az MQTT interfész konfigurálására szolgál.",fields:{state_topic:{heading:"Állapot topic",description:"Az a témakör, amelyre az állapotfrissítések kerülnek publikálásra."},event_topic:{heading:"Esemény topic",description:"Az a témakör, amelyre a riasztási események kerülnek publikálásra."},command_topic:{heading:"Parancs topic",description:"Az a témakör, amelyen keresztül az Alarmo az élesítési/hatástalanítási parancsokat fogadja."},require_code:{heading:"Kód megkövetelése",description:"A parancs elküldésekor kötelező a kód megadása."},state_payload:{heading:"Állapotonkénti payload beállítása",item:"Payload definiálása a(z) ''{state}'' állapothoz"},command_payload:{heading:"Parancsonkénti payload beállítása",item:"Payload definiálása a(z) ''{command}'' parancshoz"}}},areas:{title:"Zónák",description:"A zónák segítségével a riasztórendszer több, külön vezérelhető részre bontható.",no_items:"Még nincs definiálva egyetlen zóna sem.",table:{remarks:"Megjegyzések",summary:"Ez a zóna {summary_sensors} és {summary_automations} elemet tartalmaz.",summary_sensors:"{number} {number, plural,\n one {sensor}\n other {sensors}\n}",summary_automations:"{number} {number, plural,\n one {automation}\n other {automations}\n}"},actions:{add:"Hozzáadás"}}},dialogs:{create_area:{title:"Új zóna",fields:{copy_from:"Beállítások másolása innen"}},edit_area:{title:"Zóna szerkesztése: ''{area}''",name_warning:"Figyelem: a név megváltoztatása az entitás azonosítót is megváltoztatja."},remove_area:{title:"Zóna törlése?",description:"Biztosan törölni szeretnéd ezt a zónát? Ez a zóna {sensors} érzékelőt és {automations} automatizálást tartalmaz, amelyek szintén törölve lesznek."},edit_master:{title:"Főriasztó konfiguráció"},disable_master:{title:"Főriasztó letiltása?",description:"Biztosan le szeretnéd tiltani a főriasztót? Ez a zóna {automations} automatizálást tartalmaz, amelyek szintén törlésre kerülnek ezzel a művelettel."}}},sensors:{title:"Érzékelők",cards:{sensors:{description:"Jelenleg konfigurált érzékelők. Kattints egy elemre a szerkesztéshez.",table:{no_items:"Nincsenek megjeleníthető érzékelők.",no_area_warning:"Az érzékelő nincs hozzárendelve egyetlen zónához sem.",arm_modes:"Élesítési módok",always_on:"(Mindig aktív)"}},add_sensors:{title:"Érzékelők hozzáadása",description:"További érzékelők hozzáadása. Ügyelj arra, hogy az érzékelők neve jól azonosítható legyen.",no_items:"Nincs elérhető Home Assistant entitás, amely konfigurálható lenne a riasztóhoz. Győződj meg róla, hogy `binary_sensor` típusú entitások szerepelnek a rendszerben.",table:{type:"Észlelt típus"},actions:{add_to_alarm:"Hozzáadás a riasztóhoz",filter_supported:"Ismeretlen típusú elemek elrejtése"}},editor:{title:"Érzékelő szerkesztése",description:"A(z) ''{entity}'' érzékelő beállításainak konfigurálása.",fields:{entity:{heading:"Entitás",description:"Az érzékelőhöz tartozó entitás."},area:{heading:"Zóna",description:"Válaszd ki, melyik zónához tartozzon az érzékelő."},group:{heading:"Csoport",description:"Csoportosítsd más érzékelőkkel közös riasztási logikához."},device_type:{heading:"Eszköztípus",description:"Válassz eszköztípust az automatikus beállításokhoz.",choose:{door:{name:"Ajtó",description:"Bejárati ajtó, kapu vagy más nyílászáró, amelyen keresztül ki vagy be lehet jutni."},window:{name:"Ablak",description:"Ablak vagy olyan ajtó, amely nem szolgál fő bejáratként (pl. erkélyajtó)."},motion:{name:"Mozgásérzékelő",description:"Mozgás- vagy jelenlétérzékelő, amely aktiválás között késleltetéssel működik."},tamper:{name:"Szabotázsérzékelő",description:"Fedéllevétel-, üvegtörés- vagy más szabotázst jelző szenzor."},environmental:{name:"Környezeti érzékelő",description:"Füst-, gáz-, vízszivárgás-érzékelő (nem betörésvédelmi célra)."},other:{name:"Egyéb"}}},always_on:{heading:"Mindig aktív",description:"Az érzékelő minden esetben riasztást vált ki, függetlenül az élesítési módtól."},modes:{heading:"Aktív módok",description:"Azok az élesítési módok, amelyekben ez az érzékelő működésben van."},arm_on_close:{heading:"Élesítés zárás után",description:"Az érzékelő inaktiválódása (pl. ajtó becsukása) után a hátralévő kilépési késleltetés automatikusan átugrásra kerül."},use_exit_delay:{heading:"Kilépési késleltetés használata",description:"Az érzékelő akkor is aktív lehet, amikor a kilépési késleltetés elindul."},use_entry_delay:{heading:"Belépési késleltetés használata",description:"Az érzékelő aktiválása nem azonnali riasztást indít, hanem csak a belépési késleltetés lejárta után."},entry_delay:{heading:"Belépési késleltetés",description:"Cserélje le a késleltetést (a biztonsági módhoz beállított érték szerint) egy adott értékre."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Nyitott állapot engedélyezése induláskor",description:"Az érzékelő nyitott állapota élesítéskor figyelmen kívül marad – a későbbi aktiválása viszont riasztást vált ki."},auto_bypass:{heading:"Automatikus kizárás",description:"Ha az érzékelő nyitva van élesítéskor, automatikusan kizárásra kerül a riasztásból.",modes:"Módok, amelyekben az érzékelő kizárható"},trigger_unavailable:{heading:"Riasztás elérhetetlenség esetén",description:"Ha az érzékelő állapota 'nem elérhetővé' válik, az riasztást vált ki."}},actions:{toggle_advanced:"Speciális beállítások",remove:"Eltávolítás",setup_groups:"Csoportok beállítása"},errors:{description:"Kérlek javítsd az alábbi hibákat:",no_area:"Nincs kiválasztva zóna",no_modes:"Nincs beállítva élesítési mód, amelyben az érzékelő aktív lenne",no_auto_bypass_modes:"Nincs megadva, hogy mely módokban lehet az érzékelő automatikusan kizárva"}}},dialogs:{manage_groups:{title:"Érzékelőcsoportok kezelése",description:"Egy érzékelőcsoportban több érzékelő aktiválódásának kell bekövetkeznie egy meghatározott időn belül a riasztás kiváltásához.",no_items:"Még nincs létrehozott csoport.",actions:{new_group:"Új csoport létrehozása"}},create_group:{title:"Új érzékelőcsoport",fields:{name:{heading:"Név",description:"Az érzékelőcsoport megnevezése."},timeout:{heading:"Időkorlát",description:"Az az időintervallum (másodpercben), amelyen belül több érzékelőnek kell aktiválódnia a riasztás kiváltásához."},event_count:{heading:"Aktiválások száma",description:"Hány különböző érzékelő aktiválódása szükséges a riasztás elindításához."},sensors:{heading:"Érzékelők",description:"Válaszd ki azokat az érzékelőket, amelyek ehhez a csoporthoz tartoznak."}},errors:{invalid_name:"Érvénytelen csoportnév.",insufficient_sensors:"Legalább 2 érzékelőt ki kell választani a csoport létrehozásához."}},edit_group:{title:"Érzékelőcsoport szerkesztése: ''{name}''"}}},codes:{title:"Kódok",cards:{codes:{description:"A kódhasználat beállításai.",fields:{code_arm_required:{heading:"Kód megadása kötelező élesítéshez",description:"A riasztó élesítéséhez érvényes kód megadása szükséges."},code_disarm_required:{heading:"Kód megadása kötelező hatástalanításhoz",description:"A riasztó hatástalanításához érvényes kód megadása szükséges."},code_mode_change_required:{heading:"Kód szükséges módváltáshoz",description:"Az aktív élesítési mód módosításához érvényes kód megadása szükséges."},code_format:{heading:"Kód formátuma",description:"A Lovelace riasztókártyán használt bevitel típusa.",code_format_number:"Pin-kód",code_format_text:"Jelszó"}}},user_management:{title:"Felhasználók kezelése",description:"Minden felhasználó saját kóddal rendelkezik a riasztó élesítéséhez és hatástalanításához.",no_items:"Még nincs létrehozott felhasználó",actions:{new_user:"Új felhasználó"}},new_user:{title:"Új felhasználó létrehozása",description:"Hozz létre felhasználókat a riasztórendszer eléréséhez és vezérléséhez.",fields:{name:{heading:"Név",description:"A felhasználó neve."},code:{heading:"Kód",description:"A felhasználóhoz tartozó kód."},confirm_code:{heading:"Kód megerősítése",description:"A kód ismételt megadása a megerősítéshez."},can_arm:{heading:"Élesítés engedélyezése ezzel a kóddal",description:"Entering this code activates the alarm"},can_disarm:{heading:"Hatástalanítás engedélyezése ezzel a kóddal",description:"A kód beírásával a riasztó hatástalanítható"},is_override_code:{heading:"Kényszerített élesítési kód",description:"Ezzel a kóddal akkor is élesíthető a rendszer, ha nyitott érzékelők vannak (kényszerített élesítés)"},area_limit:{heading:"Zónakorlátozás",description:"A felhasználó csak a kiválasztott zónákhoz férhet hozzá."}},errors:{no_name:"Nincs megadva név.",no_code:"A kódnak legalább 4 karakter hosszúnak kell lennie.",code_mismatch:"A megadott kódok nem egyeznek."}},edit_user:{title:"Felhasználó szerkesztése",description:"A(z) ''{name}'' felhasználó beállításainak módosítása.",fields:{old_code:{heading:"Jelenlegi kód",description:"A jelenleg érvényes kód. Hagyd üresen, ha nem szeretnéd módosítani."}}}}},actions:{title:"Műveletek",cards:{notifications:{title:"Értesítések",description:"Ebben a panelben beállíthatod, hogy milyen riasztási eseményeknél küldjön a rendszer értesítést.",table:{no_items:"Még nincs létrehozva értesítés.",no_area_warning:"Ez a művelet nincs zónához rendelve."},actions:{new_notification:"Új értesítés"}},actions:{description:"Ez a panel lehetőséget ad eszközök (pl. lámpák, szirénák) vezérlésére a riasztórendszer állapotának változásakor.",table:{no_items:"Még nincs létrehozott művelet."},actions:{new_action:"Új művelet"}},new_notification:{title:"Értesítés beállítása",description:"Értesítés küldése a riasztó élesítése, hatástalanítása, riasztás aktiválása stb. esetén.",trigger:"Feltétel",action:"Művelet",options:"Lehetőségek",fields:{event:{heading:"Esemény",description:"Mely eseménykor küldjön a rendszer értesítést",choose:{armed:{name:"Riasztó élesítve",description:"A riasztó élesítve lett"},disarmed:{name:"Riasztó hatástalanítva",description:"A riasztó hatástalanítva lett"},triggered:{name:"A riasztó megszólalt",description:"A rendszer behatolást jelez"},untriggered:{name:"Riasztás megszűnt",description:"A riasztási állapot véget ért"},arm_failure:{name:"Élesítés sikertelen",description:"A riasztó nem tudott élesedni, mert egy vagy több érzékelő nyitva maradt"},arming:{name:"Kilépési késleltetés elindult",description:"A kilépési időzítő elindult, ideje elhagyni az otthont."},pending:{name:"Belépési késleltetés elindult",description:"A belépési időzítő elindult, hamarosan bekapcsol a riasztás."}}},mode:{heading:"Mód",description:"Korlátozza a műveletet meghatározott élesítési módokra (opcionális)"},title:{heading:"Cím",description:"Az értesítési üzenet címe"},message:{heading:"Üzenet",description:"Az értesítési üzenet szövege",insert_wildcard:"Változó beillesztése",placeholders:{armed:"A riasztó beállítva: {{arm_mode}} módba.",disarmed:"A riasztó kikapcsolt.",triggered:"Riasztáa! Ok: {{open_sensors}}.",untriggered:"A riasztási állapot megszűnt.",arm_failure:"A riasztó nem tudott élesedni. Ok: {{open_sensors}}.",arming:"A riasztó hamarosan élesítésre kerül, kérjük hagyd el az épületet.",pending:"A riasztás hamarosan aktiválódik, hatástalanítsd gyorsan!"}},open_sensors_format:{heading:"Az open_sensors változó formátuma",description:"Válaszd ki, milyen érzékelő-információ jelenjen meg az üzenetben",options:{default:"Nevek és állapotok",short:"Csak nevek"}},arm_mode_format:{heading:"arm_mode változó nyelve",description:"Válaszd ki, milyen nyelven jelenjen meg az élesítési mód az üzenetben"},target:{heading:"Cél",description:"Az eszköz, amelyre az értesítés érkezik"},media_player_entity:{heading:"Médialejátszó entitás",description:"Az a médialejátszó, amelyen az üzenet lejátszásra kerül"},name:{heading:"Név",description:"Leírás ehhez az értesítéshez",placeholders:{armed:"{target} értesítése élesítéskor",disarmed:"{target} értesítése hatástalanításkor",triggered:"{target} értesítése riasztás esetén",untriggered:"{target} értesítése amikor megszűnik a riasztás",arm_failure:"{target} értesítése élesítési hiba esetén",arming:"{target} értesítése kilépéskor",pending:"{target} értesítése érkezéskor"}},delete:{heading:"Automatizálás törlése",description:"Ez az automatizálás véglegesen törlésre kerül"}},actions:{test:"Próba"}},new_action:{title:"Művelet konfigurálása",description:"Világítás vagy eszköz (pl. sziréna) vezérlése a riasztó élesítése, hatástalanítása, vagy riasztás során.",fields:{event:{heading:"Esemény",description:"Melyik eseménykor hajtódjon végre a művelet"},area:{heading:"Zóna",description:"Az a zóna, amelyre az esemény és a művelet vonatkozik."},mode:{heading:"Mód",description:"Korlátozza a műveletet meghatározott élesítési módokra (opcionális)"},entity:{heading:"Entitás",description:"Az a Home Assistant entitás, amelyen a művelet végrehajtásra kerül"},action:{heading:"Név",description:"Leírás ehhez a művelethez",no_common_actions:"A kiválasztott entitásokhoz csak YAML módban rendelhetők műveletek."},name:{heading:"Név",description:"Leírás ehhez a művelethez",placeholders:{armed:"{entity} beállítása {state} állapotra élesítéskor",disarmed:"{entity} beállítása {state} állapotra hatástalanításkor",triggered:"{entity} beállítása {state} állapotra riasztás esetén",untriggered:"{entity} beállítása {state} állapotra riasztás megszűnésekor",arm_failure:"{entity} beállítása {state} állapotra élesítési hiba esetén",arming:"{entity} beállítása {state} állapotra távozáskor",pending:"{entity} beállítása {state} állapotra érkezéskor"}}}}}}},Da={common:xa,components:Na,title:La,panels:Ha},Pa=Object.freeze({__proto__:null,common:xa,components:Na,default:Da,panels:Ha,title:La}),Ba={modes_short:{armed_away:"Fuori casa",armed_home:"In casa",armed_night:"Notte",armed_custom_bypass:"Personalizzato",armed_vacation:"Vacanza"},enabled:"Abilitato",disabled:"Disabilitato"},qa={time_picker:{seconds:"secondi",minutes:"minuti"},editor:{ui_mode:"Passa a UI",yaml_mode:"Passa a YAML",edit_in_yaml:"Modifica in YAML"},table:{filter:{label:"Filtra elementi",item:"Filtra per {name}",hidden_items:"{number} {number, plural,\n one {item is}\n other {items are}\n} hidden"}}},Va="Pannello Allarme",Ia={general:{title:"Generale",cards:{general:{description:"Questo pannello definisce alcune impostazioni da applicare alle modalità di allarme.",fields:{disarm_after_trigger:{heading:"Disattiva allarme dopo l'attivazione",description:"Dopo che il tempo di attivazione è scaduto, disattivare l'allarme invece di tornare allo stato inserito."},ignore_blocking_sensors_after_trigger:{heading:"Ignora i sensori di blocco durante il riarmo",description:"Ritornare allo stato armato senza controllare i sensori che potrebbero essere ancora attivi."},enable_mqtt:{heading:"Abilita MQTT",description:"Permetti al pannello allarme di essere controllato attraverso MQTT."},enable_master:{heading:"Abilita Allarme Master",description:"Crea una entità per controllare tutte le aree simultaneamente."}},actions:{setup_mqtt:"Configurazione MQTT",setup_master:"Configurazione Master"}},modes:{title:"Modalità",description:"Questo pannello può essere usato per impostare le modalità dell'allarme.",modes:{armed_away:"Modalità 'fuori casa': da utilizzare quando tutte le persone lasciano la casa. Tutti i sensori di porte e finestre che consentono l'accesso alla casa saranno attivi, così come i sensori di movimento all'interno della casa.",armed_home:"Modalità 'in casa': da utilizzare quando si attiva l'allarme mentre le persone sono in casa. Tutti i sensori di porte e finestre che consentono l'accesso alla casa saranno attivi, ma non i sensori di movimento all'interno della casa.",armed_night:"Modalità 'notte': da utilizzare quando si imposta la sveglia prima di andare a dormire. Tutti i sensori di porte e finestre che consentono l'accesso alla casa saranno attivi e sensori di movimento selezionati (ad esempio al piano di sotto) nella casa.",armed_vacation:"Modalità 'vacanza': da utlizzare come estensione della modalità 'fuori casa' in caso di assenza prolungata. I ritardi e i tempi di attivazione possono essere adattati per essere distanti da casa.",armed_custom_bypass:"Modalità 'personalizzato': da utilizzare per definire una modalità di allarme specifica per le esigenze dell'utilizzatore."},number_sensors_active:"{number} {number, plural,\n one {sensor}\n other {sensors}\n} active",fields:{status:{heading:"Stato",description:"Definisce quando l'allarme può essere armato in questa modalità."},exit_delay:{heading:"Tempo di preattivazione",description:"Quando si attiva l'allarme, entro questo periodo di tempo i sensori non attiveranno ancora l'allarme."},entry_delay:{heading:"Ritardo di attivazione",description:"Tempo di ritardo fino allo scatto dell'allarme dopo l'attivazione di uno dei sensori."},trigger_time:{heading:"Tempo di attivazione",description:"Tempo durante il quale suonerà la sirena."}}},mqtt:{title:"Configurazione MQTT",description:"Questo pannello può essere usato per le impostazioni MQTT.",fields:{state_topic:{heading:"Topic di stato",description:"Topic su cui vengono pubblicati gli aggiornamenti di stato"},event_topic:{heading:"Event topic",description:"opic su cui vengono pubblicati gli eventi"},command_topic:{heading:"Topic di comando",description:"Topic su cui vengono inviati i comandi di inserimento / disinserimento."},require_code:{heading:"Richiedi Codice",description:"Richiedi il codice da inviare con il comando."},state_payload:{heading:"Configura payload per stato",item:"Definisci un payload per lo stato ''{state}''"},command_payload:{heading:"Configura payload per comando",item:"Definisci un payload per il comando ''{command}''"}}},areas:{title:"Aree",description:"Le aree possono essere utilizzate per dividere il tuo allarme in più sezioni.",no_items:"Non ci sono ancora aree definite.",table:{remarks:"Commenti",summary:"Questa area contiene {summary_sensors} e {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {sensor}\n other {sensors}\n}",summary_automations:"{number} {number, plural,\n one {automation}\n other {automations}\n}"},actions:{add:"Aggiungi"}}},dialogs:{create_area:{title:"Nuova area",fields:{copy_from:"Copia impostazioni da"}},edit_area:{title:"Modifica Area ''{area}''",name_warning:"Nota: cambiare il nome modificherà l'entity ID"},remove_area:{title:"Rimuovi Area?",description:"Sei sicuro che vuoi rimuovere questa area? Questa area contiene {sensors} sensori e {automations} automazioni, che verranno anch'esse rimossi."},edit_master:{title:"Configura Master"},disable_master:{title:"Disabilita Master?",description:"Sei sicuro che vuoi rimuovere l'allarme master? Questa area contiene {automations} automazioni, che verranno eliminate con questa azione."}}},sensors:{title:"Sensori",cards:{sensors:{description:"Sensori attualmente configurati. Clicca sull'entità per modificare.",table:{no_items:"Non ci sono ancora sensori aggiunti a questo allarme. Assicurati di aggiungerli prima.",no_area_warning:"Sensore non assegnato a nessuna area.",arm_modes:"Modalità di attivazione",always_on:"(Sempre)"}},add_sensors:{title:"Aggiungi Sensori",description:"Aggiungi più sensori. Assicurati che i sensori abbiano un friendly_name (nome amichevole), in modo da identificarli più facilmente.",no_items:"Non ci sono entità disponibili che possono essere configurate con l'allarme. Assicurati di includere entità del tipo binary_sensor (sensore binario).",table:{type:"Tipologia Innesco"},actions:{add_to_alarm:"Aggiungi all'allarme",filter_supported:"Nascondi elementi con tipologia sconosciuta"}},editor:{title:"Modifica Sensore",description:"Configura le impostazioni del sensore ''{entity}''.",fields:{entity:{heading:"Entità",description:"Entità associata a questo sensore"},area:{heading:"Area",description:"Seleziona una area che contiene questo sensore."},group:{heading:"Gruppo",description:"Raggruppa con altri sensori per inneschi combinati."},device_type:{heading:"Tipologia Dispositivo",description:"Scegli la tipologia del dispositivo per applicare le impostazioni appropriate.",choose:{door:{name:"Porta",description:"Una porta, cancello o altro ingresso che è usato per entrare/lasciare casa."},window:{name:"Finestra",description:"Una finestra, o una porta-finestra non usata per accedere alla casa."},motion:{name:"Movimento",description:"Sensore di presenza o simile che ha un ritardo tra le attivazioni."},tamper:{name:"Vibrazione",description:"Rilaveamento di vibrazione, rottura vetri, ecc."},environmental:{name:"Ambientale",description:"Rilevatori fumo/gas, ecc. (non correlati alla protezione intrusi)."},other:{name:"Generico"}}},always_on:{heading:"Sempre attivo",description:"Il sensore attiverà sempre l'allarme."},modes:{heading:"Modalità attive",description:"Modalità di allarme in cui il sensore risulta collegato."},arm_on_close:{heading:"Attiva dopo chisura",description:"Dopo la disattivazione di questo sensore il ritardo rimanente verrà automaticamente ignorato."},use_exit_delay:{heading:"Usa ritardo d'uscita",description:"Sensore che può rimanre attivo mentre il ritardo di uscita è in corso."},use_entry_delay:{heading:"Usa ritardo in ingresso",description:"Sensore che innesca l'allarme dopo il ritardo in ingresso anzichè direttamente."},entry_delay:{heading:"Ritardo di ingresso",description:"Sostituire il ritardo (impostato per la modalità di sicurezza) con un valore specifico."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Permetti apertura",description:"Consentire a questo sensore di rimanere attivo poco dopo essere usciti."},auto_bypass:{heading:"Esclusione automatica",description:"Escludi questo sensore dall'allarme se è aperto durante l'attivazione.",modes:"Modalità in cui il sensore può essere escluso"},trigger_unavailable:{heading:"Fai scattare l'allarme quando non disponibile",description:"L'allarme scatterà quando lo stato del sensore diverrà 'non disponibile'."}},actions:{toggle_advanced:"Impostazione avanzate",remove:"Rimuovi",setup_groups:"Configurazione gruppi"},errors:{description:"Per favore correggi i seguenti errori:",no_area:"Nessuna area è selezionata",no_modes:"Nessuna modalità è selezionata per la quale il sensore dovrebbe essere attivo",no_auto_bypass_modes:"Nessuna modalità è selezionata per il sensore che può essere automaticamente escluso"}}},dialogs:{manage_groups:{title:"Gestisci gruppi sensori",description:"In un gruppo sensori più sensori devono essere attivi in un intevallo di tempo prima che l'allarme sia innescato.",no_items:"Nessun gruppo",actions:{new_group:"Nuovo gruppo"}},create_group:{title:"Nuovo gruppo sensori",fields:{name:{heading:"Nome",description:"Nome del gruppo sensori"},timeout:{heading:"Time-out",description:"Periodo di tempo durante il quale l'attivazione consecutiva innesca l'allarme."},event_count:{heading:"Numero",description:"Quantità di sensori diversi che devono essere attivati per attivare l'allarme."},sensors:{heading:"Sensori",description:"Seleziona i sensori che fanno parte di questo gruppo."}},errors:{invalid_name:"Nome non valido.",insufficient_sensors:"Almeno 2 sensori devono essere selezionati."}},edit_group:{title:"Modifica gruppo sensori ''{name}''"}}},codes:{title:"Codici",cards:{codes:{description:"Modifica le impostazioni dei codici.",fields:{code_arm_required:{heading:"Usa codice d'attivazione",description:"Richiedi un codice per attivare l'allarme"},code_disarm_required:{heading:"Usa codice di disattivazione",description:"Richiedi un codice per disattivare l'allarme"},code_mode_change_required:{heading:"Richiede il codice per cambiare modalità",description:"È necessario fornire un codice valido per modificare la modalità di inserimento attiva."},code_format:{heading:"Formato del codice",description:"Imposta il tipo di codice da digitare nella card di Lovelace.",code_format_number:"Codice numerico",code_format_text:"Password"}}},user_management:{title:"Gestione utente",description:"Ogni utente ha il suo codice per attivare/disattivare l'allarme.",no_items:"Non è stato ancora creato nessun utente.",actions:{new_user:"Nuovo utente"}},new_user:{title:"Crea nuovo utente",description:"Gli utenti potranno operare con l'allarme.",fields:{name:{heading:"Nome",description:"Nome dell'utente."},code:{heading:"Codice operativo",description:"Codice che utilizzerà quest'utente."},confirm_code:{heading:"Ripeti codice operativo",description:"Ripeti il codice operativo scelto."},can_arm:{heading:"Utilizza codice per attivare l'allarme",description:"Utilizza codice per attivare l'allarme"},can_disarm:{heading:"Utilizza codice per disattivare l'allarme",description:"Utilizza codice per disattivare l'allarme"},is_override_code:{heading:"E' un codice di forzatura",description:"Inserendo questo codice forzerai lo stato di attivazione dell'allarme"},area_limit:{heading:"Aree riservate areas",description:"Limita l'utente a controllare solo le aree selezionate"}},errors:{no_name:"Non hai inserito il nome.",no_code:"Il codice deve avere almeno 4 numeri o caratteri.",code_mismatch:"Il codice scelto non combacia, verifica il codice inserito."}},edit_user:{title:"Modifica Utente",description:"Cambia impostazioni per l'utente ''{name}''.",fields:{old_code:{heading:"Modifica Codice",description:"Codice attuale, lascia vuoto per non modificare."}}}}},actions:{title:"Azioni",cards:{notifications:{title:"Notifiche",description:"Con questo pannello puoi gestire le notifiche da inviare quanto accade un determinato evento",table:{no_items:"Non è stata ancora creata nessuna notifica.",no_area_warning:"Azione non assegnata a nessuna."},actions:{new_notification:"Nuova notifica"}},actions:{description:"Questo pannello può essere usato per cambiare lo stato di una o più entità.",table:{no_items:"Non è stata ancora creata nessuna azione."},actions:{new_action:"Nuova azione"}},new_notification:{title:"Crea notifica",description:"Crea una nuova notifica.",trigger:"Condizione",action:"Azione",options:"Opzioni",fields:{event:{heading:"Evento",description:"Quando questa notifica deve essere inviata",choose:{armed:{name:"Allarme attivato",description:"L'allarme è attivo"},disarmed:{name:"Allarme disattivato",description:"L'allarme è disattivato"},triggered:{name:"Allarme innescato",description:"L'allarme è innescato"},untriggered:{name:"Allarme non innescato",description:"L'allarme non è più innescato"},arm_failure:{name:"Impossibile attivare",description:"L'attivazione dell'allarme non è riuscita a casa di uno o più sensori aperti"},arming:{name:"Ritardo d'uscita partito",description:"Ritardo d'uscita partito, preparati a lasciare la casa."},pending:{name:"Ritardo in ingresso partito",description:"Ritardo in ingresso partito, l'allarme verrà innescato a breve."}}},mode:{heading:"Modalità",description:"Limita ad una specifica modalità di allarme (opzionale)"},title:{heading:"Titolo",description:"Titolo per il messaggio di notifica"},message:{heading:"Messaggio",description:"Contenuto del messaggio di notifica",insert_wildcard:"Inserisci wildcard",placeholders:{armed:"L'allarme è impostato in {{arm_mode}}",disarmed:"L'allarme è disattivatoF",triggered:"L'allarme è stato innescato! Causa: {{open_sensors}}.",untriggered:"The alarm is not longer triggered.",arm_failure:"L'allarme non può essere attivato adesso. Causa: {{open_sensors}}.",arming:"L'allarme verrà attivato a breve, per favore lascia la casa.",pending:"L'allarme sta per essere innescato, disattivalo velocemente!"}},open_sensors_format:{heading:"Formato per la wildcard open_sensors",description:"Scegli quale informazione è inserita nel messaggio",options:{default:"Nomi e stati",short:"Nomi soltanto"}},arm_mode_format:{heading:"Traduzione per le wildcard per arm_mode",description:"Scegli la lingua in cui è scritto il messaggio"},target:{heading:"Destinatario",description:"Dispositivo a cui inviare il messaggio di notifica"},media_player_entity:{heading:"Entità lettore multimediale",description:"Lettori multimediali per riprodurre il messaggio."},name:{heading:"Nome",description:"Descrizione della notifica",placeholders:{armed:"Notifica {target} in attivazione",disarmed:"Notifica {target} in disattivazione",triggered:"Notifica {target} quando innescato",untriggered:"Notifica {target} quando l'innesco termina",arm_failure:"Notifica {target} quando impossibile attivare",arming:"Notifica {target} in uscita",pending:"Notifica {target} in ingresso"}},delete:{heading:"Elimina automazione",description:"Elimina l'automazione permanentemente"}},actions:{test:"Prova"}},new_action:{title:"Crea azione",description:"Questo pannello può essere usato per cambiare lo stato di un entità quando lo stato dell'allarme cambia.",fields:{event:{heading:"Evento",description:"Quando questa azione deve essere eseguita"},area:{heading:"Area",description:"Area nella quale l'evento avviene."},mode:{heading:"Modalità",description:"Limita ad una specifica modalità di allarme (opzionale)"},entity:{heading:"Entità",description:"Entità su cui eseguire l'azione"},action:{heading:"Azione",description:"Azione che deve eseguire l'entità",no_common_actions:"Le azioni possono essere definite solo in YAML mode per le entità selezionate."},name:{heading:"Nome",description:"Descrizione dell'azione",placeholders:{armed:"Imposta {entity} su {state} in attivazione",disarmed:"Imposta {entity} su {state} in disattivazione",triggered:"Imposta {entity} su {state} in innesco",untriggered:"Imposta {entity} su {state} quando l'innesco termina",arm_failure:"Imposta {entity} su {state} quando è impossibile attivare",arming:"Imposta {entity} su {state} in uscita",pending:"Imposta {entity} su {state} in entrata"}}}}}}},Ra={common:Ba,components:qa,title:Va,panels:Ia},Ua=Object.freeze({__proto__:null,common:Ba,components:qa,default:Ra,panels:Ia,title:Va}),Ga={modes_short:{armed_away:"Afwezig",armed_home:"Thuis",armed_night:"Nacht",armed_custom_bypass:"Aangepast",armed_vacation:"Vakantie"},enabled:"Actief",disabled:"Inactief"},Ka={time_picker:{seconds:"seconden",minutes:"minuten"},editor:{ui_mode:"Naar UI",yaml_mode:"Naar YAML",edit_in_yaml:"In YAML bewerken"},table:{filter:{label:"Items filteren",item:"Filter op {name}",hidden_items:"{number} {number, plural,\n one {item is}\n other {items zijn}\n} verborgen"}}},Fa="Alarmpaneel",Za={general:{title:"Algemeen",cards:{general:{description:"Dit paneel definieert enkele instellingen die van toepassing zijn op alle inschakelmodi.",fields:{disarm_after_trigger:{heading:"Uitschakelen na activatie",description:"Nadat de triggertijd is verstreken, schakelt u het alarm uit in plaats van terug te keren naar de ingeschakelde toestand."},ignore_blocking_sensors_after_trigger:{heading:"Negeer blokkerende sensoren bij het opnieuw inschakelen",description:"Alarm wordt teruggezet naar ingeschakelde status zonder te controleren of er nog sensoren actief zijn."},enable_mqtt:{heading:"MQTT inschakelen",description:"Toestaan het alarmpaneel via MQTT aan te sturen."},enable_master:{heading:"Master alarm inschakelen",description:"Creëert een entiteit om alle gebieden tegelijkertijd te besturen."}},actions:{setup_mqtt:"MQTT Configuratie",setup_master:"Master configuratie"}},modes:{title:"Beveiligingsmodi",description:"Dit paneel kan worden gebruikt om de beveiligingsmodi van het alarm in te stellen.",modes:{armed_away:"De afwezigheidsmodus wordt gebruikt als alle mensen het huis hebben verlaten. Alle deuren en ramen die toegang geven tot het huis worden bewaakt, evenals bewegingssensoren in het huis.",armed_home:"De thuismodus wordt gebruikt bij het instellen van het alarm terwijl er mensen in huis zijn. Alle deuren en ramen die toegang geven tot het huis worden bewaakt, maar bewegingssensoren in het huis worden niet gebruikt.",armed_night:"De nachtmodus wordt gebruikt bij het instellen van het alarm voordat u gaat slapen. Alle deuren en ramen die toegang geven tot het huis worden bewaakt, en geselecteerde bewegingssensoren (beneden) in het huis.",armed_vacation:"De vakantiemodus dient voor afwezigheid voor langere duur. Er kunnen desgewenst andere vertragingstijden en acties worden ingesteld die beter passen bij de situatie.",armed_custom_bypass:"Een extra modus om uw eigen beveiligingsperimeter te definiëren."},number_sensors_active:"{number} {number, plural,\n one {sensor}\n other {sensoren}\n} ingesteld",fields:{status:{heading:"Status",description:"Stel in of het alarm op deze modus kan worden ingesteld."},exit_delay:{heading:"Vertrek vertraging",description:"Bij het inschakelen van het alarm zullen de sensoren binnen deze tijdsperiode het alarm nog niet activeren."},entry_delay:{heading:"Binnenkomst vertraging",description:"Vertragingstijd totdat het alarm afgaat nadat een van de sensoren is geactiveerd."},trigger_time:{heading:"Activatie tijd",description:"Tijd waarin het alarm in de geactiveerde toestand blijft na activatie."}}},mqtt:{title:"MQTT configuratie",description:"Dit paneel kan worden gebruikt voor configuratie van de MQTT-interface.",fields:{state_topic:{heading:"Toestand topic",description:"Topic waarop statusupdates worden gepubliceerd"},event_topic:{heading:"Gebeurtenis topic",description:"Topic waarop gebeurtenissen worden gepubliceerd"},command_topic:{heading:"Commando topic",description:"Topic waarop commando's voor in- / uitschakelen worden verzonden."},require_code:{heading:"Vereis code",description:"Vereis dat de code wordt verzonden met de opdracht."},state_payload:{heading:"Configureer de payload per toestand",item:"Definieer een payload voor toestand ''{state}''"},command_payload:{heading:"Configureer een payload per commando",item:"Definieer een payload voor commando ''{command}''"}}},areas:{title:"Gebieden",description:"Gebieden kunnen worden gebruikt om uw alarmsysteem in meerdere compartimenten op te delen.",no_items:"Er zijn nog geen gebieden gedefinieerd.",table:{remarks:"Opmerkingen",summary:"Dit gebied bevat {summary_sensors} en {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {sensor}\n other {sensoren}\n}",summary_automations:"{number} {number, plural,\n one {automatisering}\n other {automatiseringen}\n}"},actions:{add:"Toevoegen"}}},dialogs:{create_area:{title:"Nieuw gebied",fields:{copy_from:"Kopieer instellingen van"}},edit_area:{title:"Bewerken van gebied ''{area}''",name_warning:"Opmerking: als u de naam wijzigt, wordt de entiteits-ID gewijzigd"},remove_area:{title:"Gebied verwijderen?",description:"Weet u zeker dat u dit gebied wilt verwijderen? Dit gebied bevat {sensors} sensoren en {automations} automatiseringen, die ook zullen worden verwijderd."},edit_master:{title:"Master configuratie"},disable_master:{title:"Master uitschakelen?",description:"Weet u zeker dat u het master alarm wilt verwijderen? Dit gebied bevat {automations} automatiseringen, die met deze actie worden verwijderd."}}},sensors:{title:"Sensoren",cards:{sensors:{description:"Momenteel geconfigureerde sensoren. Klik op een entiteit om wijzigingen aan te brengen.",table:{no_items:"Er zijn nog geen sensoren aan het alarm toegevoegd. Zorg ervoor dat u ze eerst toevoegt.",no_area_warning:"Sensor is niet aan een gebied toegewezen.",arm_modes:"Inschakelmodi",always_on:"(Altijd)"}},add_sensors:{title:"Voeg sensoren toe",description:"Voeg meer sensoren toe. Zorg ervoor dat uw sensoren een duidelijke naam hebben, zodat u ze kunt identificeren.",no_items:"Er zijn geen beschikbare HA-entiteiten die voor het alarm kunnen worden geconfigureerd. Zorg ervoor dat u entiteiten van het type binary_sensor opneemt.",table:{type:"Gedetecteerd type"},actions:{add_to_alarm:"Voeg aan alarm toe",filter_supported:"Verberg items met onbekend type"}},editor:{title:"Wijzig Sensor",description:"Configureren van de sensorinstellingen van ''{entity}''.",fields:{entity:{heading:"Entiteit",description:"Entiteit die verwant is aan deze sensor"},area:{heading:"Gebied",description:"Selecteer een gebied dat deze sensor bevat."},group:{heading:"Groep",description:"Groepeer met andere sensors voor gecombineerde triggers."},device_type:{heading:"Apparaat Type",description:"Kies een apparaattype om automatisch de juiste instellingen toe te passen.",choose:{door:{name:"Deur",description:"Een deur, poort of andere ingang die wordt gebruikt voor het betreden/verlaten van de woning."},window:{name:"Raam",description:"Een raam of een deur die niet wordt gebruikt om het huis binnen te komen, zoals een balkon."},motion:{name:"Beweging",description:"Aanwezigheidssensor of soortgelijk apparaat met een vertraging tussen activeringen."},tamper:{name:"Sabotage",description:"Detector van verwijdering van sensorkap, glasbreuksensor, enz."},environmental:{name:"Klimaat",description:"Rook/gassensor, lekkage detector, etc. (niet gerelateerd aan inbraakbeveiliging)."},other:{name:"Algemeen"}}},always_on:{heading:"Altijd aan",description:"Een sensor moet altijd het alarm activeren."},modes:{heading:"Ingeschakelde modi",description:"Alarmmodi waarin deze sensor actief is."},arm_on_close:{heading:"Inschakelen na sluiten",description:"Na deactivering van deze sensor wordt de resterende vertrek vertraging automatisch overgeslagen."},use_exit_delay:{heading:"Gebruik vertrek vertraging",description:"De sensor mag actief zijn wanneer de vertrekperiode wordt gestart."},use_entry_delay:{heading:"Gebruik binnenkomst vertraging",description:"Als de sensor actief wordt, activeert deze het alarm pas na de vertragingstijd voor binnenkomst."},entry_delay:{heading:"Binnenkomst vertraging",description:"Vervang de vertraging (zoals ingesteld voor de beveiligingsmodus) door een specifieke waarde."},delay_on:{heading:"Filtertijd",description:"Minimale tijdsduur dat de sensor actief moet zijn om het alarm te activeren."},allow_open:{heading:"Actieve toestand toestaan bij inschakelen",description:"Initiële toestand bij inschakelen van het alarm wordt genegeerd."},auto_bypass:{heading:"Automatisch omzeilen",description:"Elimineer de sensor als deze actief is tijdens het inschakelen van het alarm.",modes:"Modi waarin de sensor automatisch omzeild mag worden"},trigger_unavailable:{heading:"Activeren indien niet beschikbaar",description:"Wanneer de sensorstatus 'niet beschikbaar' wordt, wordt de sensor geactiveerd."}},actions:{toggle_advanced:"Geavanceerde instellingen",remove:"Verwijder",setup_groups:"Configureer groepen"},errors:{description:"Corrigeer de volgende fouten:",no_area:"Er is geen gebied geselecteerd",no_modes:"Er zijn geen modi geselecteerd waarvoor de sensor actief zou moeten zijn",no_auto_bypass_modes:"Er zijn geen modi geselecteerd waarin de sensor automatisch omzeild mag worden"}}},dialogs:{manage_groups:{title:"Beheer sensorgroepen",description:"In een sensorgroep moeten twee of meer sensoren worden geactiveerd binnen een tijdsperiode voordat het alarm wordt geactiveerd.",no_items:"Nog geen groepen ingesteld.",actions:{new_group:"Nieuwe groep"}},create_group:{title:"Nieuwe sensorgroep",fields:{name:{heading:"Naam",description:"Naam voor sensorgroep."},timeout:{heading:"Time-out",description:"Tijdsperiode waarin meerdere sensoren moeten worden geactiveerd om het alarm te activeren."},event_count:{heading:"Aantal",description:"Aantal verschillende sensoren dat moet worden geactiveerd om het alarm te activeren."},sensors:{heading:"Sensoren",description:"Selecteer de sensoren die deel moeten uitmaken van deze groep."}},errors:{invalid_name:"Verkeerde naam opgegeven.",insufficient_sensors:"Tenminste 2 sensoren moeten worden geselecteerd."}},edit_group:{title:"Bewerk sensorgroep ''{name}''"}}},codes:{title:"Codes",cards:{codes:{description:"Wijzig de instellingen voor de code.",fields:{code_arm_required:{heading:"Vereis code voor inschakelen",description:"Een correcte code moet worden ingevoerd om het alarm te kunnen inschakelen."},code_disarm_required:{heading:"Vereis code voor uitschakelen",description:"Een correcte code moet worden ingevoerd om het alarm te kunnen uitschakelen."},code_mode_change_required:{heading:"Vereis code voor mode omschakeling",description:"Een correcte code moet worden ingevoerd om de actieve beveiligingsmodus te veranderen."},code_format:{heading:"Code opmaak",description:"Stelt het invoertype in voor de Lovelace alarmkaart.",code_format_number:"Pincode",code_format_text:"Wachtwoord"}}},user_management:{title:"Gebruikersbeheer",description:"Elke gebruiker heeft zijn eigen code om het alarm in/uit te schakelen.",no_items:"Er zijn nog geen gebruikers",actions:{new_user:"Nieuwe gebruiker"}},new_user:{title:"Maak een nieuwe gebruiker aan",description:"Gebruikers kunnen worden aangemaakt om toegang te verlenen tot het bedienen van het alarm.",fields:{name:{heading:"Naam",description:"Naam van de gebruiker."},code:{heading:"Code",description:"Code voor deze gebruiker."},confirm_code:{heading:"Bevestig de code",description:"Herhaal de code."},can_arm:{heading:"Code toestaan voor inschakeling",description:"Door deze code in te voeren, wordt het alarm geactiveerd"},can_disarm:{heading:"Code toestaan voor uitschakelen",description:"Door deze code in te voeren, wordt het alarm gedeactiveerd"},is_override_code:{heading:"Is een forceer code",description:"Als u deze code invoert, wordt het alarm geforceerd geactiveerd"},area_limit:{heading:"Beperk gebieden",description:"Beperk de gebruiker tot controle over alleen de gelesecteerde gebieden"}},errors:{no_name:"Geen naam opgegeven.",no_code:"Code moet minimaal 4 tekens/cijfers bevatten.",code_mismatch:"De codes komen niet overeen."}},edit_user:{title:"Wijzig Gebruiker",description:"Wijzig de configuratie voor gebruiker ''{name}''.",fields:{old_code:{heading:"Huidige code",description:"Huidige code, laat leeg om ongewijzigd te laten."}}}}},actions:{title:"Acties",cards:{notifications:{title:"Notificaties",description:"Met dit paneel kunt u meldingen beheren die moeten worden verzonden tijdens een bepaalde alarmgebeurtenis",table:{no_items:"Er zijn nog geen notificaties aangemaakt.",no_area_warning:"Actie is niet toegewezen aan een gebied."},actions:{new_notification:"Nieuwe notificatie"}},actions:{description:"Dit paneel kan worden gebruikt om een apparaat te schakelen wanneer de status van het alarm veranderd.",table:{no_items:"Er zijn nog geen acties gemaakt."},actions:{new_action:"Nieuwe actie"}},new_notification:{title:"Notificatie instellen",description:"Ontvang een notificatie wanneer het alarm wordt in- of uitgeschakeld, wordt geactiveerd etc.",trigger:"Conditie",action:"Taak",options:"Opties",fields:{event:{heading:"Gebeurtenis",description:"Wanneer moet de notificatie worden verzonden",choose:{armed:{name:"Alarm is ingeschakeld",description:"Het alarm is succesvol ingeschakeld"},disarmed:{name:"Alarm is uitgeschakeld",description:"Het alarm is uitgeschakeld"},triggered:{name:"Alarm is afgegaan",description:"Het alarm gaat af"},untriggered:{name:"Gestopt na afgaan",description:"Het alarm gaat niet meer af"},arm_failure:{name:"Kan niet inschakelen",description:"Het inschakelen van het alarm is mislukt vanwege een of meerdere blokkerende sensoren"},arming:{name:"Vertrek",description:"Vertrekvertraging ingegaan, tijd om het huis te verlaten."},pending:{name:"Binnenkomst",description:"Binnenkomstvertraging ingegaan, het alarm dient te worden uitgeschakeld."}}},mode:{heading:"Modi",description:"Beperk de actie tot specifieke inschakel modi."},title:{heading:"Titel",description:"Titel voor de notificatie"},message:{heading:"Bericht",description:"Tekst voor de notificatie",insert_wildcard:"Wildcard invoegen",placeholders:{armed:"Het alarm is ingeschakeld op {{arm_mode}}",disarmed:"Het alarm is nu uit",triggered:"Het alarm is geactiveerd! Oorzaak: {{open_sensors}}.",untriggered:"The alarm gaat niet langer af.",arm_failure:"Het alarm kon niet worden ingeschakeld. Oorzaak: {{open_sensors}}.",arming:"Het alarm wordt ingeschakeld, verlaat het huis.",pending:"Het alarm moet nu worden uitgeschakeld, anders wordt deze geactiveerd."}},open_sensors_format:{heading:"Opmaak voor open_sensors wildcard",description:"Kies welke sensor informatie wordt weergegeven in het bericht",options:{default:"Naam en status",short:"Alleen naam"}},arm_mode_format:{heading:"Vertaling voor arm_mode wildcard",description:"Kies in welke taal de inschakelmodus wordt weergegeven in het bericht"},target:{heading:"Doel",description:"Apparaat om het push-bericht naar te sturen"},media_player_entity:{heading:"Mediaspeler entiteit",description:"Mediaspeler om het bericht af te spelen."},name:{heading:"Naam",description:"Beschrijving voor deze notificatie",placeholders:{armed:"Stuur notificatie naar {target} bij inschakelen",disarmed:"Stuur notificatie naar {target} bij uitschakelen",triggered:"Stuur notificatie naar {target} bij alarm",untriggered:"Stuur notificatie naar {target} als het alarm stopt met afgaan",arm_failure:"Stuur notificatie naar {target} bij fout",arming:"Stuur notificatie naar {target} bij vertrek",pending:"Stuur notificatie naar {target} bij binnenkomst"}},delete:{heading:"Automatisering verwijderen",description:"Verwijder deze automatisering permanent"}},actions:{test:"Testen"}},new_action:{title:"Actie instellen",description:"Schakel verlichting of apparaatuur (bijv. sirene) wanneer het alarm wordt in- of uitgeschakeld of wordt geactiveerd etc.",fields:{event:{heading:"Gebeurtenis",description:"Wanneer moet de actie worden uitgevoerd"},area:{heading:"Gebied",description:"Het gebied waarop de gebeurtenis van toepassing is."},mode:{heading:"Mode",description:"Beperk de actie tot specifieke inschakel modi (optioneel)"},entity:{heading:"Entiteit",description:"Entiteit om actie op uit te voeren"},action:{heading:"Actie",description:"Actie die op de entiteit moet worden uitgevoerd",no_common_actions:"Acties kunnen alleen worden toegewezen in de YAML modus voor de geselecteerde entiteiten."},name:{heading:"Naam",description:"Beschrijving voor deze actie",placeholders:{armed:"Schakel {entity} naar {state} bij inschakelen",disarmed:"Schakel {entity} naar {state} bij uitschakelen",triggered:"Schakel {entity} naar {state} bij alarm",untriggered:"Set {entity} to {state} when triggering stops",arm_failure:"Schakel {entity} naar {state} bij fout",arming:"Schakel {entity} naar {state} bij vertrek",pending:"Schakel {entity} naar {state} bij binnenkomst"}}}}}}},Qa={common:Ga,components:Ka,title:Fa,panels:Za},Ya=Object.freeze({__proto__:null,common:Ga,components:Ka,default:Qa,panels:Za,title:Fa}),Wa={modes_short:{armed_away:"Preč",armed_home:"Doma",armed_night:"Noc",armed_custom_bypass:"Vlastné",armed_vacation:"Dovolenka"},enabled:"Aktivovaný",disabled:"Deaktivovaný"},Xa={time_picker:{seconds:"sekundy",minutes:"minúty"},editor:{ui_mode:"Do UI",yaml_mode:"Do YAML",edit_in_yaml:"Upraviť v YAML"},table:{filter:{label:"Filtrovať položky",item:"Filter podľa {name}",hidden_items:"{number} {number, plural,\n jeden {item is}\n other {items are}\n} skriť"}}},Ja="Alarový panel",et={general:{title:"Hlavný",cards:{general:{description:"Tento panel definuje niektoré globálne nastavenia pre alarm.",fields:{disarm_after_trigger:{heading:"Deaktivujte po spustení",description:"Po uplynutí času spustenia alarm namiesto návratu do stráženého stavu deaktivujte."},ignore_blocking_sensors_after_trigger:{heading:"Ignorovať blokujúce senzory počas opätovného zapínania stráženia",description:"Návrat do zapnutého stavu bez kontroly senzorov, ktoré môžu byť stále aktívne."},enable_mqtt:{heading:"Povoliť MQTT",description:"Umožnite, aby bol panel alarmu ovládaný cez MQTT."},enable_master:{heading:"Povoliť hlavný alarm",description:"Vytvorí entitu na kontrolu všetkých oblastí súčasne."}},actions:{setup_mqtt:"MQTT Konfigurácia",setup_master:"Hlavná konfigurácia"}},modes:{title:"Režimy",description:"Tento panel možno použiť na nastavenie režimov stráženia alarmu.",modes:{armed_away:"Aktivovaný preč sa použije, keď všetci ľudia opustia dom. Všetky dvere a okná umožňujúce vstup do domu budú strážené, ako aj pohybové senzory vo vnútri domu.",armed_home:"Aktivovaný doma (známy aj ako zabezpečený pobyt) sa použije pri nastavovaní alarmu, keď sú ľudia v dome. Strážené budú všetky dvere a okná umožňujúce vstup do domu, nie však pohybové senzory vo vnútri domu.",armed_night:"Aktivovaný noc sa použije pri nastavovaní alarmu pred spaním. Všetky dvere a okná umožňujúce vstup do domu budú strážené a vybrané pohybové senzory (na prízemí) v dome.",armed_vacation:"Aktivovaný dovolenku možno použiť ako rozšírenie režimu stráženia v prípade dlhšej neprítomnosti. Časy oneskorenia a odozvy spúšťača je možné prispôsobiť podľa potreby.",armed_custom_bypass:"Extra režim na definovanie vlastného bezpečnostného obvodu."},number_sensors_active:"{number} {number, plural,\n jeden {sensor}\n other {sensors}\n} aktívny",fields:{status:{heading:"Stav",description:"Ovláda, či je možné v tomto režime zapnúť alarm."},exit_delay:{heading:"Oneskorenie odchodu",description:"Pri aktivácii alarmu v tomto časovom období senzory ešte nespustia alarm."},entry_delay:{heading:"Oneskorenie pri vstupe",description:"Čas oneskorenia, kým sa spustí alarm po aktivácii jedného zo senzorov."},trigger_time:{heading:"Spúšťací čas",description:"Čas, počas ktorého zostane alarm po aktivácii v spustenom stave."}}},mqtt:{title:"MQTT konfigurácia",description:"Tento panel je možné použiť na konfiguráciu rozhrania MQTT.",fields:{state_topic:{heading:"Stav topic",description:"Topic o ktorom zverejňuje aktualizácia stavu"},event_topic:{heading:"Udalosť topic",description:"Topicna na ktorý sa zverejňujú poplachové udalosti"},command_topic:{heading:"Príkazový topic",description:"Topic na ktorý Alarmo počúva príkazy na zapnutie/vypnutie."},require_code:{heading:"Vyžadovať kód",description:"Vyžadovať kódu ktorý sa má odoslať s príkazom."},state_payload:{heading:"Konfiguračný payload pre stav",item:"Definuje payload pre stav ''{state}''"},command_payload:{heading:"Konfiguračný payload pre príkaz",item:"Definuje payload pre príkaz ''{command}''"}}},areas:{title:"Oblasti",description:"Oblasti môžu byť použité na rozdelenie vášho poplašného systému do viacerých oddelení.",no_items:"Zatiaľ nie sú definované žiadne oblasti.",table:{remarks:"Poznámky",summary:"Táto oblasť obsahuje {summary_sensors} a {summary_automations}.",summary_sensors:"{number} {number, plural,\n jeden {sensor}\n other {sensors}\n}",summary_automations:"{number} {number, plural,\n jeden {automation}\n other {automations}\n}"},actions:{add:"Pridať"}}},dialogs:{create_area:{title:"Nová oblasť",fields:{copy_from:"Kopírovať nastavenia z"}},edit_area:{title:"Úprava oblasti ''{area}''",name_warning:"Poznámka: Zmena názvu zmení ID entity"},remove_area:{title:"Odstrániť oblasť?",description:"Naozaj chcete odstrániť túto oblasť? Táto oblasť obsahuje {sensors} senzory a {automations} automatizácie, ktoré budú tiež odstránené."},edit_master:{title:"Hlavná konfigurácia"},disable_master:{title:"Zakázať hlavnú?",description:"Naozaj chcete odstrániť hlavný alarm? Táto oblasť obsahuje {automations} automatizácie, ktoré budú touto akciou odstránené."}}},sensors:{title:"Senzory",cards:{sensors:{description:"Aktuálne nakonfigurované senzory. Kliknutím na položku vykonáte zmeny.",table:{no_items:"Nie sú tu žiadne senzory na zobrazenie.",no_area_warning:"Senzor nie je priradený k žiadnej oblasti.",arm_modes:"Režim alarmu",always_on:"(Vždy zapnutý)"}},add_sensors:{title:"pridať senzor",description:"Pridajte ďalšie senzory. Uistite sa, že vaše senzory majú vhodný názov, aby ste ich mohli identifikovať.",no_items:"Neexistujú žiadne dostupné entity HA, ktoré je možné nakonfigurovať pre alarm. Nezabudnite zahrnúť entity typu binárny_senzor.",table:{type:"Zistený typ"},actions:{add_to_alarm:"Pridať k alarmu",filter_supported:"Skryť položky s neznámym typom"}},editor:{title:"Upraviť senzor",description:"Konfigurácia nastavení senzorov ''{entity}''.",fields:{entity:{heading:"Entita",description:"Entita spojená s týmto senzorom"},area:{heading:"Oblasť",description:"Vyberte oblasť, ktorá obsahuje tento senzor."},group:{heading:"Skupina",description:"Zoskupenie s ďalšími snímačmi pre kombinované spúšťanie."},device_type:{heading:"Typ zariadenia",description:"Vyberte typ zariadenia, aby sa automaticky použili príslušné nastavenia.",choose:{door:{name:"Dvere",description:"Dvere, brána alebo iný vchod, ktorý sa používa na vstup/výstup z domu."},window:{name:"Okno",description:"Okno alebo dvere, ktoré sa nepoužívajú na vstup do domu, ako je balkón."},motion:{name:"Senzor pohybu",description:"Snímač prítomnosti alebo podobné zariadenie s oneskorením medzi aktiváciami."},tamper:{name:"Tamper",description:"Detektor odstránenia krytu snímača, snímač rozbitia skla atď."},environmental:{name:"Environmentálne",description:"Snímač dymu/plynu, detektor úniku atď. (nesúvisí s ochranou proti vlámaniu)."},other:{name:"Generic"}}},always_on:{heading:"Vždy zapnutý",description:"Senzor by mal vždy spustiť alarm."},modes:{heading:"Povolené režimy",description:"Alarmové režimy, v ktorých je tento snímač aktívny."},arm_on_close:{heading:"Zabezpečiť po zatvorní",description:"Po deaktivácii tohto senzora sa zostávajúce odchodové oneskorenie automaticky preskočí."},use_exit_delay:{heading:"Použite odchodové oneskoreniey",description:"Snímač môže byť aktívny, keď sa spustí odchodové oneskorenie."},use_entry_delay:{heading:"Použite oneskorenie vstupu",description:"Aktivácia senzora spustí alarm po vstupnom oneskorení, nie priamo."},entry_delay:{heading:"Vstupné oneskorenie",description:"Nahraďte oneskorenie (nastavené pre bezpečnostný režim) konkrétnou hodnotou."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Po aktivácii povoliť otvorené",description:"Ak je senzor aktívny aj po odchodovom oneskorení, nespôsobí to zlyhanie stráženia."},auto_bypass:{heading:"Obísť automaticky",description:"Vylúčte tento senzor z alarmu, ak je otvorený počas zapnutia stráženia.",modes:"Režimy, v ktorých môže byť senzor obídený"},trigger_unavailable:{heading:"Spustiť, keď nie je k dispozícii",description:"Keď sa stav senzora stane „nedostupným“, senzor sa aktivuje."}},actions:{toggle_advanced:"Pokročilé nastavenia",remove:"Odstrániť",setup_groups:"Nastavte skupiny"},errors:{description:"Opravte nasledujúce chyby:",no_area:"Nie je vybratá žiadna oblasť",no_modes:"Nie sú zvolené žiadne režimy, pre ktoré by mal byť snímač aktívny",no_auto_bypass_modes:"Nie sú zvolené žiadne režimy, aby sa senzor mohol automaticky obísť"}}},dialogs:{manage_groups:{title:"Spravujte skupiny senzorov",description:"V skupine senzorov musí byť aktivovaných viacero senzorov v časovom úseku pred spustením alarmu.",no_items:"Zatiaľ žiadne skupiny",actions:{new_group:"Nová skupina"}},create_group:{title:"Nová skupina senzorov",fields:{name:{heading:"Názov",description:"Názov skupiny senzorov"},timeout:{heading:"Čas vypršal",description:"Časové obdobie, počas ktorého po sebe idúce aktivácie senzora spustia alarm."},event_count:{heading:"Číslo",description:"Množstvo rôznych senzorov, ktoré je potrebné aktivovať na spustenie alarmu."},sensors:{heading:"Senzory",description:"Vyberte snímače, ktoré sú obsiahnuté v tejto skupine."}},errors:{invalid_name:"Zadané neplatné meno.",insufficient_sensors:"Je potrebné vybrať aspoň 2 senzory."}},edit_group:{title:"Upravte skupinu senzorov ''{name}''"}}},codes:{title:"Kódy",cards:{codes:{description:"Zmeňte nastavenia kódu.",fields:{code_arm_required:{heading:"Použite kód zabezpečenia",description:"Vyžaduje sa kód na aktiváciu alarmu"},code_disarm_required:{heading:"Použite deaktivačný kód",description:"Vyžaduje sa kód na vypnutie alarmu"},code_mode_change_required:{heading:"Vyžadovať kód pre režim prepínania",description:"Ak chcete zmeniť aktívny režim stráženia, musíte zadať platný kód."},code_format:{heading:"Formát kódu",description:"Nastaví typ vstupu pre kartu alarmu Lovelace.",code_format_number:"PIN",code_format_text:"Heslo"}}},user_management:{title:"Správa užívateľov",description:"Každý užívateľ má svoj vlastný kód na zapnutie/vypnutie alarmu.",no_items:"Zatiaľ nie sú žiadni používatelia",actions:{new_user:"Nový užívateľ"}},new_user:{title:"Vytvoriť nového používateľa",description:"Je možné vytvoriť používateľov na poskytovanie prístupu k ovládaniu alarmu.",fields:{name:{heading:"Meno",description:"Meno používateľa."},code:{heading:"Kód",description:"Kód pre tohto používateľa."},confirm_code:{heading:"Potvrďte kód",description:"Opakujte kód."},can_arm:{heading:"Povoliť kód na zapnutie stráženia",description:"Zadaním tohto kódu sa aktivuje alarm"},can_disarm:{heading:"Povoliť kód na vypnutie stráženia",description:"Zadaním tohto kódu sa alarm deaktivuje"},is_override_code:{heading:"Povinný kód",description:"Zadaním tohto kódu aktivujete alarm"},area_limit:{heading:"Zakázané oblasti",description:"Obmedzte používateľa na ovládanie iba vybraných oblastí"}},errors:{no_name:"Nebolo zadané žiadne meno.",no_code:"Kód by mal mať minimálne 4 znaky/čísla.",code_mismatch:"Kódy sa nezhodujú."}},edit_user:{title:"Upraviť používateľa",description:"Zmena konfigurácie pre používateľa ''{name}''.",fields:{old_code:{heading:"Aktuálny kód",description:"Aktuálny kód, ponechajte pole prázdne, ak chcete ponechať nezmenené."}}}}},actions:{title:"Akcie",cards:{notifications:{title:"Upozornenia",description:"Pomocou tohto panela môžete spravovať upozornenia, ktoré sa majú odoslať, keď nastane určitá poplachová udalosť.",table:{no_items:"Zatiaľ nie sú vytvorené žiadne upozornenia.",no_area_warning:"Akcia nie je priradená žiadnej oblasti."},actions:{new_notification:"Nová notifikácia"}},actions:{description:"Tento panel je možné použiť na prepnutie zariadenia pri zmene stavu alarmu.",table:{no_items:"Zatiaľ nie sú vytvorené žiadne akcie."},actions:{new_action:"Nová akcia"}},new_notification:{title:"Konfigurácia upozornenia",description:"Dostávať upozornenie pri zapnutí/vypnutí alarmu, aktivácii atď.",trigger:"Podmienka",action:"Úloha",options:"Možnosti",fields:{event:{heading:"Udalosť",description:"Kedy treba poslať oznámenie",choose:{armed:{name:"Alarm je aktivovaný",description:"Alarm je úspešne aktivovaný"},disarmed:{name:"Alarm je deaktivovaný",description:"Alarm je deaktivovaný"},triggered:{name:"Alarm je spustený",description:"Alarm sa spustí"},untriggered:{name:"Alarm už nie je spustený",description:"Spustený stav poplachu skončil"},arm_failure:{name:"Nepodarilo sa zapnúť",description:"Zapnutie alarmu zlyhalo kvôli jednému alebo viacerým otvoreným senzorom"},arming:{name:"Oneskorenie odchodu začalo",description:"Spustilo sa oneskorenie odchodu, pripravený opustiť dom."},pending:{name:"Začalo sa oneskorenie vstupu",description:"Vstupné oneskorenie začalo, alarm sa spustí čoskoro."}}},mode:{heading:"Režim",description:"Obmedzte akciu na konkrétne režimy spustenia (voliteľné)"},title:{heading:"Názov",description:"Názov správy s upozornením"},message:{heading:"Správa",description:"Obsah správy s upozornením",insert_wildcard:"Vložte zástupný znak",placeholders:{armed:"Alarm je nastavený na {{arm_mode}}",disarmed:"Alarm je teraz VYPNUTÝ",triggered:"Spustil sa alarm! dôvod: {{open_sensors}}.",untriggered:"Alarm už nie je spustený.",arm_failure:"Alarm teraz nebolo možné aktivovať z nasledujúcich dôvodov: {{open_sensors}}.",arming:"Alarm bude čoskoro aktivovaný, prosím opustite dom.",pending:"Alarm sa spustí, rýchlo ho deaktivujte!"}},open_sensors_format:{heading:"Formát pre zástupný znak open_sensors",description:"Vyberte, ktoré informácie o senzore sa vložia do správy",options:{default:"Meno a stav",short:"Iba mená"}},arm_mode_format:{heading:"Preklad pre zástupný znak režimu alarmu",description:"Vyberte, v akom jazyku sa do správy vloží režim stráženia"},target:{heading:"Cieľ",description:"Zariadenie, do ktorého sa má odoslať upozornenie"},media_player_entity:{heading:"Entita prehrávača médií",description:"Prehrávače médií na prehrávanie správy."},name:{heading:"Názov",description:"Popis tohto upozornenia",placeholders:{armed:"Upozorniť {target} pri aktivácii",disarmed:"Upozorniť {target} pri deaktivácii",triggered:"Upozorniť {target} pri spustení",untriggered:"Upozorniť {target}, keď sa spúšťanie zastaví",arm_failure:"Upozorniť {target} na zlyhanie",arming:"Upozorniť {target} pri odchode",pending:"Upozorniť {target} pri príchode"}},delete:{heading:"Odstrániť automatizáciu",description:"Natrvalo odstráňte túto automatizáciu"}},actions:{test:"Skús to"}},new_action:{title:"Konfigurovať akciu",description:"Zapnite svetlá alebo zariadenia (napríklad sirény) pri zapínaní/vypínaní stráženia, pri aktivácii atď.",fields:{event:{heading:"Udalosť",description:"Kedy sa má akcia vykonať"},area:{heading:"Oblasť",description:"Oblasť, pre ktorú sa udalosť vzťahuje."},mode:{heading:"Režim",description:"Obmedzte akciu na konkrétne režimy stráženia (voliteľné)"},entity:{heading:"Entity",description:"Entita, na ktorej sa má vykonať akcia"},action:{heading:"Akcia",description:"Akcia, ktorá sa má vykonať na entite",no_common_actions:"Akcie môžu byť priradené iba v režime YAML pre vybrané entity."},name:{heading:"Názov",description:"Popis tejto akcie",placeholders:{armed:"Nastavte {entity} na {state} pri aktivácii",disarmed:"Nastavte {entity} na {state} pri deaktivácii",triggered:"Nastavte {entity} na {state} pri spustení",untriggered:"Nastavte {entity} na {state}, keď sa spúšťanie zastaví",arm_failure:"Nastavte {entity} na {state} pri zlyhani",arming:"Nastavte {entity} na {state} pri odchode",pending:"Nastavte {entity} na {state} pri príchode"}}}}}}},at={common:Wa,components:Xa,title:Ja,panels:et},tt=Object.freeze({__proto__:null,common:Wa,components:Xa,default:at,panels:et,title:Ja}),it={modes_short:{armed_away:"Borta",armed_home:"Hemma",armed_night:"Natt",armed_custom_bypass:"Anpassad",armed_vacation:"Semester"},enabled:"Aktiverat",disabled:"Inaktiverat"},nt={time_picker:{seconds:"sekunder",minutes:"minuter"},editor:{ui_mode:"Till UI",yaml_mode:"Till YAML",edit_in_yaml:"Redigera i YAML"},table:{filter:{label:"Filtrera sensorer",item:"Filtrera med {name}",hidden_items:"{number} {number, plural,\n en {item is}\n other {items are}\n} dolda"}}},st="Alarm panel",rt={general:{title:"Generellt",cards:{general:{description:"Denna panel definierar några globala inställningar för larmet.",fields:{disarm_after_trigger:{heading:"Larma av efter utlös",description:"Efter utlös tiden har gått ut, larma av larmet istället för att återgå till larmat läge."},ignore_blocking_sensors_after_trigger:{heading:"Ignorera blockeringssensorer vid återaktivering",description:"Återgå till tillkopplat läge utan att kontrollera om det finns sensorer som fortfarande kan vara aktiva."},enable_mqtt:{heading:"Aktivera MQTT",description:"Tillåt alarm panelen att kontrolleras via MQTT."},enable_master:{heading:"Aktivera alarm master",description:"Skapar en entity för att kontrollera alla områden samtidigt."}},actions:{setup_mqtt:"MQTT konfiguration",setup_master:"Master konfiguration"}},modes:{title:"Lägen",description:"Denna panel kan användas för att konfigurera larmets olika larmlägen.",modes:{armed_away:"Larmat borta används när alla personer lämnat huset. Alla dörrar och fönster som tillåter tillgång till huset kommer att larmas, det samma gäller rörelsesensorer inne i huset.",armed_home:"Larmat hemma används när det finns personer kvar i huset. Alla dörrar och fönster som tillåter tillgång till huset kommer att larmas, dock inga rörelsesensorer inne i huset.",armed_night:"Larmat natt används när du aktiverar larmen innan du lägger dig. Alla dörrar och fönster som tillåter tillgång till huset kommer att larmas, det samma gäller utvalda rörelsesensorer inne i huset.",armed_vacation:"Larmat semester kan användas som en förlängning av läget för larmat borta vid längre frånvaro. Fördröjningstiderna och utlössvaren kan anpassas (efter önskemål) för att vara borta längre tid från hemmet.",armed_custom_bypass:"Ett extra läge för för att definiera sin egen säkerhetsperimeter."},number_sensors_active:"{number} {number, plural,\n en {sensor}\n other {sensorer}\n} aktiv",fields:{status:{heading:"Status",description:"Styr om larmet kan aktiveras i detta läge."},exit_delay:{heading:"Lämna fördröjning",description:"Efter att du har aktiverat larmet kommer dina sensorer inte utlösa ditt larm inom denna tid."},entry_delay:{heading:"Ankomst fördröjning",description:"Fördröjning i tid tills att ditt larm triggas efter att en av dina sensorer har aktiverats."},trigger_time:{heading:"Utlös tid",description:"Tid som ditt larm kommer vara i utlöst läge efter att ett larm har utlösts."}}},mqtt:{title:"MQTT konfiguration",description:"Denna panel kan användas för att anpassa konfigurationen av MQTT.",fields:{state_topic:{heading:"Status topic",description:"Topic på vilket status uppdateringar publiceras till."},event_topic:{heading:"Event topic",description:"Topic på vilket alarm events publiceras till."},command_topic:{heading:"Kommando topic",description:"Topic på vilket Alarmo lyssnar på för larma/larma av kommandon."},require_code:{heading:"Kräv kod",description:"Kräv att koden ska skickas med kommandot."},state_payload:{heading:"Konfigurera payload per state",item:"Definiera en payload för state ''{state}''"},command_payload:{heading:"Konfigurera payload per kommando",item:"Definiera en payload för kommando ''{command}''"}}},areas:{title:"Områden",description:"Områden kan användas för att dela upp ditt larm till flera områden.",no_items:"Det är inga områden definierade än.",table:{remarks:"Anmärkningar",summary:"Detta område innehåller {summary_sensors} och {summary_automations}.",summary_sensors:"{number} {number, plural,\n en {sensor}\n other {sensorer}\n}",summary_automations:"{number} {number, plural,\n en {automation}\n other {automationer}\n}"},actions:{add:"Lägg till"}}},dialogs:{create_area:{title:"Nytt område",fields:{copy_from:"Kopiera inställningarna från"}},edit_area:{title:"Redigera område ''{area}''",name_warning:"OBS: Ändrar du namn kommer entitetens ID att ändras"},remove_area:{title:"Ta bort område?",description:"Är du säker att du vill ta bort detta område? Detta område innehåller {sensors} sensorer och {automations} automationer, som också kommer att tas bort."},edit_master:{title:"Master konfiguration"},disable_master:{title:"Inaktivera master?",description:"Är du säker att du vill ta bort master alarm? Detta område innehåller {automations} automationer, som kommer att tas bort med detta val."}}},sensors:{title:"Sensorer",cards:{sensors:{description:"Nuvarande konfigurerade sensorer. Klicka på ett entity för att göra förändringar.",table:{no_items:"Det finns inga sensorer att visa här.",no_area_warning:"Sensor är inte tilldelat till något område.",arm_modes:"Larmläge",always_on:"(Alltid)"}},add_sensors:{title:"Lägg till sensorer",description:"Lägg till mer sensorer. Säkerställ att dina sensorer har ett friendly_name, så du kan identifiera dem.",no_items:"Det finns inga tillgängliga HA entities som kan konfigureras för larmet. Säkerställ att inkludera entiteter av typen binary_sensor.",table:{type:"Detekteringstyp"},actions:{add_to_alarm:"Addera till larmet",filter_supported:"Dölj sensorer av typen unknown"}},editor:{title:"Justera Sensor",description:"Justera inställningarna för sensor ''{entity}''.",fields:{entity:{heading:"Entitet",description:"Entitet associerad med denna sensor"},area:{heading:"Område",description:"Välj ett område som innehåller denna sensor."},group:{heading:"Grupp",description:"Gruppera med andra sensorer för kombinerad trigger."},device_type:{heading:"Enhetstyp",description:"Välj en enhetstyp att automatiskt applicera rekommenderade inställningar på.",choose:{door:{name:"Dörr",description:"En dörr, grind eller annan entre som används för att gå in/lämna hemmet."},window:{name:"Fönster",description:"Ett fönster eller en dörr som inte används för att gå in/lämna huset, t.ex. en balkongdörr."},motion:{name:"Rörelse",description:"Närvarosensor eller liknande som har fördröjning mellan sina aktiveringar."},tamper:{name:"Manipulering",description:"Detektor av sensorskydd, glaskross sensor etc."},environmental:{name:"Miljö",description:"Rök/gas sensor eller läckage sensor etc. (Inte relaterat till inbrottsskydd)."},other:{name:"Generell"}}},always_on:{heading:"Larma alltid",description:"Sensorn ska alltid utlösa larmet."},modes:{heading:"Aktiverat läge",description:"Larmläge när sensorn ska vara aktiv."},arm_on_close:{heading:"Larma efter stängning",description:"Resterande lämna fördröjning skippas automatiskt när denna sensor inaktiveras."},use_exit_delay:{heading:"Använd lämna fördröjning",description:"Sensorn är tillåten att vara aktiv när lämna fördröjningen startar."},use_entry_delay:{heading:"Använd ankomst fördröjning",description:"Sensor aktivering utlöser larmet efter ankomst fördröjningen istället för direkt."},entry_delay:{heading:"Ankomst fördröjning",description:"Ersätt fördröjningen (som inställd för säkerhetsläget) med ett specifikt värde."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Tillåt öppnad efter larmning.",description:"Om sensorn fortfarande är aktiv efter lämna fördröjningen kommer det inte misslyckas att larma."},auto_bypass:{heading:"Exkludera automatiskt",description:"Exkludera denna sensor från larmet om den är öppen vid pålarmning.",modes:"Lägen där sensor kan bli exkluderad"},trigger_unavailable:{heading:"Trigga vid otillgänglig",description:"Detta kommer aktiveras när sensorns status blir 'unavailable'."}},actions:{toggle_advanced:"Avancerade inställningar",remove:"Ta bort",setup_groups:"Hantera grupper"},errors:{description:"Var vänlig att justera följande fel:",no_area:"Inget område är vald",no_modes:"Inga lägen är valda när sensorn ska vara aktiv",no_auto_bypass_modes:"Inga lägen är valda när sensorn eventuellt automatiskt ska förbikopplas"}}},dialogs:{manage_groups:{title:"Hantera sensor grupper",description:"I en sensor grupp måste flera sensorer bli aktiverade inom en tidsperiod för att larmet ska triggas.",no_items:"Inga grupper ännu",actions:{new_group:"Ny grupp"}},create_group:{title:"Ny sensor grupp",fields:{name:{heading:"Namn",description:"Namn för sensor gruppen"},timeout:{heading:"Time-out",description:"Tidsperiod för de sammankopplade sensorernas aktivitet ska utlösa larmet."},event_count:{heading:"Siffra",description:"Mängd olika sensorer som behöver aktiveras för att utlösa larmet."},sensors:{heading:"Sensorer",description:"Välj sensorer som tillhöra gruppen."}},errors:{invalid_name:"Ogiltigt namn specificerat.",insufficient_sensors:"Minst två sensorer behöver väljas."}},edit_group:{title:"Justera sensor grupp ''{name}''"}}},codes:{title:"Koder",cards:{codes:{description:"Ändra inställningar för kod.",fields:{code_arm_required:{heading:"Använd pålarmningskod",description:"Kräv en kod för att aktivera larmet"},code_disarm_required:{heading:"Använd avlarmningskod",description:"Kräv en kod för att inaktivera larmet"},code_mode_change_required:{heading:"Kräv kod för att byta läge",description:"En giltig kod måste tillhandahållas för att ändra aktiveringsläget."},code_format:{heading:"Kodformat",description:"Ändra inmatningstyp för Lovelace alarm kortet.",code_format_number:"Pinkod",code_format_text:"Lösenord"}}},user_management:{title:"Användarhantering",description:"Varje användare har sin egen kod för aktivera/inaktivera larmet.",no_items:"Det finns inga användare än",actions:{new_user:"Ny användare"}},new_user:{title:"Skapa en ny användare",description:"Användare kan skapas för att ge tillgång att styra larmet.",fields:{name:{heading:"Namn",description:"Namn på användaren"},code:{heading:"Kod",description:"Koden för användaren."},confirm_code:{heading:"Repetera koden",description:"Repetera koden."},can_arm:{heading:"Tillåt kod för pålarmning",description:"Denna kod aktiverar larmet"},can_disarm:{heading:"Tillåt kod för avlarmning",description:"Denna kod inaktiverar larmet"},is_override_code:{heading:"Tvingande kod",description:"Denna kod tvingar aktivering av larmet"},area_limit:{heading:"Begränsade områden",description:"Begränsa användare att hantera utvalda områden"}},errors:{no_name:"Inget namn angivet.",no_code:"Koden ska vara minst 4 tecken eller siffror.",code_mismatch:"Koderna matchar inte."}},edit_user:{title:"Justera användare",description:"Ändra inställningar för användare ''{name}''.",fields:{old_code:{heading:"Nuvarande kod",description:"Nuvarande kod, lämna tomt för att inte ändra."}}}}},actions:{title:"Åtgärder",cards:{notifications:{title:"Notifikationer",description:"Du använder denna panel för att hantera notifikationer som ska skickas vid utvalda larmevents.",table:{no_items:"Det är inga notifikationer skapade än.",no_area_warning:"Åtgärd är inte tilldelad till något område."},actions:{new_notification:"Ny notifikation"}},actions:{description:"I denna panel kan du trigga olika beteende på enheter baserat på olika events från ditt larm.",table:{no_items:"Det finns inga åtgärder skapade ännu."},actions:{new_action:"Ny åtgärd"}},new_notification:{title:"Konfigurera notifikationer",description:"Ta emot en notifikation när ditt larm aktivera/inaktiveras eller om en sensor aktiveras eller liknande.",trigger:"Villkor",action:"Åtgärd",options:"Inställningar",fields:{event:{heading:"Event",description:"När ska notifikationen skickas",choose:{armed:{name:"Larmet är aktiverat",description:"Larmet aktiveras framgångsrikt"},disarmed:{name:"Larmet är inaktiverat",description:"Larmet är inaktiverat"},triggered:{name:"Larmet har utlösts",description:"Larmet har utlösts"},untriggered:{name:"Larmet inte längre utlöst",description:"Larmet inte längre utlöst"},arm_failure:{name:"Misslyckas att aktivera larm",description:"Larmet misslyckas att aktiveras på grund av någon sensor"},arming:{name:"Lämna fördröjning startas",description:"Lämna fördröjning startas, redo att lämna huset."},pending:{name:"Ankomst fördröjning startas",description:"Ankomst fördröjning startas, larmet kommer triggas snart."}}},mode:{heading:"Läge",description:"Begränsa åtgärd till specifikt larmläge (valfritt)"},title:{heading:"Titel",description:"Titel för notifikationsmeddelandet"},message:{heading:"Meddelande",description:"Innehåll av notifikationsmeddelandet",insert_wildcard:"Lägg in wildcard",placeholders:{armed:"Larmet har bytt status till {{arm_mode}}",disarmed:"Larmet är nu AVSTÄNGT",triggered:"Larmet har utlösts! Anledning: {{open_sensors}}.",untriggered:"Larmet inte längre utlöst.",arm_failure:"Larmet kunde inte aktiveras nu, detta på grund av: {{open_sensors}}.",arming:"Larmet kommer aktiveras snart, lämna huset.",pending:"Larmet kommer snart utlösas, inaktivera larmet snarast!"}},open_sensors_format:{heading:"Format för open_sensors wildcard",description:"Välj vilken sensorinformation som ska infogas i meddelandet",options:{default:"Namn och tillstånd",short:"Endast namn"}},arm_mode_format:{heading:"Översättning för larmläge wildcard",description:"Välj vilket språk som larmläge ska infogas i meddelandet"},target:{heading:"Mål",description:"Enhet att skicka push-meddelandet till"},media_player_entity:{heading:"Mediasoitinkohde",description:"Mediasoittimet viestin toistamiseen."},name:{heading:"Namn",description:"Beskrivning av notifikationen",placeholders:{armed:"Notifiera {target} vid aktivering av larm",disarmed:"Notifiera {target} vid inaktivering av larm",triggered:"Notifiera {target} vid utlöst larm",untriggered:"Notifiera {target} när larm inte längre utlöst",arm_failure:"Notifiera {target} vid fel av larm",arming:"Notifiera {target} vid utpassering",pending:"Notifiera {target} vid ankomst"}},delete:{heading:"Ta bort automation",description:"Ta bort automation permanent"}},actions:{test:"Testa"}},new_action:{title:"Konfigurera action",description:"Aktivera lampor eller andra enheter som sirener eller högtalare vid aktivering/inaktivering av larmet, triggning av larmet osv.",fields:{event:{heading:"Event",description:"När ska denna action aktiveras"},area:{heading:"Område",description:"Område som detta event ska appliceras på."},mode:{heading:"Läge",description:"Begränsa åtgärd till specifika larmlägen (frivilligt)"},entity:{heading:"Entitet",description:"Entitet att utföra åtgärd på"},action:{heading:"Åtgärd",description:"Åtgärd att utföra på entitet",no_common_actions:"Åtgärder kan enbart bli applicerade i YAML läge för utvalda entiteter."},name:{heading:"Namn",description:"Beskrivning av denna åtgärd",placeholders:{armed:"Sätt {entity} till {state} vid aktivering av larmet",disarmed:"Sätt {entity} till {state} vid inaktivering av larmet",triggered:"Sätt {entity} till {state} när larmet utlöses",untriggered:"Sätt {entity} till {state} när larmet inte längre utlöst",arm_failure:"Sätt {entity} till {state} vid fel av larmet",arming:"Sätt {entity} till {state} vid utpassering",pending:"Sätt {entity} till {state} vid ankomst"}}}}}}},ot={common:it,components:nt,title:st,panels:rt},dt=Object.freeze({__proto__:null,common:it,components:nt,default:ot,panels:rt,title:st}),lt={modes_short:{armed_away:"Đi vắng",armed_home:"Ở nhà",armed_night:"Ban đêm",armed_custom_bypass:"Tùy chỉnh",armed_vacation:"Đi nghỉ"},enabled:"Đang bật",disabled:"Đang tắt"},ct={time_picker:{seconds:"giây",minutes:"phút"},editor:{ui_mode:"Chế độ giao diện",yaml_mode:"Chế độ YAML",edit_in_yaml:"Soạn bằng YAML"},table:{filter:{label:"Lọc mục",item:"Lọc theo {name}",hidden_items:"{number} {number, plural,\n one {mục}\n other {mục}\n} bị ẩn"}}},mt="Bảng điều khiển báo động",ht={general:{title:"Tổng quan",cards:{general:{description:"Bảng điều khiển này đặt một số thiết lập toàn cục cho hệ thống báo động.",fields:{disarm_after_trigger:{heading:"Tắt bảo vệ sau khi báo động",description:"Sau khi đã hết thời gian kích hoạt báo động, tắt bảo vệ thay vì trở lại trạng thái bảo vệ trước đó."},ignore_blocking_sensors_after_trigger:{heading:"Bỏ qua các cảm biến chặn khi kích hoạt lại",description:"Trở lại trạng thái bật chế độ cảnh báo mà không cần kiểm tra các cảm biến có thể vẫn đang hoạt động."},enable_mqtt:{heading:"Bật MQTT",description:"Cho phép quản lý bảng điều khiển báo động qua MQTT."},enable_master:{heading:"Bật báo động tổng",description:"Tạo một thực thể để quản lý đồng thời mọi khu vực."}},actions:{setup_mqtt:"Cấu hình MQTT",setup_master:"Cấu hình báo động tổng"}},modes:{title:"Chế độ",description:"Bảng điều khiển này dùng để cài đặt các chế độ bảo vệ của hệ thống.",modes:{armed_away:"Bảo vệ khi đi vắng được dùng khi mọi người đã rời khỏi nhà. Tất cả cửa lớn và cửa sổ dẫn vào nhà, cũng như các cảm biến chuyển động trong nhà, sẽ được theo dõi.",armed_home:"Bảo vệ khi ở nhà được dùng để thiết lập báo động khi có người ở nhà. Tất cả cửa lớn và cửa sổ dẫn vào nhà, nhưng không theo dõi cảm biến chuyển động trong nhà, sẽ được theo dõi.",armed_night:"Bảo vệ vào ban đêm được dùng để thiết lập báo động trước khi đi ngủ. Tất cả cửa lớn và cửa sổ dẫn vào nhà, và một số cảm biến chuyển động (tầng dưới) trong nhà, sẽ được theo dõi.",armed_vacation:"Bảo vệ khi đi nghỉ có thể được coi là mở rộng của chế độ bảo vệ khi đi vắng khi bạn vắng nhà trong thời gian dài. Thời gian đếm giờ và phản ứng khi có kích hoạt sẽ được thay đổi (nếu muốn) khi ở xa nhà.",armed_custom_bypass:"Chế độ bổ sung để xác định phạm vi an ninh riêng của bạn."},number_sensors_active:"{number} {number, plural,\n one {cảm biến}\n other {cảm biến}\n} đang hoạt động",fields:{status:{heading:"Tình trạng",description:"Quyết định xem hệ thống có bảo vệ trong chế độ này không."},exit_delay:{heading:"Đếm giờ đi ra",description:"Khi đang bật bảo vệ, trong khoảng thời gian này các cảm biến sẽ chưa kích hoạt báo động."},entry_delay:{heading:"Đếm giờ đi vào",description:"Thời gian đếm lùi từ khi cảm biến bị kích hoạt cho đến khi báo động."},trigger_time:{heading:"Thời gian kích hoạt báo động",description:"Thời gian duy trì trạng thái báo động sau khi bị kích hoạt."}}},mqtt:{title:"Cấu hình MQTT",description:"Bảng điều khiển này dùng để cấu hình giao diện MQTT.",fields:{state_topic:{heading:"Chủ đề trạng thái",description:"Chủ đề đăng tải cập nhật trạng thái"},event_topic:{heading:"Chủ đề sự kiện",description:"Chủ đề đăng tải sự kiện báo động"},command_topic:{heading:"Chủ đề câu lệnh",description:"Chủ đề để Alarmo lắng nghe lệnh bật/tắt bảo vệ."},require_code:{heading:"Yêu cầu mã",description:"Yêu cầu phải gửi mã cùng với câu lệnh."},state_payload:{heading:"Cấu hình phụ tải trong mỗi trạng thái",item:"Định nghĩa phụ tải cho trạng thái ''{state}''"},command_payload:{heading:"Cấu hình phụ tải trong mỗi câu lệnh",item:"Định nghĩa phụ tải cho câu lệnh ''{command}''"}}},areas:{title:"Khu vực",description:"Khu vực có thể dùng để chia hệ thống báo động làm nhiều phần.",no_items:"Hiện chưa xác định khu vực.",table:{remarks:"Lưu ý",summary:"Khu vực này có {summary_sensors} và {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {cảm biến}\n other {cảm biến}\n}",summary_automations:"{number} {number, plural,\n one {tự động hóa}\n other {tự động hóa}\n}"},actions:{add:"Thêm"}}},dialogs:{create_area:{title:"Khu vực mới",fields:{copy_from:"Chép thiết lập từ"}},edit_area:{title:"Sửa khu vực ''{area}''",name_warning:"Ghi chú: đổi tên sẽ làm đổi mã thực thể"},remove_area:{title:"Xóa khu vực?",description:"Bạn có chắc chắn muốn xóa khu vực này? Khu vực này có {sensors} cảm biến và {automations} tự động hóa, sẽ đều bị xóa theo."},edit_master:{title:"Cấu hình báo động tổng"},disable_master:{title:"Tắt báo động tổng?",description:"Bạn có chắc chắn muốn xóa báo động tổng không? Khu vực này có {sensors} cảm biến và {automations} tự động hóa, sẽ đều bị xóa theo."}}},sensors:{title:"Cảm biến",cards:{sensors:{description:"Cảm biến đã được cấu hình. Nhấn vào mục để thay đổi.",table:{no_items:"Không có cảm biến nào.",no_area_warning:"Chưa gán cảm biến vào bất kỳ khu vực nào.",arm_modes:"Chế độ bảo vệ",always_on:"(Luôn luôn)"}},add_sensors:{title:"Thêm cảm biến",description:"Bổ sung cảm biến. Hãy đảm bảo cảm biến của bạn có tên phù hợp để dễ nhận ra.",no_items:"Không có thực thể HA sẵn có nào có thể cấu hình cho hệ thống báo động. Hãy đảm bảo đưa vào thực thể thuộc kiểu binary_sensor.",table:{type:"Kiểu được phát hiện"},actions:{add_to_alarm:"Thêm vào hệ thống",filter_supported:"Ẩn mục không rõ kiểu"}},editor:{title:"Sửa cảm biến",description:"Cấu hình thiết lập cảm biến ''{entity}''.",fields:{entity:{heading:"Thực thể",description:"Thực thể được liên kết với cảm biến này"},area:{heading:"Khu vực",description:"Chọn một khu vực để đưa cảm biến này vào."},group:{heading:"Nhóm",description:"Gom chung với các cảm biến khác để kích hoạt chung."},device_type:{heading:"Kiểu thiết bị",description:"Chọn một kiểu thiết bị để tự động áp dụng thiết lập phù hợp.",choose:{door:{name:"Cửa lớn",description:"Cửa ra vào, cổng hoặc nơi khác dùng để ra vào nhà."},window:{name:"Cửa sổ",description:"Cửa sổ, hoặc cửa lớn nhưng không dùng để ra vào nhà, như ban công chẳng hạn."},motion:{name:"Chuyển động",description:"Cảm biến hiện diện hoặc thiết bị tương tự có thời gian nghỉ giữa các lần kích hoạt."},tamper:{name:"Phá hoại",description:"Bộ phát hiện mở nắp cảm biến, cảm biến vỡ kính, v.v."},environmental:{name:"Môi trường",description:"Cảm biến khói/khí đốt, phát hiện rò rỉ, v.v. (không liên quan đến chống trộm)."},other:{name:"Chung chung"}}},always_on:{heading:"Luôn bật",description:"Cảm biến luôn kích hoạt báo động."},modes:{heading:"Chế độ được bật",description:"Các chế độ báo động có kích hoạt cảm biến này."},arm_on_close:{heading:"Bảo vệ sau khi đóng",description:"Sau khi tắt kích hoạt cảm biến này, đếm giờ đi ra sẽ được tự động bỏ qua."},use_exit_delay:{heading:"Dùng đếm giờ đi ra",description:"Cảm biến được phép hoạt động khi bắt đầu đếm giờ đi ra."},use_entry_delay:{heading:"Dùng đếm giờ đi vào",description:"Kích hoạt cảm biến sẽ kích hoạt báo động sau khi đã hết thời gian đếm giờ đi vào, chứ không kích hoạt ngay."},entry_delay:{heading:"Đếm giờ đi vào",description:"Ghi đè độ trễ nhập cảnh (được cấu hình cho chế độ kích hoạt) bằng độ trễ cụ thể cho cảm biến."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Cho phép mở lúc đầu",description:"Trạng thái mở lúc bật bảo vệ sẽ được bỏ qua (những lần kích hoạt cảm biến sau đó sẽ kích hoạt báo động)."},auto_bypass:{heading:"Tự động bỏ qua",description:"Bỏ qua cảm biến này không kích hoạt báo động nếu nó đang mở khi bắt đầu bảo vệ.",modes:"Các chế độ mà cảm biến này có thể được bỏ qua"},trigger_unavailable:{heading:"Báo động khi không khả dụng",description:"Khi trạng thái của cảm biến trở thành 'không khả dụng', nó sẽ kích hoạt cảm biến."}},actions:{toggle_advanced:"Thiết lập nâng cao",remove:"Xóa",setup_groups:"Cài đặt nhóm"},errors:{description:"Vui lòng sửa các lỗi sau:",no_area:"Chưa chọn khu vực",no_modes:"Chưa chọn chế độ để bật cảm biến",no_auto_bypass_modes:"Chưa chọn chế độ để cảm biến được tự động bỏ qua"}}},dialogs:{manage_groups:{title:"Quản lý nhóm cảm biến",description:"Trong một nhóm cảm biến, các cảm biến này phải được kích hoạt trong cùng khoảng thời gian thì mới kích hoạt báo động.",no_items:"Chưa có nhóm",actions:{new_group:"Nhóm mới"}},create_group:{title:"Nhóm cảm biến mới",fields:{name:{heading:"Tên",description:"Tên của nhóm cảm biến"},timeout:{heading:"Thời hạn",description:"Khoảng thời gian các cảm biến phải lần lượt được kích hoạt thì mới kích hoạt báo động."},event_count:{heading:"Số lượng",description:"Số lượng cảm biến khác nhau cần được kích hoạt để kích hoạt báo động."},sensors:{heading:"Cảm biến",description:"Chọn cảm biến để đưa vào nhóm này."}},errors:{invalid_name:"Đã cung cấp tên không hợp lệ.",insufficient_sensors:"Phải chọn ít nhất 2 cảm biến."}},edit_group:{title:"Sửa nhóm cảm biến ''{name}''"}}},codes:{title:"Mã",cards:{codes:{description:"Thay đổi thiết lập mã.",fields:{code_arm_required:{heading:"Yêu cầu nhập mã để bật",description:"Phải nhập mã đúng để bật hệ thống báo động."},code_disarm_required:{heading:"Yêu cầu nhập mã để tắt",description:"Phải nhập mã đúng để tắt hệ thống báo động."},code_mode_change_required:{heading:"Yêu cầu nhập mã để chuyển chế độ",description:"Phải nhập mã đúng để thay đổi chế độ bảo vệ đang hoạt động."},code_format:{heading:"Định dạng mã",description:"Thiết lập kiểu nhập liệu cho thẻ bảo vệ Lovelace.",code_format_number:"Mã số",code_format_text:"Mật khẩu"}}},user_management:{title:"Quản lý người dùng",description:"Mỗi người dùng sẽ có một mã riêng để bật/tắt hệ thống báo động.",no_items:"Chưa có người dùng nào",actions:{new_user:"Người dùng mới"}},new_user:{title:"Tạo người dùng mới",description:"Người dùng phải được tạo để cấp quyền vận hành hệ thống báo động.",fields:{name:{heading:"Tên",description:"Tên người dùng."},code:{heading:"Mã",description:"Mã dành cho người dùng này."},confirm_code:{heading:"Xác nhận mã",description:"Lặp lại mã."},can_arm:{heading:"Cho phép nhập mã để bật",description:"Nhập mã này để bật bảo vệ"},can_disarm:{heading:"Cho phép nhập mã để tắt",description:"Nhập mã này để tắt bảo vệ"},is_override_code:{heading:"Có phải mã vượt quyền không",description:"Nhập mã này sẽ buộc hệ thống phải bật bảo vệ ngay"},area_limit:{heading:"Khu vực giới hạn",description:"Giới hạn chỉ cho phép người dùng điều khiển các khu vực cụ thể"}},errors:{no_name:"Chưa cung cấp tên.",no_code:"Mã cần có ít nhất 4 ký tự/ký số.",code_mismatch:"Mã không trùng khớp."}},edit_user:{title:"Sửa người dùng",description:"Thay đổi cấu hình cho người dùng ''{name}''.",fields:{old_code:{heading:"Mã hiện tại",description:"Mã hiện tại, để trống khi không thay đổi."}}}}},actions:{title:"Hành động",cards:{notifications:{title:"Thông báo",description:"Khi dùng bảng điều khiển này, bạn có thể quản lý thông báo gửi đi khi có một sự kiện báo động xảy ra.",table:{no_items:"Chưa tạo thông báo nào.",no_area_warning:"Chưa gán hành động vào khu vực nào cả."},actions:{new_notification:"Thông báo mới"}},actions:{description:"Bảng điều khiển này có thể dùng để bật tắt thiết bị khi trạng thái báo động thay đổi.",table:{no_items:"Chưa tạo hành động nào."},actions:{new_action:"Hành động mới"}},new_notification:{title:"Cấu hình thông báo",description:"Nhận thông báo khi bật/tắt hệ thống báo động, khi bị kích hoạt, v.v.",trigger:"Điều kiện",action:"Nhiệm vụ",options:"Tùy chọn",fields:{event:{heading:"Sự kiện",description:"Khi nào thì gửi thông báo",choose:{armed:{name:"Hệ thống báo động được bật",description:"Hệ thống báo động đã được bật thành công"},disarmed:{name:"Hệ thống báo động được tắt",description:"Hệ thống báo động đã được tắt"},triggered:{name:"Hệ thống báo động bị kích hoạt",description:"Hệ thống báo động bị kích hoạt"},untriggered:{name:"Hệ thống báo động không còn bị kích hoạt",description:"Trạng thái kích hoạt của hệ thống đã kết thúc"},arm_failure:{name:"Bật bảo vệ thất bại",description:"Bật hệ thống báo động thất bại do một hay nhiều cảm biến đang mở"},arming:{name:"Bắt đầu đếm giờ đi ra",description:"Bắt đầu đếm giờ đi ra, hãy sẵn sàng rời khỏi nhà."},pending:{name:"Bắt đầu đếm giờ đi vào",description:"Bắt đầu đếm giờ đi vào, báo động sẽ sớm bị kích hoạt."}}},mode:{heading:"Chế độ",description:"Giới hạn hành động chỉ trong một số chế độ bảo vệ (tùy chọn)"},title:{heading:"Tiêu đề",description:"Tiêu đề của tin nhắn thông báo"},message:{heading:"Thông báo",description:"Nội dung tin nhắn thông báo",insert_wildcard:"Nhập mẫu",placeholders:{armed:"Hệ thống báo động chuyển sang {{arm_mode}}",disarmed:"Hệ thống báo động giờ đã TẮT",triggered:"Hệ thống báo động bị kích hoạt! {{open_sensors}}.",untriggered:"Hệ thống báo động không còn bị kích hoạt.",arm_failure:"Hệ thống báo động không bật bảo vệ được, lý do: {{open_sensors}}.",arming:"Hệ thống báo động sẽ sớm được bật, vui lòng rời khởi nhà.",pending:"Hệ thống báo động sắp bị kích hoạt, hãy tắt nó nhanh!"}},open_sensors_format:{heading:"Định dạng cho mẫu open_sensors",description:"Chọn thông tin cảm biến nào để chèn vào thông báo",options:{default:"Tên và trạng thái",short:"Chỉ tên"}},arm_mode_format:{heading:"Bản dịch cho mẫu arm_mode",description:"Chọn ngôn ngữ chế độ bảo vệ sẽ chèn vào thông báo"},target:{heading:"Mục tiêu",description:"Thiết bị để gửi thông báo tới"},media_player_entity:{heading:"Media Player Entity",description:"Trình phát đa phương tiện để phát tin nhắn trên"},name:{heading:"Tên",description:"Miêu tả của thông báo này",placeholders:{armed:"Thông báo đến {target} khi bật bảo vệ",disarmed:"Thông báo đến {target} khi tắt bảo vệ",triggered:"Thông báo đến {target} khi kích hoạt báo động",untriggered:"Thông báo đến {target} khi dừng báo động",arm_failure:"Thông báo đến {target} khi thất bại",arming:"Thông báo đến {target} khi đi ra",pending:"Thông báo đến {target} khi đi vào"}},delete:{heading:"Xóa tự động hóa",description:"Xóa vĩnh viễn tự động hóa này"}},actions:{test:"Chạy thử"}},new_action:{title:"Cấu hình hành động",description:"Bật đèn hoặc thiết bị (như chuông báo động) khi bật/tắt hệ thống báo động, khi bị kích hoạt, v.v.",fields:{event:{heading:"Sự kiện",description:"Khi nào nên thực hiện hành động"},area:{heading:"Khu vực",description:"Khu vực áp dụng sự kiện."},mode:{heading:"Chế độ",description:"Giới hạn hành động chỉ cho những chế độ bảo vệ cụ thể (tùy chọn)"},entity:{heading:"Thực thể",description:"Thực thể bị hành động tác động"},action:{heading:"Hành động",description:"Hành động tác động lên thực thể",no_common_actions:"Hành động chỉ có thể được gán trong chế độ YAML đối với thực thể đã chọn."},name:{heading:"Tên",description:"Miêu tả hành động này",placeholders:{armed:"Đặt {entity} thành {state} khi bật bảo vệ",disarmed:"Đặt {entity} thành {state} khi tắt bảo vệ",triggered:"Đặt {entity} thành {state} khi kích hoạt báo động",untriggered:"Đặt {entity} thành {state} khi dừng báo động",arm_failure:"Đặt {entity} thành {state} khi thất bại",arming:"Đặt {entity} thành {state} khi đi ra",pending:"Đặt {entity} thành {state} khi đi vào"}}}}}}},pt={common:lt,components:ct,title:mt,panels:ht},gt=Object.freeze({__proto__:null,common:lt,components:ct,default:pt,panels:ht,title:mt}),ut={modes_short:{armed_away:"离家警戒",armed_home:"在家警戒",armed_night:"夜间警戒",armed_custom_bypass:"自定义警戒",armed_vacation:"度假警戒"},enabled:"已启用",disabled:"已禁用"},vt={time_picker:{seconds:"秒",minutes:"分",infinite:"无限",none:"无"},editor:{ui_mode:"UI模式",yaml_mode:"YAML模式",edit_in_yaml:"在YAML中编辑"},table:{filter:{label:"过滤项目",item:"通过{name}过滤",hidden_items:"{number} {number, plural,\n one {项目}\n other {项目}\n} 已隐藏"}}},_t="警戒面板",bt={general:{title:"通用",cards:{general:{description:"该面板定义了警戒的一些全局设置。",fields:{disarm_after_trigger:{heading:"触发后解除警戒",description:"触发超时后解除警报,而不是返回到警戒状态。"},ignore_blocking_sensors_after_trigger:{heading:"重新布防期间忽略阻挡传感器",description:"返回到武装状态,而不检查可能仍处于活动状态的传感器。"},enable_mqtt:{heading:"启用MQTT",description:"允许通过MQTT控制警戒面板。"},enable_master:{heading:"启用警戒主控",description:"创建一个实体,用于同时控制所有区域。"}},actions:{setup_mqtt:"MQTT配置",setup_master:"主控配置"}},modes:{title:"模式",description:"该面板可用于设置报警器的警戒模式。",modes:{armed_away:"当所有的人离开房子时,将使用离家警戒。所有接入房屋的门窗传感器都将被监听状态,包括有动作传感器。",armed_home:"当有人在家时,设置警戒时将使用在家警戒(也称为停留警戒)。所有接入房屋的门窗传感器都将被监听状态,但房屋的动作传感器不受监听。",armed_night:"在睡觉前设置警报时,将使用夜间警报。所有接入房屋的门窗传感器都将被监听状态,并且指定的动作传感器(例如:楼梯)也将被监听。",armed_vacation:"度假警戒可以作为离家警戒模式的拓展,以应对长时间的离家情况。延迟时间和触发反应可以根据离家的时间按需调整。",armed_custom_bypass:"一个额外的模式,用于定义你自己的警戒模式。"},number_sensors_active:"{number} {number, plural,\n one {传感器}\n other {传感器}\n} 激活",fields:{status:{heading:"状态",description:"控制警报器是否可以在此模式下警戒。"},exit_delay:{heading:"离开延迟",description:"当开启警戒时,在这个时间段内,传感器还不会触发警报。"},entry_delay:{heading:"进入延迟",description:"在其中一个传感器被触发后,直到触发警报的延迟时间。"},trigger_time:{heading:"触发时间",description:"警戒在激活后保持在触发状态的时间。"}}},mqtt:{title:"MQTT配置",description:"该面板可用于配置MQTT接口。",fields:{state_topic:{heading:"状态主题(Topic)",description:"更新状态发布的主题"},event_topic:{heading:"事件主题(Topic)",description:"警戒事件发布的主题"},command_topic:{heading:"指令主题(Topic)",description:"Alarmo 监听警戒或者解除警戒的主题"},require_code:{heading:"需要密码",description:"需要密码和指令一起发送"},state_payload:{heading:"配置每个状态的有效载荷",item:"定义状态的有效载荷 ''{state}''"},command_payload:{heading:"配置每个指令的有效载荷",item:"定义指令的有效载荷 ''{command}''"}}},areas:{title:"区域",description:"区域可用于将您的报警系统划分为多个区间。",no_items:"目前还没有定义任何区域。",table:{remarks:"备注",summary:"当前区域包含 {summary_sensors} 和 {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {传感器}\n other {传感器}\n}",summary_automations:"{number} {number, plural,\n one {自动化}\n other {自动化}\n}"},actions:{add:"添加"}}},dialogs:{create_area:{title:"新区域",fields:{copy_from:"复制设置,从"}},edit_area:{title:"编辑区域 ''{area}''",name_warning:"注意:改变名称将改变实体ID。"},remove_area:{title:"删除区域?",description:"你确定要删除区域吗? 当前区域包含 {sensors} 传感器和 {automations} 自动化, 也会一起删除。"},edit_master:{title:"主控配置"},disable_master:{title:"禁用主控?",description:"你确定你要删除警报器主控吗? 当前区域包含 {automations} 自动化, 也会一起删除。"}}},sensors:{title:"传感器",cards:{sensors:{description:"目前配置的传感器。点击一个项目来进行修改。",table:{no_items:"这里没有要显示的传感器。",no_area_warning:"传感器没有被分配到任何区域。",arm_modes:"警戒模式",always_on:"(一直开启)"}},add_sensors:{title:"添加传感器",description:"添加更多的传感器。确保你的传感器有一个合适的名字,这样你就可以识别它们。",no_items:"没有可用的HA实体可以被配置为报警器。请确保包含 binary_sensor 类型的实体。",table:{type:"检测到的类型"},actions:{add_to_alarm:"添加到报警器",filter_supported:"隐藏未知类型的项目"}},editor:{title:"编辑传感器",description:"配置传感器 ''{entity}'' 的设置。",fields:{entity:{heading:"实体",description:"与该传感器关联的实体"},area:{heading:"区域",description:"选择一个包含该传感器的区域。"},group:{heading:"群组",description:"与其他传感器分组进行联合触发。"},device_type:{heading:"设备类型",description:"选择一个设备类型来自动应用适当的设置。",choose:{door:{name:"门",description:"用于进入/离开房屋的门或其他入口。"},window:{name:"窗",description:"窗户或不用于进入房屋的门,如阳台。"},motion:{name:"动作",description:"存在传感器或类似装置,在激活之间有一个延迟。"},tamper:{name:"篡改",description:"移除传感器盖的探测器,玻璃破碎传感器等。"},environmental:{name:"环境",description:"烟雾/气体传感器、泄漏探测器等(与防盗不相关)。"},other:{name:"通用"}}},always_on:{heading:"总是开启",description:"传感器应始终触发警报。"},modes:{heading:"启用的模式",description:"该传感器处于活动状态的警戒模式。"},arm_on_close:{heading:"关闭后警戒",description:"该传感器停用后,剩余的离开延迟将被自动跳过。"},use_exit_delay:{heading:"使用离开延迟",description:"当离开延迟开始时,传感器被允许处于活动状态。"},use_entry_delay:{heading:"使用进入延迟",description:"传感器的激活会在进入延迟后触发警报,而不是直接触发。"},entry_delay:{heading:"进入延迟",description:"使用特定于传感器的延迟覆盖进入延迟(如为布防模式配置的)。"},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"允许在警戒后打开",description:"在警戒时,传感器的初始状态将被忽略。"},auto_bypass:{heading:"自动旁路",description:"如果该传感器在警戒时被触发,则将其排除在报警之外。",modes:"可绕过传感器的模式"},trigger_unavailable:{heading:"不可用时触发",description:'当传感器状态变成"不可用"时,将激活传感器。'}},actions:{toggle_advanced:"高级设定",remove:"删除",setup_groups:"配置群组"},errors:{description:"请修正以下错误:",no_area:"没有选择任何区域",no_modes:"没有选择传感器应处于活动状态的模式",no_auto_bypass_modes:"没有选择任何模式的传感器可能会被自动绕过。"}}},dialogs:{manage_groups:{title:"管理传感器群组",description:"在一个传感器群组中,多个传感器必须在一个时间段内被触发,才能触发警报。",no_items:"无群组",actions:{new_group:"新群组"}},create_group:{title:"新传感器群组",fields:{name:{heading:"名称",description:"传感器群组的名称"},timeout:{heading:"超时",description:"连续的传感器激活触发报警的时间段。"},event_count:{heading:"数字",description:"需要激活才能触发警报的不同传感器的数量。"},sensors:{heading:"传感器",description:"选择该群组所包含的传感器。"}},errors:{invalid_name:"提供的名称无效。",insufficient_sensors:"至少需要选择2个传感器。"}},edit_group:{title:"编辑传感器群组''{name}''"}}},codes:{title:"密码",cards:{codes:{description:"更改密码的设置。",fields:{code_arm_required:{heading:"使用警戒密码",description:"需要密码才能启用警报器"},code_disarm_required:{heading:"使用解除警戒密码",description:"需要密码才能解除警报器"},code_mode_change_required:{heading:"切换模式需要代码",description:"必须提供有效的代码才能更改处于活动状态的手臂模式。"},code_format:{heading:"密码格式",description:"设置 Lovelace Alarm Card 的输入类型。",code_format_number:"PIN码",code_format_text:"密码"}}},user_management:{title:"用户管理",description:"每个用户都有自己的密码来启用/解除警报。",no_items:"无用户",actions:{new_user:"新用户"}},new_user:{title:"创建新用户",description:"可以创建用户以提供操作警报器的权限。",fields:{name:{heading:"名称",description:"该用户的名称。"},code:{heading:"密码",description:"该用户的密码"},confirm_code:{heading:"确认密码",description:"重复输入密码。"},can_arm:{heading:"允许密码用于警戒",description:"输入此密码可激活警戒"},can_disarm:{heading:"允许密码用于解除警戒",description:"输入此密码可解除警戒"},is_override_code:{heading:"是覆盖密码",description:"输入此密码将强制激活警戒。"},area_limit:{heading:"限制区域",description:"限制用户只控制选定的区域"}},errors:{no_name:"没有提供名称。",no_code:"密码应至少有4个字符/数字。",code_mismatch:"密码不匹配。"}},edit_user:{title:"编辑用户",description:"为用户 ''{name}'' 变更配置。",fields:{old_code:{heading:"当前密码",description:"当前密码,留空表示保持不变。"}}}}},actions:{title:"动作",cards:{notifications:{title:"提醒",description:"使用此面板,你可以管理当某个报警事件发生时要发送的通知。",table:{no_items:"目前还没有创建任何通知。",no_area_warning:"动作没有被分配到任何领域。"},actions:{new_notification:"新通知"}},actions:{description:"当报警状态改变时,这个面板可以用来切换设备。",table:{no_items:"目前还没有创建任何动作。"},actions:{new_action:"新动作"}},new_notification:{title:"配置通知",description:"在启动/解除警报时、激活时等收到通知。",trigger:"条件",action:"任务",options:"选项",fields:{event:{heading:"事件",description:"应在何时发送通知",choose:{armed:{name:"警报器已警戒",description:"警报器已成功警戒"},disarmed:{name:"警报器已解除警戒",description:"警报器已解除警戒"},triggered:{name:"警报器已触发",description:"警报器已触发"},untriggered:{name:"警报器不再被触发",description:"警报器的触发状态已经结束"},arm_failure:{name:"警戒失败",description:"由于一个或多个传感器打开,警报器的警戒失败。"},arming:{name:"离开延迟开始",description:"离开延迟开始,准备离开房屋。"},pending:{name:"进入延迟开始",description:"进入延迟开始,警报将很快触发。"}}},mode:{heading:"模式",description:"将动作限制在特定的警戒模式(可选)。"},title:{heading:"标题",description:"通知信息的标题"},message:{heading:"信息",description:"通知信息的内容",insert_wildcard:"插入通配符",placeholders:{armed:"报警器被设置为 {{arm_mode}}",disarmed:"警报器现在是关闭的。",triggered:"警报被触发了! 因为:{{open_sensors}}.",untriggered:"警报器不再被触发。",arm_failure:"警报器现在无法启动,因为: {{open_sensors}}.",arming:"警报器很快就会警戒,请离开房屋。",pending:"警报器即将触发,请迅速解除警报!"}},open_sensors_format:{heading:"open_sensors通配符的格式",description:"选择在信息中插入哪些传感器信息",options:{default:"名称和状态",short:"仅名称"}},arm_mode_format:{heading:"警戒模式通配符的翻译",description:"选择在信息中插入警戒模式的语言"},target:{heading:"目标",description:"要发送通知的设备"},media_player_entity:{heading:"媒体播放器实体",description:"用于播放消息的媒体播放器。"},name:{heading:"名称",description:"该通知的描述",placeholders:{armed:"警戒时通知 {target}",disarmed:"解除警戒时通知 {target}",triggered:"触发警报时通知 {target}",untriggered:"警报解除时通知 {target}",arm_failure:"警戒失败时通知 {target}",arming:"警戒延迟开始时通知 {target}",pending:"警报即将触发时通知 {target}"}},delete:{heading:"删除自动化",description:"永久性地删除这个自动化"}},actions:{test:"测试"}},new_action:{title:"配置动作",description:"在启动/解除警报时,在激活时,切换灯光或设备(如警笛)。",fields:{event:{heading:"事件",description:"什么时候应该执行该动作"},area:{heading:"区域",description:"事件适用的区域。"},mode:{heading:"模式",description:"将动作限制在特定的警戒模式(可选)。"},entity:{heading:"实体",description:"要执行动作的实体"},action:{heading:"动作",description:"对实体执行的动作",no_common_actions:"动作只能在YAML模式下为选定的实体分配。"},name:{heading:"名称",description:"该动作的描述",placeholders:{armed:"警戒时将 {entity} 设置为 {state}。",disarmed:"解除警戒时将 {entity} 设置为 {state}。",triggered:"触发警报时将 {entity} 设置为 {state}。",untriggered:"警报解除时将 {entity} 设置为 {state}。",arm_failure:"警戒失败时将 {entity} 设置为 {state}。",arming:"警戒延迟开始时将 {entity} 设置为 {state}。",pending:"警报即将触发时将 {entity} 设置为 {state}。"}}}}}}},ft={common:ut,components:vt,title:_t,panels:bt},yt=Object.freeze({__proto__:null,common:ut,components:vt,default:ft,panels:bt,title:_t}),kt={modes_short:{armed_away:"離家警戒",armed_home:"在家警戒",armed_night:"夜間警戒",armed_custom_bypass:"自定義警戒",armed_vacation:"度假警戒"},enabled:"已啟用",disabled:"已禁用"},wt={time_picker:{seconds:"秒",minutes:"分"},editor:{ui_mode:"UI模式",yaml_mode:"YAML模式",edit_in_yaml:"在YAML中編輯"},table:{filter:{label:"過濾項目",item:"通過{name}過濾",hidden_items:"{number} {number, plural,\n one {項目}\n other {項目}\n} 已隱藏"}}},zt="警戒面板",At={general:{title:"通用",cards:{general:{description:"該面板定義了警戒的一些全局設置。",fields:{disarm_after_trigger:{heading:"觸發後解除警戒",description:"觸發超時後解除警報,而不是返回到警戒狀態。"},ignore_blocking_sensors_after_trigger:{heading:"重新布防期間忽略阻擋感應器",description:"返回武裝狀態,而不檢查可能仍處於活動狀態的感測器。"},enable_mqtt:{heading:"啟用MQTT",description:"允許通過MQTT控制警戒面板。"},enable_master:{heading:"啟用警戒主控",description:"創建一個實體,用於同時控制所有區域。"}},actions:{setup_mqtt:"MQTT配置",setup_master:"主控配置"}},modes:{title:"模式",description:"該面板可用於設置報警器的警戒模式。",modes:{armed_away:"當所有的人離開房子時,將使用離家警戒。所有接入房屋的門窗傳感器都將被監聽狀態,包括有動作傳感器。",armed_home:"當有人在家時,設置警戒時將使用在家警戒(也稱為停留警戒)。所有接入房屋的門窗傳感器都將被監聽狀態,但房屋的動作傳感器不受監聽。",armed_night:"在睡覺前設置警報時,將使用夜間警報。所有接入房屋的門窗傳感器都將被監聽狀態,並且指定的動作傳感器(例如:樓梯)也將被監聽。",armed_vacation:"度假警戒可以作為離家警戒模式的拓展,以應對長時間的離家情況。延遲時間和觸發反應可以根據離家的時間按需調整。",armed_custom_bypass:"一個額外的模式,用於定義你自己的警戒模式。"},number_sensors_active:"{number} {number, plural,\n one {傳感器}\n other {傳感器}\n} 激活",fields:{status:{heading:"狀態",description:"控制警報器是否可以在此模式下警戒。"},exit_delay:{heading:"離開延遲",description:"當開啟警戒時,在這個時間段內,傳感器還不會觸發警報。"},entry_delay:{heading:"進入延遲",description:"在其中一個傳感器被觸發後,直到觸發警報的延遲時間。"},trigger_time:{heading:"觸發時間",description:"警戒在激活後保持在觸發狀態的時間。"}}},mqtt:{title:"MQTT配置",description:"該面板可用於配置MQTT接口。",fields:{state_topic:{heading:"狀態主題(Topic)",description:"更新狀態發布的主題"},event_topic:{heading:"事件主題(Topic)",description:"警戒事件發布的主題"},command_topic:{heading:"指令主題(Topic)",description:"Alarmo 監聽警戒或者解除警戒的主題"},require_code:{heading:"需要密碼",description:"需要密碼和指令一起發送"},state_payload:{heading:"配置每個狀態的有效載荷",item:"定義狀態的有效載荷 ''{state}''"},command_payload:{heading:"配置每個指令的有效載荷",item:"定義指令的有效載荷 ''{command}''"}}},areas:{title:"區域",description:"區域可用於將您的報警系統劃分為多個區間。",no_items:"目前還沒有定義任何區域。",table:{remarks:"備註",summary:"當前區域包含 {summary_sensors} 和 {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {傳感器}\n other {傳感器}\n}",summary_automations:"{number} {number, plural,\n one {自動化}\n other {自動化}\n}"},actions:{add:"添加"}}},dialogs:{create_area:{title:"新區域",fields:{copy_from:"覆制設置,從"}},edit_area:{title:"編輯區域 ''{area}''",name_warning:"註意:改變名稱將改變實體ID。"},remove_area:{title:"刪除區域?",description:"你確定要刪除區域嗎? 當前區域包含 {sensors} 傳感器和 {automations} 自動化, 也會一起刪除。"},edit_master:{title:"主控配置"},disable_master:{title:"禁用主控?",description:"你確定你要刪除警報器主控嗎? 當前區域包含 {automations} 自動化, 也會一起刪除。"}}},sensors:{title:"傳感器",cards:{sensors:{description:"目前配置的傳感器。點擊一個項目來進行修改。",table:{no_items:"這裏沒有要顯示的傳感器。",no_area_warning:"傳感器沒有被分配到任何區域。",arm_modes:"警戒模式",always_on:"(一直開啟)"}},add_sensors:{title:"添加傳感器",description:"添加更多的傳感器。確保你的傳感器有一個合適的名字,這樣你就可以識別它們。",no_items:"沒有可用的HA實體可以被配置為報警器。請確保包含 binary_sensor 類型的實體。",table:{type:"檢測到的類型"},actions:{add_to_alarm:"添加到報警器",filter_supported:"隱藏未知類型的項目"}},editor:{title:"編輯傳感器",description:"配置傳感器 ''{entity}'' 的設置。",fields:{entity:{heading:"實體",description:"與該感測器關聯的實體"},area:{heading:"區域",description:"選擇一個包含該傳感器的區域。"},group:{heading:"群組",description:"與其他傳感器分組進行聯合觸發。"},device_type:{heading:"設備類型",description:"選擇一個設備類型來自動應用適當的設置。",choose:{door:{name:"門",description:"用於進入/離開房屋的門或其他入口。"},window:{name:"窗",description:"窗戶或不用於進入房屋的門,如陽台。"},motion:{name:"動作",description:"存在傳感器或類似裝置,在激活之間有一個延遲。"},tamper:{name:"篡改",description:"移除傳感器蓋的探測器,玻璃破碎傳感器等。"},environmental:{name:"環境",description:"煙霧/氣體傳感器、泄漏探測器等(與防盜不相關)。"},other:{name:"通用"}}},always_on:{heading:"總是開啟",description:"傳感器應始終觸發警報。"},modes:{heading:"啟用的模式",description:"該傳感器處於活動狀態的警戒模式。"},arm_on_close:{heading:"關閉後警戒",description:"該傳感器停用後,剩余的離開延遲將被自動跳過。"},use_exit_delay:{heading:"使用離開延遲",description:"當離開延遲開始時,傳感器被允許處於活動狀態。"},use_entry_delay:{heading:"使用進入延遲",description:"傳感器的激活會在進入延遲後觸發警報,而不是直接觸發。"},entry_delay:{heading:"進入延遲",description:"使用特定於感測器的延遲覆蓋進入延遲(如為布防模式配置的)。"},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"允許在警戒後打開",description:"在警戒時,傳感器的初始狀態將被忽略。"},auto_bypass:{heading:"自動旁路",description:"如果該傳感器在警戒時被觸發,則將其排除在報警之外。",modes:"可繞過傳感器的模式"},trigger_unavailable:{heading:"不可用時觸發",description:'當傳感器狀態變成"不可用"時,將激活傳感器。'}},actions:{toggle_advanced:"高級設定",remove:"刪除",setup_groups:"配置群組"},errors:{description:"請修正以下錯誤:",no_area:"沒有選擇任何區域",no_modes:"沒有選擇傳感器應處於活動狀態的模式",no_auto_bypass_modes:"沒有選擇任何模式的傳感器可能會被自動繞過。"}}},dialogs:{manage_groups:{title:"管理傳感器群組",description:"在一個傳感器群組中,多個傳感器必須在一個時間段內被觸發,才能觸發警報。",no_items:"無群組",actions:{new_group:"新群組"}},create_group:{title:"新傳感器群組",fields:{name:{heading:"名稱",description:"傳感器群組的名稱"},timeout:{heading:"超時",description:"連續的傳感器激活觸發報警的時間段。"},event_count:{heading:"數位",description:"需要啟動才能觸發警報的不同感測器的數量。"},sensors:{heading:"傳感器",description:"選擇該群組所包含的傳感器。"}},errors:{invalid_name:"提供的名稱無效。",insufficient_sensors:"至少需要選擇2個傳感器。"}},edit_group:{title:"編輯傳感器群組''{name}''"}}},codes:{title:"密碼",cards:{codes:{description:"更改密碼的設置。",fields:{code_arm_required:{heading:"使用警戒密碼",description:"需要密碼才能啟用警報器"},code_disarm_required:{heading:"使用解除警戒密碼",description:"需要密碼才能解除警報器"},code_mode_change_required:{heading:"切換模式需要代碼",description:"必須提供有效的代碼才能更改處於活動狀態的手臂模式。"},code_format:{heading:"密碼格式",description:"設置 Lovelace Alarm Card 的輸入類型。",code_format_number:"PIN碼",code_format_text:"密碼"}}},user_management:{title:"用戶管理",description:"每個用戶都有自己的密碼來啟用/解除警報。",no_items:"無用戶",actions:{new_user:"新用戶"}},new_user:{title:"創建新用戶",description:"可以創建用戶以提供操作警報器的權限。",fields:{name:{heading:"名稱",description:"該用戶的名稱。"},code:{heading:"密碼",description:"該用戶的密碼"},confirm_code:{heading:"確認密碼",description:"重覆輸入密碼。"},can_arm:{heading:"允許密碼用於警戒",description:"輸入此密碼可激活警戒"},can_disarm:{heading:"允許密碼用於解除警戒",description:"輸入此密碼可解除警戒"},is_override_code:{heading:"是覆蓋密碼",description:"輸入此密碼將強制激活警戒。"},area_limit:{heading:"限制區域",description:"限制用戶只控制選定的區域"}},errors:{no_name:"沒有提供名稱。",no_code:"密碼應至少有4個字符/數字。",code_mismatch:"密碼不匹配。"}},edit_user:{title:"編輯用戶",description:"為用戶 ''{name}'' 變更配置。",fields:{old_code:{heading:"當前密碼",description:"當前密碼,留空表示保持不變。"}}}}},actions:{title:"動作",cards:{notifications:{title:"提醒",description:"使用此面板,你可以管理當某個報警事件發生時要發送的通知。",table:{no_items:"目前還沒有創建任何通知。",no_area_warning:"動作沒有被分配到任何領域。"},actions:{new_notification:"新通知"}},actions:{description:"當報警狀態改變時,這個面板可以用來切換設備。",table:{no_items:"目前還沒有創建任何動作。"},actions:{new_action:"新動作"}},new_notification:{title:"配置通知",description:"在啟動/解除警報時、激活時等收到通知。",trigger:"條件",action:"任務",options:"選項",fields:{event:{heading:"事件",description:"應在何時發送通知",choose:{armed:{name:"警報器已警戒",description:"警報器已成功警戒"},disarmed:{name:"警報器已解除警戒",description:"警報器已解除警戒"},triggered:{name:"警報器已觸發",description:"警報器已觸發"},untriggered:{name:"警報器不再被觸發",description:"警報器的觸發狀態已經結束"},arm_failure:{name:"警戒失敗",description:"由於一個或多個傳感器打開,警報器的警戒失敗。"},arming:{name:"離開延遲開始",description:"離開延遲開始,準備離開房屋。"},pending:{name:"進入延遲開始",description:"進入延遲開始,警報將很快觸發。"}}},mode:{heading:"模式",description:"將動作限制在特定的警戒模式(可選)。"},title:{heading:"標題",description:"通知信息的標題"},message:{heading:"信息",description:"通知信息的內容",insert_wildcard:"插入通配符",placeholders:{armed:"報警器被設置為 {{arm_mode}}",disarmed:"警報器現在是關閉的。",triggered:"警報被觸發了! 因為:{{open_sensors}}.",untriggered:"警報器不再被觸發。",arm_failure:"警報器現在無法啟動,因為: {{open_sensors}}.",arming:"警報器很快就會警戒,請離開房屋。",pending:"警報器即將觸發,請迅速解除警報!"}},open_sensors_format:{heading:"open_sensors通配符的格式",description:"選擇在信息中插入哪些傳感器信息",options:{default:"名稱和狀態",short:"僅名稱"}},arm_mode_format:{heading:"警戒模式通配符的翻譯",description:"選擇在信息中插入警戒模式的語言"},target:{heading:"目標",description:"要發送通知的設備"},media_player_entity:{heading:"媒體播放器實體",description:"用於播放訊息的媒體播放器。"},name:{heading:"名稱",description:"該通知的描述",placeholders:{armed:"警戒時通知 {target}",disarmed:"解除警戒時通知 {target}",triggered:"觸發警報時通知 {target}",untriggered:"警報解除時通知 {target}",arm_failure:"警戒失敗時通知 {target}",arming:"警戒延遲開始時通知 {target}",pending:"警報即將觸發時通知 {target}"}},delete:{heading:"刪除自動化",description:"永久性地刪除這個自動化"}},actions:{test:"測試"}},new_action:{title:"配置動作",description:"在啟動/解除警報時,在激活時,切換燈光或設備(如警笛)。",fields:{event:{heading:"事件",description:"什麽時候應該執行該動作"},area:{heading:"區域",description:"事件適用的區域。"},mode:{heading:"模式",description:"將動作限制在特定的警戒模式(可選)。"},entity:{heading:"實體",description:"要執行動作的實體"},action:{heading:"動作",description:"對實體執行的動作",no_common_actions:"動作只能在YAML模式下為選定的實體分配。"},name:{heading:"名稱",description:"該動作的描述",placeholders:{armed:"警戒時將 {entity} 設置為 {state}。",disarmed:"解除警戒時將 {entity} 設置為 {state}。",triggered:"觸發警報時將 {entity} 設置為 {state}。",untriggered:"警報解除時將 {entity} 設置為 {state}。",arm_failure:"警戒失敗時將 {entity} 設置為 {state}。",arming:"警戒延遲開始時將 {entity} 設置為 {state}。",pending:"警報即將觸發時將 {entity} 設置為 {state}。"}}}}}}},jt={common:kt,components:wt,title:zt,panels:At},$t=Object.freeze({__proto__:null,common:kt,components:wt,default:jt,panels:At,title:zt}),Tt={modes_short:{armed_away:"Полная охрана",armed_home:"Охрана дома",armed_night:"Охрана ночью",armed_custom_bypass:"Своя",armed_vacation:"Охрана отпуск"},enabled:"Включено",disabled:"Выключено"},Et={time_picker:{seconds:"секунды",minutes:"минуты"},editor:{ui_mode:"В пользовательский интерфейс",yaml_mode:"В YAML",edit_in_yaml:"Редактировать в YAML"},table:{filter:{label:"Фильтр элементов",item:"Фильтровать по {name}",hidden_items:"{number} {number, plural,\n one {item is}\n other {items are}\n} скрыты"}}},St="Панель сигнализации",Ct={general:{title:"Общие",cards:{general:{description:"Эта панель содержит общие настройки для сигнализации.",fields:{disarm_after_trigger:{heading:"Снять с охраны после срабатывания",description:"По истечении времени срабатывания отключит сигнализацию вместо возврата в состояние охраны."},ignore_blocking_sensors_after_trigger:{heading:"Игнорировать блокирующие датчики при повторной постановке на охрану",description:"Возврат в состояние готовности без проверки датчиков, которые могут быть еще активны."},enable_mqtt:{heading:"Включить MQTT",description:"Разрешить управление панелью сигнализации через MQTT."},enable_master:{heading:"Включить мастер сигнализации",description:"Создает объект для одновременного управления всеми областями."}},actions:{setup_mqtt:"Конфигурация MQTT",setup_master:"Мастер конфигурации"}},modes:{title:"Режимы",description:"Эту панель можно использовать для настройки режимов включения сигнализации.",modes:{armed_away:"Полная охрана будет использоваться, когда все люди покинут дом. Все двери и окна, позволяющие получить доступ в дом, будут охраняться, так же как и датчики движения внутри дома.",armed_home:"Охрана дома будет использоваться при установке сигнализации, пока люди находятся в доме. Все двери и окна, позволяющие получить доступ в дом, будут охраняться, но не датчики движения внутри дома.",armed_night:"Охрана ночью будет использоваться при установке сигнализации перед сном. Все двери и окна, позволяющие получить доступ в дом, будут охраняться, а в доме будут установлены датчики движения (внизу).",armed_vacation:"Охрана отпуск может использоваться в качестве дополнения к режиму Охрана не дома в случае более длительного отсутствия. Время задержки и срабатывания триггера могут быть адаптированы (по желанию) к удаленности от дома.",armed_custom_bypass:"Дополнительный режим для определения вашего собственного периметра безопасности."},number_sensors_active:"{number} {number, plural,\n one {sensor}\n other {sensors}\n} активны",fields:{status:{heading:"Статус",description:"Определяет, может ли сигнализация быть включена в этом режиме."},exit_delay:{heading:"Время для выхода",description:"При включении сигнализации в течение этого периода времени датчики еще не активируют сигнал тревоги."},entry_delay:{heading:"Время для входа",description:"Время задержки до срабатывания сигнализации после активации одного из датчиков."},trigger_time:{heading:"Время срабатывания",description:"Время, в течение которого сигнализация будет оставаться в срабатывающем состоянии после активации."}}},mqtt:{title:"Конфигурация MQTT",description:"Эта панель может быть использована для настройки интерфейса MQTT.",fields:{state_topic:{heading:"Состояние темы",description:"Тема, по которой публикуются обновления состояния"},event_topic:{heading:"Тема мероприятия",description:"Тема, по которой публикуются тревожные события"},command_topic:{heading:"Команда темы",description:"Тема, которую Аламо прослушивает для команд включения / выключения."},require_code:{heading:"Требовать код",description:"Требовать отправки кода вместе с командой."},state_payload:{heading:"Настройка полезной нагрузки для каждого состояния",item:"Определите полезную нагрузку для состояния ''{state}''"},command_payload:{heading:"Настройка полезной нагрузки для каждой команды",item:"Определите полезную нагрузку для команды ''{command}''"}}},areas:{title:"Зоны",description:"Зоны можно использовать для разделения вашей системы сигнализации на несколько отсеков.",no_items:"Пока еще не определены зоны.",table:{remarks:"Замечания",summary:"Эта зона содержит {summary_sensors} и {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {sensor}\n other {sensors}\n}",summary_automations:"{number} {number, plural,\n one {automation}\n other {automations}\n}"},actions:{add:"Добавить"}}},dialogs:{create_area:{title:"Новая зона",fields:{copy_from:"Копировать настройки из"}},edit_area:{title:"Редактирование зоны ''{area}''",name_warning:"Примечание: изменение имени приведет к изменению идентификатора объекта"},remove_area:{title:"Удалить зону?",description:"Вы уверены, что хотите удалить эту зону? Эта зона содержит {sensors} датчики и {automations} автоматизации, которые также будут удалены."},edit_master:{title:"Мастер конфигурации"},disable_master:{title:"Отключить мастер?",description:"Вы уверены, что хотите удалить мастер сигнализации? Эта область содержит {automations} автоматизации, которые будут удалены с помощью этого действия."}}},sensors:{title:"Датчики",cards:{sensors:{description:"Настроенные в данный момент датчики. Нажмите на элемент, чтобы внести изменения.",table:{no_items:"Здесь нет датчиков, которые будут отображаться.",no_area_warning:"Датчик не привязан ни к какой зоне.",arm_modes:"Режимы охраны",always_on:"(Always)"}},add_sensors:{title:"Добавить датчики",description:"Добавьте больше датчиков. Убедитесь, что у ваших датчиков есть подходящее название, чтобы вы могли их идентифицировать.",no_items:"Нет доступных объектов HA, которые можно было бы настроить для сигнализации. Обязательно включите объекты типа binary_sensor.",table:{type:"Тип обнаружения"},actions:{add_to_alarm:"Добавить в сигнализацию",filter_supported:"Скрыть элементы с неизвестным типом"}},editor:{title:"Редактировать датчик",description:"Настройка параметров датчика ''{entity}''.",fields:{entity:{heading:"Объект",description:"Объект, связанный с этим датчиком"},area:{heading:"Зона",description:"Выберите зону, содержащую этот датчик."},group:{heading:"Группа",description:"Объедините с другими датчиками для комбинированного срабатывания."},device_type:{heading:"Тип устройства",description:"Выберите тип устройства, чтобы автоматически применить соответствующие настройки.",choose:{door:{name:"Дверь",description:"Дверь, ворота или другой вход, который используется для входа / выхода из дома."},window:{name:"Окно",description:"Окно или дверь, не используемые для входа в дом, такие как балкон."},motion:{name:"Движение",description:"Датчик присутствия или аналогичное устройство, имеющее задержку между активациями."},tamper:{name:"Тампер",description:"Датчик снятия крышки датчика, датчик разбитого стекла и т.д."},environmental:{name:"Экологический",description:"Датчик дыма / газа, течеискатель и т.д. (не связано с защитой от взлома)."},other:{name:"Общий"}}},always_on:{heading:"Всегда включен",description:"Датчик всегда должен подавать сигнал тревоги."},modes:{heading:"Включенные режимы",description:"Режимы сигнализации, в которых активен этот датчик."},arm_on_close:{heading:"Рычаг после закрытия",description:"После деактивации этого датчика оставшаяся задержка выхода будет автоматически пропущена."},use_exit_delay:{heading:"Используйте задержку для выхода",description:"Датчику разрешается быть активным, когда начинается задержка выхода."},use_entry_delay:{heading:"Используйте задержку для входа",description:"Активация датчика запускает сигнал тревоги после задержки входа, а не непосредственно."},entry_delay:{heading:"Задержка входа",description:"Переопределите задержку на вход (настроенную для режима охраны) на задержку, специфичную для датчика."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Разрешить открытие после постановки на охрану",description:"Начальное состояние датчика игнорируется при постановке на охрану."},auto_bypass:{heading:"Автоматический исключение",description:"Исключите этот датчик из системы сигнализации, если он открыт во время постановки на охрану.",modes:"Режимы, в которых датчик может быть исключен"},trigger_unavailable:{heading:"Срабатывает, когда недоступен",description:"Когда состояние датчика становится недоступным, это активирует датчик."}},actions:{toggle_advanced:"Дополнительные настройки",remove:"Удалить",setup_groups:"Настройки групп"},errors:{description:"Пожалуйста, исправьте следующие ошибки:",no_area:"Зона не выбрана",no_modes:"Не выбраны режимы, для которых датчик должен быть активен",no_auto_bypass_modes:"Никакие режимы не выбраны для датчика, который может быть автоматически исключен"}}},dialogs:{manage_groups:{title:"Управление группами датчиков",description:"В группе датчиков несколько датчиков должны быть активированы в течение определенного периода времени до срабатывания сигнализации.",no_items:"Групп пока нет",actions:{new_group:"Новая группа"}},create_group:{title:"Новая группа датчиков",fields:{name:{heading:"Название",description:"Название для группы датчиков"},timeout:{heading:"Тайм-аут",description:"Период времени, в течение которого последовательные срабатывания датчика вызывают сигнал тревоги."},event_count:{heading:"Число",description:"Количество различных датчиков, которые необходимо активировать для срабатывания сигнализации."},sensors:{heading:"Датчики",description:"Выберите датчики, входящие в эту группу."}},errors:{invalid_name:"Неверное название.",insufficient_sensors:"Необходимо выбрать по крайней мере 2 датчика."}},edit_group:{title:"Редактировать группу датчиков ''{name}''"}}},codes:{title:"Коды",cards:{codes:{description:"Измените настройки для кода.",fields:{code_arm_required:{heading:"Используйте код включения сигнализации",description:"Требуется код для включения сигнализации"},code_disarm_required:{heading:"Используйте код снятия с охраны",description:"Требуется код для снятия сигнализации с охраны"},code_mode_change_required:{heading:"Требовать код для переключения режима",description:"Для изменения активного режима охраны необходимо предоставить действительный код."},code_format:{heading:"Формат кода",description:"Задает тип ввода для карты сигнализации Lovelace.",code_format_number:"Пинкод",code_format_text:"Пароль"}}},user_management:{title:"Управление пользователями",description:"У каждого пользователя есть свой собственный код для включения / выключения сигнализации.",no_items:"Пользователей пока нет",actions:{new_user:"Hовый пользователь"}},new_user:{title:"Создать нового пользователя",description:"Пользователи могут быть созданы для предоставления доступа к управлению сигнализацией.",fields:{name:{heading:"Имя",description:"Имя пользователя."},code:{heading:"Код",description:"Код для этого пользователя."},confirm_code:{heading:"Подтвердите код",description:"Повторите код."},can_arm:{heading:"Разрешить код для постановки на охрану",description:"Ввод этого кода активирует сигнализацию"},can_disarm:{heading:"Разрешающить код для снятия с охраны",description:"Ввод этого кода отключает сигнализацию"},is_override_code:{heading:"Является переопределяющим кодом",description:"Ввод этого кода приведет к включению сигнализации в действие"},area_limit:{heading:"Запретные зоны",description:"Ограничить пользователя контролем только над выбранными зонами"}},errors:{no_name:"Имя не указано.",no_code:"Код должен содержать минимум 4 символа/ цифры.",code_mismatch:"Коды не совпадают."}},edit_user:{title:"Редактировать пользователя",description:"Изменить конфигурацию для пользователя ''{name}''.",fields:{old_code:{heading:"Текущий код",description:"Текущий код, оставьте пустым, чтобы оставить без изменений."}}}}},actions:{title:"Действия",cards:{notifications:{title:"Уведомления",description:"Используя эту панель, вы можете управлять уведомлениями, которые будут отправляться при возникновении определенного тревожного события.",table:{no_items:"Уведомления еще не созданы.",no_area_warning:"Действие не назначено ни для какой зоны."},actions:{new_notification:"Hовое уведомление"}},actions:{description:"Эта панель может использоваться для переключения устройства при изменении состояния тревоги.",table:{no_items:"Еще не создано никаких действий."},actions:{new_action:"Hoвое действие"}},new_notification:{title:"Настройка уведомления",description:"Получать уведомление при постановке на охрану / снятии с охраны сигнализации, при активации и т.д.",trigger:"Состояние",action:"Задача",options:"Опции",fields:{event:{heading:"Событие",description:"Когда должно быть отправлено уведомление",choose:{armed:{name:"Сигнализация включена",description:"Сигнализация успешно включена"},disarmed:{name:"Сигнализация отключена",description:"Сигнализация отключена"},triggered:{name:"Срабатывает сигнализация",description:"Срабатывает сигнализация"},untriggered:{name:"Тревога больше не срабатывает",description:"Состояние срабатывания сигнализации завершилось"},arm_failure:{name:"Включение сигнализации не удалось",description:"Включение сигнализации не удалось из-за одного или нескольких открытых датчиков"},arming:{name:"Началась задержка для выхода",description:"Началась задержка для выхода, выйдите из дома."},pending:{name:"Началась задержка для входа",description:"Началась задержка для входа, скоро сработает сигнализация."}}},mode:{heading:"Режим",description:"Ограничьте действие определенными режимами (необязательно)"},title:{heading:"Заголовок",description:"Заголовок для сообщения с уведомлением"},message:{heading:"Сообщение",description:"Содержание сообщения-уведомления",insert_wildcard:"Вставить подстановочный знак",placeholders:{armed:"Сигнализация включена на {{arm_mode}}",disarmed:"Сигнализация включена",triggered:"Сработала сигнализация! Причина: {{open_sensors}}.",untriggered:"Сигнал тревоги больше не срабатывает.",arm_failure:"Сигнализация не могла быть включена прямо сейчас из-за: {{open_sensors}}.",arming:"Сигнализация скоро включится, пожалуйста, покиньте дом.",pending:"Сигнализация вот-вот сработает, быстро отключите ее!"}},open_sensors_format:{heading:"Формат для подстановочного знака open_sensors",description:"Выберите, информация о каком датчике будет вставлена в сообщение",options:{default:"Названия и состояния",short:"Только имена"}},arm_mode_format:{heading:"Перевод для подстановочного знака arm_mode",description:"Выберите, на каком языке режим arm будет вставлен в сообщение"},target:{heading:"Цель",description:"Устройство для отправки уведомления на"},media_player_entity:{heading:"Объект медиаплеера",description:"Медиаплееры для воспроизведения сообщения."},name:{heading:"название",description:"Описание для этого уведомления",placeholders:{armed:"Уведомлять {target} при постановке на охрану",disarmed:"Уведомлять {target} после снятия с охраны",triggered:"Уведомлять {target} при срабатывании",untriggered:"Уведомлять {target} когда срабатывание прекращается",arm_failure:"Уведомлять {target} при неудаче",arming:"Уведомлять {target} при выходе",pending:"Уведомлять {target} при входе"}},delete:{heading:"Удалить автоматизацию",description:"Навсегда удалите эту автоматизацию"}},actions:{test:"Попробовать это"}},new_action:{title:"Настройки Действия",description:"Включайте освещение или устройства (например, сирены) при постановке на охрану/снятии с охраны сигнализации, при активации и т.д.",fields:{event:{heading:"Событие",description:"Когда должно быть выполнено действие"},area:{heading:"Зона",description:"Зона, для которой применяется событие."},mode:{heading:"Режим",description:"Ограничьте действие определенными режимами arm (необязательно)"},entity:{heading:"Объект",description:"Объект для выполнения действия над"},action:{heading:"Действие",description:"Действие, которое необходимо выполнить над объектом",no_common_actions:"Действия могут быть назначены только в режиме YAML для выбранных объектов."},name:{heading:"Название",description:"Описание для этого действия",placeholders:{armed:"Установите {entity} в {state} при постановке на охрану",disarmed:"Установите {entity} в {state} после снятия с охраны",triggered:"Установите {entity} в {state} при срабатывании",untriggered:"Установите {entity} в {state} когда срабатывание прекращается",arm_failure:"Установите {entity} в {state} при неудаче",arming:"Установите {entity} в {state} при выходе",pending:"Установите {entity} в {state} при входе"}}}}}}},Ot={common:Tt,components:Et,title:St,panels:Ct},Mt=Object.freeze({__proto__:null,common:Tt,components:Et,default:Ot,panels:Ct,title:St}),xt={modes_short:{armed_away:"Uzakta",armed_home:"Ev",armed_night:"Gece",armed_custom_bypass:"Özel",armed_vacation:"Tatil"},enabled:"Aktif",disabled:"Deaktif"},Nt={time_picker:{seconds:"saniye",minutes:"dakika"},editor:{ui_mode:"Kullanıcı Arayüzüne",yaml_mode:"YAML'e",edit_in_yaml:"YAML olarak düzenleme"},table:{filter:{label:"Öğeleri filtrele",item:"{name}'e göre filtrele",hidden_items:"{number} {number, plural,\n one {bir madde}\n other {iki madde}\n} gizli"}}},Lt="Alarm paneli",Ht={general:{title:"Genel",cards:{general:{description:"Bu panel alarm için bazı genel ayarları tanımlar.",fields:{disarm_after_trigger:{heading:"Tetiklemeden sonra devre dışı bırakma",description:"Tetikleme süresi zaman aşımına uğradıktan sonra, etkin duruma dönmek yerine alarmı devre dışı bırakın."},ignore_blocking_sensors_after_trigger:{heading:"Yeniden silahlandırırken bloke eden sensörleri yok sayın",description:"Hala aktif olabilecek sensörleri kontrol etmeden silahlı duruma geri dönün."},enable_mqtt:{heading:"MQTT'yi Etkinleştir",description:"Alarm panelinin MQTT aracılığıyla kontrol edilmesine izin verin."},enable_master:{heading:"Alarm yöneticisini etkinleştir",description:"Tüm alanları aynı anda kontrol etmek için bir varlık oluşturur."}},actions:{setup_mqtt:"MQTT Yapılandırması",setup_master:"Ana Yapılandırma"}},modes:{title:"Modlar",description:"Bu panel alarmın modlarını ayarlamak için kullanılabilir.",modes:{armed_away:"Tüm insanlar evi terk ettiğinde uzakta modu kullanılacaktır. Eve erişim sağlayan tüm kapı ve pencerelerin yanı sıra evin içindeki hareket sensörleri de korunacaktır.",armed_home:"Evdeyken aktif(evde iken alarm devrede olarak da bilinir), insanlar evdeyken ve alarm aktifken kullanılacaktır. Eve erişim sağlayan tüm kapı ve pencereler korunacak, ancak evin içindeki hareket sensörleri korunmayacaktır.",armed_night:"Uyumadan önce alarm kurulurken gece aktif modu kullanılacaktır. Eve erişim sağlayan tüm kapı ve pencereler ve evdeki seçilmiş hareket sensörleri (mesela alt kat) korunacak.",armed_vacation:"Aktif tatil, daha uzun süreli uzakta kalma durumunda aktif uzakta modunun bir eki olarak kullanılabilir. Gecikme süreleri ve tetikleme tepkileri (istenildiği gibi) evden uzakta olmaya göre uyarlanabilir.",armed_custom_bypass:"Kendi güvenlik çevrenizi tanımlamak için ekstra bir mod."},number_sensors_active:"{number} {number, plural,\n one {sensör}\n other {sensörler}\n} aktif",fields:{status:{heading:"Durum",description:"Alarmın bu modda devreye alınıp alınamayacağını kontrol eder."},exit_delay:{heading:"Çıkış gecikmesi",description:"Alarmı kurarken geçen bu süre içinde sensörler henüz alarmı tetiklemeyecektir."},entry_delay:{heading:"Giriş gecikmesi",description:"Sensörlerden biri etkinleştirildikten sonra alarmın tetiklenmesine kadar geçen gecikme süresi."},trigger_time:{heading:"Tetikleme süresi",description:"Etkinleştirmeden sonra alarmın tetiklenmiş durumda kalacağı süre."}}},mqtt:{title:"MQTT yapılandırması",description:"Bu panel MQTT arayüzünün yapılandırılması için kullanılabilir.",fields:{state_topic:{heading:"Durum konusu",description:"Durum güncellemelerinin yayınlandığı konu"},event_topic:{heading:"Etkinlik konusu",description:"Alarm olaylarının yayınlandığı konu başlığı"},command_topic:{heading:"Komut konusu",description:"Alarmo'nun devreye alma/devreden çıkarma komutları için dinlediği konu."},require_code:{heading:"Kod gerektir",description:"Komutla birlikte gönderilecek kodu gerektirir."},state_payload:{heading:"Durum başına veri yükü yapılandırma",item:"''{state}'' durumu için bir veri yükü tanımlayın"},command_payload:{heading:"Komut başına veri yükü yapılandırma",item:"''{command}'' komutu için bir veri yükü tanımlayın"}}},areas:{title:"Alanlar",description:"Alanlar, alarm sisteminizi birden fazla bölüme ayırmak için kullanılabilir.",no_items:"Henüz tanımlanmış bir alan bulunmamaktadır.",table:{remarks:"Özet",summary:"Bu alan {summary_sensors} ve {summary_automations} öğelerini içerir.",summary_sensors:"{number} {number, plural,\n one {sensör}\n other {sensörler}\n}",summary_automations:"{number} {number, plural,\n one {otomasyon}\n other {otomasyonlar}\n}"},actions:{add:"Ekle"}}},dialogs:{create_area:{title:"Yeni alan",fields:{copy_from:"Ayarları şuradan kopyala"}},edit_area:{title:"Alanı düzenle ''{area}''",name_warning:"Not: adın değiştirilmesi varlık kimliğini(entity ID) de değiştirecektir"},remove_area:{title:"Alan kaldırılsın mı?",description:"Bu alanı kaldırmak istediğinizden emin misiniz? Bu alan {sensors} sensörlerini ve {automations} otomasyonlarını içerir, bunlar da kaldırılacaktır."},edit_master:{title:"Ana yapılandırma"},disable_master:{title:"Ana yapılandırma'yı devre dışı bırakalım mı?",description:"Alarm yöneticisini kaldırmak istediğinizden emin misiniz? Bu alan, bu eylemle kaldırılacak olan {otomasyonlar} otomasyonlarını içerir."}}},sensors:{title:"Sensörler",cards:{sensors:{description:"Şu anda yapılandırılmış sensörler. Değişiklik yapmak için bir öğeye tıklayın.",table:{no_items:"Görüntülenecek herhangi bir sensör yok.",no_area_warning:"Sensör herhangi bir alana atanmamıştır.",arm_modes:"Alarm Modları",always_on:"(Her zaman)"}},add_sensors:{title:"Sensör Ekleme",description:"Daha fazla sensör ekleyin. Sensörlerinizin uygun bir ada sahip olduğundan emin olun, böylece onları tanımlayabilirsiniz.",no_items:"Alarm için yapılandırılabilecek mevcut herhangi HA varlığı yok. binary_sensor türündeki varlıkları eklediğinizden emin olun.",table:{type:"Tespit edilen tip"},actions:{add_to_alarm:"Alarma ekle",filter_supported:"Türü bilinmeyen öğeleri gizle"}},editor:{title:"Sensörü Düzenle",description:"''{entity}'' sensör ayarlarının yapılandırılması.",fields:{entity:{heading:"Varlık",description:"Bu sensörle ilişkili varlık"},area:{heading:"Alan",description:"Bu sensörü içeren bir alan seçin."},group:{heading:"Grup",description:"Kombine tetikleme için diğer sensörlerle gruplayın."},device_type:{heading:"Cihaz Tipi",description:"Uygun ayarları otomatik olarak uygulamak için bir cihaz türü seçin.",choose:{door:{name:"Kapı",description:"Eve giriş/çıkış için kullanılan bir kapı, geçit veya başka bir giriş."},window:{name:"Pencere",description:"Bir pencere veya balkon gibi eve girmek için kullanılmayan bir kapı."},motion:{name:"Hareket",description:"Aktivasyonlar arasında geri sayımı olan varlık sensörü veya benzer bir cihaz."},tamper:{name:"Titreşim",description:"Sensör kapağının çıkarılma müşiri, cam kırılma sensörü vb."},environmental:{name:"Çevresel",description:"Duman/gaz sensörü, sızıntı detektörü, vb. (hırsıza karşı korumayla ilgili değildir)."},other:{name:"Genel"}}},always_on:{heading:"Her zaman açık",description:"Sensör her zaman alarmı tetiklemelidir."},modes:{heading:"Etkin modlar",description:"Bu sensörün aktif olduğu alarm modları."},arm_on_close:{heading:"Kapattıktan sonra aktif et",description:"Bu sensörün devre dışı bırakılmasından sonra, kalan evden ayrılma geri sayımı otomatik olarak atlanacaktır."},use_exit_delay:{heading:"Evden çıkış ertelemesini kullanın",description:"Evden ayrılma geri sayımı başladığında sensörün aktif olmasına izin verilir."},use_entry_delay:{heading:"Eve giriş ertelemesini kullanın",description:"Sensör aktivasyonu alarmı doğrudan değil giriş geri sayımından sonra tetikler."},entry_delay:{heading:"Giriş gecikmesi",description:"Giriş gecikmesini (kol modu için yapılandırıldığı gibi) sensöre özgü bir gecikmeyle geçersiz kılın."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Başlangıçta açık olmasına izin verin",description:"Devreye alma sırasında açık durum göz ardı edilir (sonraki sensör aktivasyonu alarmı tetikleyecektir)."},auto_bypass:{heading:"Otomatik olarak bypass",description:"Devreye alma sırasında açıksa bu sensörü alarmın etkileşiminden hariç tutun.",modes:"Sensörün bypass edilebileceği modlar"},trigger_unavailable:{heading:"Kullanılamadığında tetikle",description:"Sensör durumu 'kullanılamaz' olduğunda, bu sensörü etkinleştirecektir."}},actions:{toggle_advanced:"Gelişmiş ayarlar",remove:"Kaldır",setup_groups:"Grupları ayarlayın"},errors:{description:"Lütfen aşağıdaki hataları düzeltin:",no_area:"Hiçbir alan seçilmedi",no_modes:"Sensörün aktif olması gereken hiçbir mod seçilmedi",no_auto_bypass_modes:"Sensör için hiçbir mod seçilmezse, otomatik olarak bypass edilebilir"}}},dialogs:{manage_groups:{title:"Sensör gruplarını yönet",description:"Bir sensör grubunda, alarm tetiklenmeden önce birden fazla sensörün belirli bir süre içinde etkinleştirilmesi gerekir.",no_items:"Henüz grup yok",actions:{new_group:"Yeni grup"}},create_group:{title:"Yeni sensör grubu",fields:{name:{heading:"İsim",description:"Sensör grubu için ad"},timeout:{heading:"Zaman aşımı",description:"Ardışık sensör aktivasyonlarının alarmı tetiklediği süre."},event_count:{heading:"Sayı",description:"Alarmı tetiklemek için etkinleştirilmesi gereken farklı sensörlerin miktarı."},sensors:{heading:"Sensörler",description:"Bu grubun içerdiği sensörleri seçin."}},errors:{invalid_name:"Geçersiz ad sağlandı.",insufficient_sensors:"En az 2 sensörün seçilmesi gerekir."}},edit_group:{title:"Sensör ''{name}'' grubunu düzenleyin"}}},codes:{title:"Kodlar",cards:{codes:{description:"Kod için ayarları değiştirin.",fields:{code_arm_required:{heading:"Etkinleştirmek için kod gerektir",description:"Alarmı aktifleştirmek için geçerli bir kod sunulmalıdır."},code_disarm_required:{heading:"Devre dışı bırakmak için kod gereksin",description:"Alarmı devre dışı bırakmak için geçerli bir kod sağlanmalıdır."},code_mode_change_required:{heading:"Anahtarlama modu için kod gerektir",description:"Aktif modu değiştirmek için geçerli bir kod sağlanmalıdır."},code_format:{heading:"Kod formatı",description:"Lovelace alarm kartı için giriş türünü ayarlar.",code_format_number:"Pin kodu",code_format_text:"Şifre"}}},user_management:{title:"Kullanıcı yönetimi",description:"Her kullanıcının alarmı kurmak/devre dışı bırakmak için kendi kodu vardır.",no_items:"Henüz kullanıcı yok",actions:{new_user:"Yeni kullanıcı"}},new_user:{title:"Yeni kullanıcı oluştur",description:"Alarmın çalıştırılmasına erişim sağlamak için kullanıcılar oluşturulabilir.",fields:{name:{heading:"İsim",description:"Kullanıcının adı."},code:{heading:"Kod",description:"Bu kullanıcı için kod."},confirm_code:{heading:"Kodu onayla",description:"Kodu tekrarlayın."},can_arm:{heading:"Etkinleştirmek için koda izin ver",description:"Bu kodun girilmesi alarmı etkinleştirir"},can_disarm:{heading:"Etkisizleştirmek için koda izin ver",description:"Bu kod girilince alarm devre dışı kalır"},is_override_code:{heading:"Geçersiz kılma kodu",description:"Bu kodun girilmesi alarmı zor kullanarak aktif tutacaktır."},area_limit:{heading:"Kısıtlı alanlar",description:"Kullanıcıyı yalnızca seçilen alanları kontrol etmekle sınırlandırın"}},errors:{no_name:"İsim verilmemiş.",no_code:"Kod en az 4 karakter/rakam içermelidir.",code_mismatch:"Kodlar uyuşmuyor."}},edit_user:{title:"Kullanıcıyı Düzenle",description:"''{name}'' kullanıcısı için yapılandırmayı değiştirin.",fields:{old_code:{heading:"Şu anki kod",description:"Geçerli kodu değiştirmek istemiyorsanız boş bırakın."}}}}},actions:{title:"Eylemler",cards:{notifications:{title:"Bildirimler",description:"Bu paneli kullanarak, belirli bir alarm olayı gerçekleştiğinde gönderilecek bildirimleri yönetebilirsiniz.",table:{no_items:"Henüz oluşturulmuş bir bildirim yok.",no_area_warning:"Eylem herhangi bir alana atanmamıştır."},actions:{new_notification:"Yeni bildirim"}},actions:{description:"Bu panel, alarm durumu değiştiğinde bir cihazı değiştirmek için kullanılabilir.",table:{no_items:"Henüz oluşturulmuş bir eylem yok."},actions:{new_action:"Yeni eylem"}},new_notification:{title:"Bildirimi yapılandırın",description:"Alarmı kurarken/devre dışı bırakırken, aktivasyonda bırakırken vb. durumlarda bir bildirim alın.",trigger:"Durum",action:"Görev",options:"Seçenekler",fields:{event:{heading:"Etkinlik",description:"Bildirim ne zaman gönderilmeli?",choose:{armed:{name:"Alarm devrede",description:"Alarm başarıyla devreye alındı"},disarmed:{name:"Alarm devre dışı bırakıldı",description:"Alarm devre dışı bırakıldı"},triggered:{name:"Alarm tetiklendi",description:"Alarm tetiklenir"},untriggered:{name:"Alarm artık tetiklenmiyor",description:"Alarmın tetiklenme durumu sona erdi"},arm_failure:{name:"Etkinleştirilemedi",description:"Bir veya daha fazla açık sensör nedeniyle alarmın devreye alınması başarısızlıkla sonuçlandı"},arming:{name:"Çıkış gecikmesi başladı",description:"Çıkış geri sayımı başladı, evden çıkmaya hazır."},pending:{name:"Giriş gecikmesi başladı",description:"Giriş geri sayımı başladı, alarm yakında tetiklenecek."}}},mode:{heading:"Mod",description:"Eylemi belirli modlarla sınırlayın (isteğe bağlı)"},title:{heading:"Başlık",description:"Bildirim mesajı için başlık"},message:{heading:"Mesaj",description:"Bildirim mesajının içeriği",insert_wildcard:"Joker karakter ekle",placeholders:{armed:"Alarm {{arm_mode}} olarak ayarlanmıştır",disarmed:"Alarm şimdi KAPALI",triggered:"Alarm tetiklendi! Nedeni: {{open_sensors}}.",untriggered:"Alarm artık tetiklenmez.",arm_failure:"Alarm şu anda etkinleştirilemedi, çünkü: {{open_sensors}}.",arming:"Alarm birazdan devreye girecek, lütfen evi terk edin.",pending:"Alarm tetiklenmek üzere, hızlıca etkisiz hale getirin!"}},open_sensors_format:{heading:"open_sensors joker karakteri için biçim",description:"Mesaja hangi sensör bilgilerinin ekleneceğini seçin",options:{default:"İsimler ve durumlar",short:"Sadece isimler"}},arm_mode_format:{heading:"arm_mode joker karakteri için çeviri",description:"Alarm modunun mesaja hangi dilde ekleneceğini seçin"},target:{heading:"Hedef",description:"Bildirimin gönderileceği cihaz"},media_player_entity:{heading:"Medya oynatıcı varlığı",description:"Mesajı oynatmak için medya oynatıcı"},name:{heading:"İsim",description:"Bu bildirim için açıklama",placeholders:{armed:"Etkinleştirildiğinde {target} bilgilendir",disarmed:"Etkisizleştirildiğinde {target} bildir",triggered:"Tetiklendiğinde {target}'e bildir",untriggered:"Tetikleme durduğunda {hedef}'e bildir",arm_failure:"Başarısızlık durumunda {target}'e bildir",arming:"Ayrılırken {target}'e bildir",pending:"Geldiğinde {target}'e bildir"}},delete:{heading:"Otomasyonu sil",description:"Bu otomasyonu kalıcı olarak kaldırın"}},actions:{test:"Dene"}},new_action:{title:"Eylemi yapılandırın",description:"Alarmı devreye sokarken/devreden çıkarırken, etkinleştirme sırasında vb. ışıkları veya cihazları (sirenler gibi) yönetin.",fields:{event:{heading:"Etkinlik",description:"Eylem ne zaman gerçekleştirilmelidir"},area:{heading:"Alan",description:"Olayın geçerli olduğu alarm alanı."},mode:{heading:"Mod",description:"Eylemi belirli modlarla sınırlayın (isteğe bağlı)"},entity:{heading:"Varlık",description:"Üzerinde eylem gerçekleştirilecek varlık"},action:{heading:"Eylem",description:"Varlık üzerinde gerçekleştirilecek eylem",no_common_actions:"Eylemler seçilen varlıklar için yalnızca YAML modunda atanabilir."},name:{heading:"İsim",description:"Bu eylem için açıklama",placeholders:{armed:"Etkinleştirildiğinde {entity} öğesini {state} olarak ayarla",disarmed:"Etkisizleştirmenin ardından {entity} öğesini {state} olarak ayarlayın",triggered:"Tetiklendiğinde {entity} öğesini {state} olarak ayarla",untriggered:"Tetikleme durduğunda, {entity} öğesini {state} olarak ayarlayın",arm_failure:"Başarısızlık durumunda {entity} öğesini {state} olarak ayarla",arming:"Ayrılırken {entity} öğesini {state} olarak ayarlayın",pending:"Vardığımda {entity} öğesini {state} olarak ayarla"}}}}}}},Dt={common:xt,components:Nt,title:Lt,panels:Ht},Pt=Object.freeze({__proto__:null,common:xt,components:Nt,default:Dt,panels:Ht,title:Lt}),Bt={modes_short:{armed_away:"Poza domem",armed_home:"W domu",armed_night:"Noc",armed_custom_bypass:"Custom",armed_vacation:"Wakacje"},enabled:"Włączone",disabled:"Wyłączone"},qt={time_picker:{seconds:"sekundy",minutes:"minuty"},editor:{ui_mode:"Tryb UI",yaml_mode:"Tryb YAML",edit_in_yaml:"Edytuj w YAML"},table:{filter:{label:"Filtruj elementy",item:"Filtruj po {name}",hidden_items:"{number} {number, plural,\n one { element jest}\n other { elementy są}\n} ukryte"}}},Vt="Panel alarmu",It={general:{title:"Główne",cards:{general:{description:"Ten panel definiuje globalne ustawienia alarmu.",fields:{disarm_after_trigger:{heading:"Rozbrojenie po wywołaniu alarmu",description:"Po upływie zdefiniowanego czasu od wywołania alarmu zostanie on rozbrojony."},enable_mqtt:{heading:"Włącz MQTT",description:"Pozwala na sterowanie panelem alarmu za pomocą protokołu MQTT."},enable_master:{heading:"Włącz master alarm",description:"Tworzy encję do sterowania wszystkimi obszarami jednocześnie."}},actions:{setup_mqtt:"Konfiguracja MQTT",setup_master:"Konfiguracja master"}},modes:{title:"Tryby",description:"Ten panel służy do konfiguracji trybów uzbrojenia alarmu.",modes:{armed_away:"Tryb 'Poza domem' może być użyty, gdy wszyscy opuszczają dom. Wszystkie czujniki drzwi i okien oraz detektory ruchu wewnątrz domu będą aktywne.",armed_home:"Tryb 'W domu' może być użyty, gdy mieszkańcy są w domu. Wszystkie czujniki drzwi i okien będą aktywne, poza detektorami ruchu wewnątrz domu.",armed_night:"Tryb 'Noc' może być użyty, gdy domownicy idą spać. Wszystkie czujniki drzwi i okien będą aktywne, a wybrane detektory ruchu (np. na dole) będą aktywne.",armed_vacation:"Tryb 'Wakacje' może być użyty w przypadku dłuższej nieobecności w domu. Czasy opóźnienia i reakcje na wyzwolenie czujnikow i detektorów mogą być dowolnie definiowane, aby nie wszczynać fałszywego alarmu",armed_custom_bypass:"Dodatkowa opcja do definiowania własnego trybu działania alarmu."},number_sensors_active:"{number} {number, plural,\n one {czujnik}\n other {czujniki}\n} aktywne",fields:{status:{heading:"Stan",description:"Sprawdza, czy alarm może być uzbrojony w tym trybie."},exit_delay:{heading:"Opóźnienie uzbrojenia alarmu",description:"Alarm zostanie uzbrojony po ustawionym czasie, aby wszyscy mogli opuścić dom nie wywołując alarmu."},entry_delay:{heading:"Opóźnienie wywołania alarmu",description:"Zdefiniowany czas, aby domownicy mogli wejść do domu i rozbroić alarm."},trigger_time:{heading:"Czas działania alarmu",description:"Długość czasu aktywności alarmu po jego wyzwoleniu."}}},mqtt:{title:"Konfiguracja MQTT",description:"Ten panel służy do konfiguracji interfejsu MQTT.",fields:{state_topic:{heading:"Temat aktualizacji",description:"Temat, na którym publikowane są aktualizacje stanu alarmu"},event_topic:{heading:"Temat zdarzeń",description:"Temat, na którym publikowane są zdarzenia alarmowe"},command_topic:{heading:"Temat poleceń",description:"Temat, na którym są nasłuchiwane polecenia uzbrojenia/rozbrojenia alarmu."},require_code:{heading:"Wymagaj kodu",description:"Wymagaj przesłania kodu wraz z poleceniem."},state_payload:{heading:"Konfiguruj pakiet danych dla każdego stanu",item:"Zdefiniuj pakiet danych dla stanu ''{state}''"},command_payload:{heading:"Konfiguruj pakiet danych dla każdego polecenia",item:"Zdefiniuj pakiet danych dla polecenia ''{command}''"}}},areas:{title:"Strefy",description:"Strefy służą do podziału domu na kilka części, każda z nich działa niezależnie od siebie.",no_items:"Nie zdefiniowano jeszcze żadnych stref.",table:{remarks:"Uwagi",summary:"Ten obszar zawiera {summary_sensors} oraz {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {czujnik}\n other {czujniki}\n}",summary_automations:"{number} {number, plural,\n one {automatyzacja}\n other {automatyzacje}\n}"},actions:{add:"Dodaj"}}},dialogs:{create_area:{title:"Nowa strefa",fields:{copy_from:"Skopiuj ustawienia z"}},edit_area:{title:"Edytowanie strefy ''{area}''",name_warning:"Uwaga: zmiana nazwy strefy spowoduje zmianę nazwy encji"},remove_area:{title:"Usunąć strefę?",description:"Czy na pewno chcesz usunąć tą strefę? Ta strefa zawiera {sensors} czujników i {automations} automatyzacji, które również zostaną usunięte."},edit_master:{title:"Konfiguracja nadrzędna"},disable_master:{title:"Wyłączyć konfigurację nadrzędną?",description:"Czy na pewno chcesz usunąć alarm nadrzędny? Ten obszar zawiera {automations} automatyzacje, które zostaną usunięte wraz z tą akcją."}}},sensors:{title:"Czujniki",cards:{sensors:{description:"Obecnie skonfigurowane czujniki. Kliknij, aby wprowadzić zmiany.",table:{no_items:"Brak czujników do wyświetlenia.",no_area_warning:"Czujnik nie jest przypisany do żadnej strefy.",arm_modes:"Tryby uzbrojenia",always_on:"(Zawsze)"}},add_sensors:{title:"Dodaj czujniki",description:"Dodaj więcej czujników. Upewnij się, że Twoje czujniki mają odpowiednią nazwę, aby można je było rozpoznać.",no_items:"Nie ma dostępnych encji HA, które można skonfigurować dla alarmu. Upewnij się, że uwzględniasz encje typu binary_sensor.",table:{type:"Wykryty rodzaj"},actions:{add_to_alarm:"Dodaj do alarmu",filter_supported:"Ukryj nieznane elementy"}},editor:{title:"Edytuj czujnik",description:"Konfigurowanie ustawień czujnika ''{entity}''.",fields:{entity:{heading:"Encja",description:"Encja powiązana z tym czujnikiem"},area:{heading:"Obszar",description:"Wybierz obszar, który zawiera ten czujnik."},group:{heading:"Grupa",description:"Grupuj z innymi czujnikami, aby używać ich łącznie."},device_type:{heading:"Typ urządzenia",description:"Wybierz typ urządzenia, odpowiednie ustawienia będą dobrane automatycznie.",choose:{door:{name:"Drzwi",description:"Drzwi, brama itp., którędy wchodzi się do domu."},window:{name:"Okno",description:"Okno lub drzwi nieużywane do wchodzenia do domu, takie jak balkon."},motion:{name:"Ruch",description:"Czujnik obecności lub podobne urządzenie mające opóźnienie między aktywacjami."},tamper:{name:"Sabotaż",description:"Detektor usunięcia pokrywy czujnika, czujnik stłuczenia szkła itp."},environmental:{name:"Środowisko",description:"Czujnik dymu/gazu, zalania itp. (nie związany z ochroną przed włamaniem)."},other:{name:"Ogólny"}}},always_on:{heading:"Zawsze włączony",description:"Czujnik powinien zawsze wywołać alarm."},modes:{heading:"Włączone tryby",description:"Tryby alarmu, w których ten czujnik jest aktywny."},arm_on_close:{heading:"Uzbrojenie po zamknięciu",description:"Po wykryciu zamknięcia, alarm się uzbroi."},use_exit_delay:{heading:"Użyj opóźnienia wyjścia",description:"Czujnik może być aktywny, w trakcie uzbrajania alarmu."},use_entry_delay:{heading:"Użyj opóźnienia wejścia",description:"Aktywacja czujnika wywoła alarm dopiero po uzbrojeniu się alarmu."},entry_delay:{heading:"Opóźnienie wejścia",description:"Zastąp opóźnienie (ustawione dla trybu bezpieczeństwa) konkretną wartością."},delay_on:{heading:"Delay on",description:"Sensor must remain active for this duration before triggering. Resets if sensor deactivates."},allow_open:{heading:"Pozwól na otwarcie",description:"Stan otwarty czujnika podczas uzbrajania jest ignorowany (kolejna aktywacja czujnika wyzwoli alarm)."},auto_bypass:{heading:"Automatyczne pomijanie",description:"Wyklucz ten czujnik z alarmu, jeśli jest otwarty podczas uzbrajania.",modes:"Tryby, w których czujnik może być pominięty"},trigger_unavailable:{heading:"Wyzwalaj, gdy niedostępny",description:"Gdy stan czujnika staje się 'niedostępny', czujnik jest wyzwalany."}},actions:{toggle_advanced:"Zaawansowane",remove:"Usuń",setup_groups:"Konfiguruj grupy"},errors:{description:"Proszę poprawić następujące błędy:",no_area:"Nie wybrano obszaru",no_modes:"Nie wybrano trybów, w których czujnik powinien być aktywny",no_auto_bypass_modes:"Nie wybrano trybów, w których czujnik może być automatycznie pominięty"}}},dialogs:{manage_groups:{title:"Zarządzaj grupami czujników",description:"W grupie czujników kilka czujników musi zostać aktywowanych w określonym czasie, aby alarm został uruchomiony.",no_items:"Brak grup",actions:{new_group:"Nowa grupa"}},create_group:{title:"Nowa grupa czujników",fields:{name:{heading:"Nazwa",description:"Nazwa grupy czujników"},timeout:{heading:"Limit czasu",description:"Okres czasu, w którym kolejne aktywacje czujników uruchamiają alarm."},event_count:{heading:"Liczba",description:"Liczba różnych czujników, które muszą zostać aktywowane, aby uruchomić alarm."},sensors:{heading:"Czujniki",description:"Wybierz czujniki, które są zawarte w tej grupie."}},errors:{invalid_name:"Podano nieprawidłową nazwę.",insufficient_sensors:"Należy wybrać przynajmniej 2 czujniki."}},edit_group:{title:"Edytuj grupę czujników ''{name}''"}}},codes:{title:"Kody",cards:{codes:{description:"Zmień ustawienia kodu.",fields:{code_arm_required:{heading:"Wymagaj kodu do uzbrojenia",description:"Aby uzbroić alarm, należy podać prawidłowy kod."},code_disarm_required:{heading:"Wymagaj kodu do rozbrojenia",description:"Aby rozbroić alarm, należy podać prawidłowy kod."},code_mode_change_required:{heading:"Wymagaj kodu do zmiany trybu",description:"Aby zmienić aktywny tryb uzbrojenia, należy podać prawidłowy kod."},code_format:{heading:"Format kodu",description:"Ustawia sposób wprowadzania kodu/hasła dla karty alarmu Lovelace.",code_format_number:"Kod PIN",code_format_text:"Hasło"}}},user_management:{title:"Zarządzanie użytkownikami",description:"Każdy użytkownik ma własny kod do uzbrojenia/rozbrojenia alarmu.",no_items:"Nie ma jeszcze użytkowników",actions:{new_user:"Nowy użytkownik"}},new_user:{title:"Utwórz nowego użytkownika",description:"Użytkownicy, którzy mają dostęp do obsługi alarmu.",fields:{name:{heading:"Nazwa",description:"Nazwa użytkownika."},code:{heading:"Kod",description:"Kod użytkownika."},confirm_code:{heading:"Potwierdź kod",description:"Powtórz kod."},can_arm:{heading:"Zezwól na kod do uzbrojenia",description:"Wprowadzenie tego kodu uzbroi alarm"},can_disarm:{heading:"Zezwól na kod do rozbrojenia",description:"Wprowadzenie tego kodu rozbroi alarm"},is_override_code:{heading:"Jest kodem nadrzędnym",description:"Wprowadzenie tego kodu uzbroi alarm siłowo"},area_limit:{heading:"Ograniczone obszary",description:"Ogranicz użytkownika do kontrolowania tylko wybranych obszarów"}},errors:{no_name:"Nie podano nazwy.",no_code:"Kod powinien mieć co najmniej 4 znaki/liczby.",code_mismatch:"Kody nie pasują do siebie."}},edit_user:{title:"Edytuj użytkownika",description:"Zmień konfigurację dla użytkownika ''{name}''.",fields:{old_code:{heading:"Obecny kod",description:"Obecny kod, pozostaw pusty, aby go nie zmieniać."}}}}},actions:{title:"Akcje",cards:{notifications:{title:"Powiadomienia",description:"Za pomocą tego panelu możesz zarządzać wysyłanymi powiadomieniami.",table:{no_items:"Nie utworzono jeszcze żadnych powiadomień.",no_area_warning:"Akcja nie jest przypisana do żadnego obszaru."},actions:{new_notification:"Nowe powiadomienie"}},actions:{description:"Ten panel może być użyty do przełączania urządzenia, gdy zmienia się stan alarmu.",table:{no_items:"Nie utworzono jeszcze żadnych akcji."},actions:{new_action:"Nowa akcja"}},new_notification:{title:"Konfiguruj powiadomienie",description:"Otrzymuj powiadomienie podczas uzbrajania/rozbrajania alarmu, aktywacji itp.",trigger:"Warunek",action:"Zadanie",options:"Opcje",fields:{event:{heading:"Zdarzenie",description:"Zdecyduj, kiedy powiadomienie powinno być wysłane",choose:{armed:{name:"Alarm jest uzbrojony",description:"Alarm został uzbrojony"},disarmed:{name:"Alarm jest rozbrojony",description:"Alarm został rozbrojony"},triggered:{name:"Wywołano alarm",description:"Alarm został wywołany"},untriggered:{name:"Zakończono alarm",description:"Alarm został zakończony"},arm_failure:{name:"Nie udało się uzbroić alarmu",description:"Uzbrojenie alarmu nie powiodło się z powodu co najmniej jednego otwartego czujnika"},arming:{name:"Rozpoczęto opóźnienie wyjścia",description:"Rozpoczęto opóźnienie wyjścia, wyjdź z domu."},pending:{name:"Rozpoczęto opóźnienie wejścia",description:"Rozpoczęto opóźnienie wejścia, alarm wkrótce będzie wywołany."}}},mode:{heading:"Tryb",description:"Ogranicz akcję do określonych trybów uzbrojenia (opcjonalnie)"},title:{heading:"Tytuł",description:"Tytuł powiadomienia"},message:{heading:"Wiadomość",description:"Treść powiadomienia",insert_wildcard:"Wstaw wildcard",placeholders:{armed:"Alarm jest ustawiony w trybie {{arm_mode}}",disarmed:"Alarm jest teraz wyłączony.",triggered:"Alarm został wyzwolony! Przyczyna: {{open_sensors}}.",untriggered:"Alarm nie jest już wyzwolony.",arm_failure:"Alarm nie mógł zostać uzbrojony w tej chwili z powodu: {{open_sensors}}.",arming:"Alarm wkrótce zostanie uzbrojony, wyjdż z domu.",pending:"Alarm wkrótce zostanie wywołany, rozbrój go!"}},open_sensors_format:{heading:"Format wildcard dla open_sensors",description:"Wybierz, które informacje o czujnikach są dodawane do wiadomości",options:{default:"Nazwy i stany",short:"Tylko nazwy"}},arm_mode_format:{heading:"Język dla wildcard arm_mode",description:"Wybierz, w jakim języku tryb uzbrojenia jest wstawiany do wiadomości"},target:{heading:"Urządzenie",description:"Urządzenie, do którego wysyłane będzie powiadomienie"},media_player_entity:{heading:"Encja odtwarzacza multimedialnego",description:"Wybierz głosnik, na którym będą odtwarzane wiadomości"},name:{heading:"Nazwa",description:"Opis dla tego powiadomienia",placeholders:{armed:"Powiadom {target} o uzbrojeniu",disarmed:"Powiadom {target} o rozbrojeniu",triggered:"Powiadom {target} o wywołaniu alarmu",untriggered:"Powiadom {target} o zakończeniu alarmu",arm_failure:"Powiadom {target} o niepowodzeniu",arming:"Powiadom {target} o opuszczeniu domu",pending:"Powiadom {target} o przybyciu do domu"}},delete:{heading:"Usuń automatyzację",description:"Trwale usuń tę automatyzację"}},actions:{test:"Wypróbuj"}},new_action:{title:"Konfiguruj akcję",description:"Przełącz światła lub urządzenia (takie jak syreny) podczas uzbrajania/rozbrajania alarmu, aktywacji itp.",fields:{event:{heading:"Zdarzenie",description:"Kiedy akcja powinna być wykonana"},area:{heading:"Strefa",description:"Strefa alarmu, dla którego zdarzenie ma zastosowanie."},mode:{heading:"Tryb",description:"Ogranicz akcję do określonych trybów uzbrojenia (opcjonalnie)"},entity:{heading:"Encja",description:"Encja, na której ma być wykonana akcja"},action:{heading:"Akcja",description:"Akcja do wykonania na encji",no_common_actions:"Akcje mogą być przypisane tylko w trybie YAML dla wybranych encji."},name:{heading:"Nazwa",description:"Opis dla tej akcji",placeholders:{armed:"Ustaw {entity} na {state} podczas uzbrajania alarmu",disarmed:"Ustaw {entity} na {state} podczas rozbrajania alarmu",triggered:"Ustaw {entity} na {state} gdy wywołano alarm",untriggered:"Ustaw {entity} na {state} gdy rozbroisz alarm",arm_failure:"Ustaw {entity} na {state} nie uda się rozbroić alarmu",arming:"Ustaw {entity} na {state} gdy opuszczasz dom",pending:"Ustaw {entity} na {state} gdy wchodzisz do domu"}}}}}}},Rt={common:Bt,components:qt,title:Vt,panels:It},Ut=Object.freeze({__proto__:null,common:Bt,components:qt,default:Rt,panels:It,title:Vt}),Gt={modes_short:{armed_away:"Ausente",armed_home:"Em casa",armed_night:"Noturno",armed_custom_bypass:"Personalizado",armed_vacation:"Férias"},enabled:"Ativado",disabled:"Desativado"},Kt={time_picker:{seconds:"segundos",minutes:"minutos"},editor:{ui_mode:"Para UI",yaml_mode:"Para YAML",edit_in_yaml:"Editar em YAML"},table:{filter:{label:"Filtrar itens",item:"Filtrar por {name}",hidden_items:"{number} {number, plural,\n one {item está}\n other {itens estão}\n} oculto"}}},Ft="Painel de alarme",Zt={general:{title:"Geral",cards:{general:{description:"Este painel define algumas configurações globais para o alarme.",fields:{disarm_after_trigger:{heading:"Desarmar após o disparo",description:"Desarmar o alarme automaticamente em vez de retornar ao estado armado."},ignore_blocking_sensors_after_trigger:{heading:"Ignorar sensores de bloqueio ao rearmar",description:"Retornar ao estado armado sem verificar se há sensores que ainda podem estar ativos."},enable_mqtt:{heading:"Ativar MQTT",description:"Permitir que o painel de alarme seja controlado via MQTT."},enable_master:{heading:"Ativar alarme mestre",description:"Cria uma entidade para controlar todas as áreas simultaneamente."}},actions:{setup_mqtt:"Configuração MQTT",setup_master:"Configuração mestre"}},modes:{title:"Modos",description:"Este painel pode ser usado para configurar os modos de armamento do alarme.",modes:{armed_away:"Armado ausente será usado quando todas as pessoas saírem de casa. Todas as portas e janelas que permitem acesso à casa serão monitoradas, assim como os sensores de movimento dentro da casa.",armed_home:"Armado em casa (também conhecido como estadia armada) será usado ao ativar o alarme enquanto há pessoas em casa. Todas as portas e janelas que permitem acesso à casa serão monitoradas, mas não os sensores de movimento dentro da casa.",armed_night:"Armado noturno será usado ao configurar o alarme antes de dormir. Todas as portas e janelas que permitem acesso à casa serão monitoradas, além de sensores de movimento selecionados dentro da casa.",armed_vacation:"Armado férias pode ser usado como extensão do modo armado ausente em caso de ausência prolongada. Os tempos de atraso e as respostas ao disparo podem ser adaptados conforme desejado.",armed_custom_bypass:"Um modo extra para definir seu próprio perímetro de segurança."},number_sensors_active:"{number} {number, plural,\n one {sensor}\n other {sensores}\n} ativo",fields:{status:{heading:"Estado",description:"Controla se o alarme pode ser armado neste modo."},exit_delay:{heading:"Atraso de saída",description:"Ao armar o alarme, dentro deste período os sensores ainda não dispararão o alarme."},entry_delay:{heading:"Atraso de entrada",description:"Tempo de atraso até que o alarme seja disparado após um dos sensores ser ativado."},trigger_time:{heading:"Tempo de disparo",description:"Tempo durante o qual o alarme permanecerá no estado disparado após a ativação."}}},mqtt:{title:"Configuração MQTT",description:"Este painel pode ser usado para configurar a interface MQTT.",fields:{state_topic:{heading:"Tópico de estado",description:"Tópico no qual as atualizações de estado são publicadas."},event_topic:{heading:"Tópico de evento",description:"Tópico no qual os eventos de alarme são publicados."},command_topic:{heading:"Tópico de comando",description:"Tópico que o Alarmo monitora para comandos de armar/desarmar."},require_code:{heading:"Exigir código",description:"Exige que o código seja enviado junto com o comando."},state_payload:{heading:"Configurar payload por estado",item:"Definir um payload para o estado ''{state}''"},command_payload:{heading:"Configurar payload por comando",item:"Definir um payload para o comando ''{command}''"}}},areas:{title:"Áreas",description:"As áreas podem ser usadas para dividir o sistema de alarme em múltiplos compartimentos.",no_items:"Não há áreas definidas ainda.",table:{remarks:"Observações",summary:"Esta área contém {summary_sensors} e {summary_automations}.",summary_sensors:"{number} {number, plural,\n one {sensor}\n other {sensores}\n}",summary_automations:"{number} {number, plural,\n one {automação}\n other {automações}\n}"},actions:{add:"Adicionar"}}},dialogs:{create_area:{title:"Nova área",fields:{copy_from:"Copiar configurações de"}},edit_area:{title:"Editando área ''{area}''",name_warning:"Nota: alterar o nome irá alterar o ID da entidade."},remove_area:{title:"Remover área?",description:"Tem certeza que deseja remover esta área? Esta área contém {sensors} sensores e {automations} automações, que também serão removidos."},edit_master:{title:"Configuração mestre"},disable_master:{title:"Desativar mestre?",description:"Tem certeza que deseja remover o alarme mestre? Esta área contém {automations} automações, que serão removidas com esta ação."}}},sensors:{title:"Sensores",cards:{sensors:{description:"Sensores configurados atualmente. Clique em um item para fazer alterações.",table:{no_items:"Não há sensores para exibir aqui.",no_area_warning:"O sensor não está atribuído a nenhuma área.",arm_modes:"Modos de armamento",always_on:"(Sempre)"}},add_sensors:{title:"Adicionar sensores",description:"Adicione mais sensores. Certifique-se de que seus sensores tenham um nome adequado para que você possa identificá-los.",no_items:"Não há entidades do HA disponíveis para configurar no alarme. Certifique-se de incluir entidades do tipo binary_sensor.",table:{type:"Tipo detectado"},actions:{add_to_alarm:"Adicionar ao alarme",filter_supported:"Ocultar itens com tipo desconhecido"}},editor:{title:"Editar sensor",description:"Configurando os ajustes do sensor ''{entity}''.",fields:{entity:{heading:"Entidade",description:"Entidade associada a este sensor."},area:{heading:"Área",description:"Selecione uma área que contenha este sensor."},group:{heading:"Grupo",description:"Agrupar com outros sensores para disparo combinado."},device_type:{heading:"Tipo de dispositivo",description:"Escolha um tipo de dispositivo para aplicar automaticamente as configurações adequadas.",choose:{door:{name:"Porta",description:"Uma porta, portão ou outra entrada usada para entrar/sair de casa."},window:{name:"Janela",description:"Uma janela ou porta não usada para entrar em casa, como uma varanda."},motion:{name:"Movimento",description:"Sensor de presença ou dispositivo similar com atraso entre ativações."},tamper:{name:"Adulteração",description:"Detector de remoção da tampa do sensor, sensor de quebra de vidro, etc."},environmental:{name:"Ambiental",description:"Sensor de fumaça/gás, detector de vazamento, etc. (não relacionado à proteção contra invasão)."},other:{name:"Genérico"}}},always_on:{heading:"Sempre ativo",description:"O sensor deve sempre disparar o alarme."},modes:{heading:"Modos ativados",description:"Modos de alarme nos quais este sensor está ativo."},arm_on_close:{heading:"Armar ao fechar",description:"Após a desativação deste sensor, o atraso de saída restante será ignorado automaticamente."},use_exit_delay:{heading:"Usar atraso de saída",description:"O sensor pode estar ativo quando o atraso de saída começar."},use_entry_delay:{heading:"Usar atraso de entrada",description:"A ativação do sensor dispara o alarme após o atraso de entrada, e não imediatamente."},entry_delay:{heading:"Atraso de entrada",description:"Substituir o atraso de entrada (configurado para o modo de armamento) por um atraso específico para o sensor."},delay_on:{heading:"Atraso de ativação",description:"O sensor deve permanecer ativo por esta duração antes de disparar. Reinicia se o sensor desativar."},allow_open:{heading:"Permitir aberto inicialmente",description:"O estado aberto durante o armamento é ignorado (ativação subsequente do sensor disparará o alarme)."},auto_bypass:{heading:"Ignorar automaticamente",description:"Excluir este sensor do alarme se estiver aberto durante o armamento.",modes:"Modos nos quais o sensor pode ser ignorado"},trigger_unavailable:{heading:"Disparar quando indisponível",description:"Quando o estado do sensor se torna 'indisponível', isso ativará o sensor."}},actions:{toggle_advanced:"Configurações avançadas",remove:"Remover",setup_groups:"Configurar grupos"},errors:{description:"Por favor, corrija os seguintes erros:",no_area:"Nenhuma área selecionada",no_modes:"Nenhum modo selecionado para o qual o sensor deve estar ativo",no_auto_bypass_modes:"Nenhum modo selecionado para o qual o sensor pode ser ignorado automaticamente"}}},dialogs:{manage_groups:{title:"Gerenciar grupos de sensores",description:"Em um grupo de sensores, múltiplos sensores devem ser ativados dentro de um período de tempo antes que o alarme seja disparado.",no_items:"Nenhum grupo ainda",actions:{new_group:"Novo grupo"}},create_group:{title:"Novo grupo de sensores",fields:{name:{heading:"Nome",description:"Nome para o grupo de sensores."},timeout:{heading:"Tempo limite",description:"Período de tempo durante o qual ativações consecutivas de sensores disparam o alarme."},event_count:{heading:"Contagem",description:"Quantidade de sensores diferentes que precisam ser ativados para disparar o alarme."},sensors:{heading:"Sensores",description:"Selecione os sensores que fazem parte deste grupo."}},errors:{invalid_name:"Nome fornecido inválido.",insufficient_sensors:"Pelo menos 2 sensores precisam ser selecionados."}},edit_group:{title:"Editar grupo de sensores ''{name}''"}}},codes:{title:"Códigos",cards:{codes:{description:"Alterar as configurações do código.",fields:{code_arm_required:{heading:"Exigir código para armar",description:"Um código válido deve ser fornecido para armar o alarme."},code_disarm_required:{heading:"Exigir código para desarmar",description:"Um código válido deve ser fornecido para desarmar o alarme."},code_mode_change_required:{heading:"Exigir código para trocar de modo",description:"Um código válido deve ser fornecido para alterar o modo de armamento ativo."},code_format:{heading:"Formato do código",description:"Define o tipo de entrada para o card de alarme do Lovelace.",code_format_number:"PIN",code_format_text:"Senha"}}},user_management:{title:"Gerenciamento de usuários",description:"Cada usuário tem seu próprio código para armar/desarmar o alarme.",no_items:"Não há usuários ainda",actions:{new_user:"Novo usuário"}},new_user:{title:"Criar novo usuário",description:"Usuários podem ser criados para fornecer acesso à operação do alarme.",fields:{name:{heading:"Nome",description:"Nome do usuário."},code:{heading:"Código",description:"Código para este usuário."},confirm_code:{heading:"Confirmar código",description:"Repita o código."},can_arm:{heading:"Permitir código para armar",description:"Inserir este código ativa o alarme."},can_disarm:{heading:"Permitir código para desarmar",description:"Inserir este código desativa o alarme."},is_override_code:{heading:"É código de substituição",description:"Inserir este código irá forçar o armamento do alarme."},area_limit:{heading:"Áreas restritas",description:"Limitar o usuário a controlar apenas as áreas selecionadas."}},errors:{no_name:"Nenhum nome fornecido.",no_code:"O código deve ter no mínimo 4 caracteres/números.",code_mismatch:"Os códigos não coincidem."}},edit_user:{title:"Editar usuário",description:"Alterar configurações do usuário ''{name}''.",fields:{old_code:{heading:"Código atual",description:"Código atual, deixe em branco para não alterar."}}}}},actions:{title:"Ações",cards:{notifications:{title:"Notificações",description:"Neste painel você pode gerenciar notificações a serem enviadas quando um determinado evento de alarme ocorrer.",table:{no_items:"Não há notificações criadas ainda.",no_area_warning:"A ação não está atribuída a nenhuma área."},actions:{new_notification:"Nova notificação"}},actions:{description:"Este painel pode ser usado para acionar um dispositivo quando o estado do alarme mudar.",table:{no_items:"Não há ações criadas ainda."},actions:{new_action:"Nova ação"}},new_notification:{title:"Configurar notificação",description:"Receba uma notificação ao armar/desarmar o alarme, na ativação, etc.",trigger:"Condição",action:"Tarefa",options:"Opções",fields:{event:{heading:"Evento",description:"Quando a notificação deve ser enviada.",choose:{armed:{name:"Alarme está armado",description:"O alarme foi armado com sucesso."},disarmed:{name:"Alarme está desarmado",description:"O alarme está desarmado."},triggered:{name:"Alarme foi disparado",description:"O alarme foi disparado."},untriggered:{name:"Alarme não está mais disparado",description:"O estado de disparo do alarme terminou."},arm_failure:{name:"Falha ao armar",description:"O armamento do alarme falhou devido a um ou mais sensores abertos."},arming:{name:"Atraso de saída iniciado",description:"Atraso de saída iniciado, pronto para sair de casa."},pending:{name:"Atraso de entrada iniciado",description:"Atraso de entrada iniciado, o alarme será disparado em breve."}}},mode:{heading:"Modo",description:"Limitar a ação a modos de armamento específicos (opcional)."},title:{heading:"Título",description:"Título da mensagem de notificação."},message:{heading:"Mensagem",description:"Conteúdo da mensagem de notificação.",insert_wildcard:"Inserir curinga",placeholders:{armed:"O alarme está configurado para {{arm_mode}}",disarmed:"O alarme está DESLIGADO",triggered:"O alarme foi disparado! Causa: {{open_sensors}}.",untriggered:"O alarme não está mais disparado.",arm_failure:"O alarme não pôde ser armado agora, devido a: {{open_sensors}}.",arming:"O alarme será armado em breve, por favor saia de casa.",pending:"O alarme está prestes a disparar, desarme rapidamente!"}},open_sensors_format:{heading:"Formato para o curinga open_sensors",description:"Escolha quais informações do sensor são inseridas na mensagem.",options:{default:"Nomes e estados",short:"Apenas nomes"}},arm_mode_format:{heading:"Tradução para o curinga arm_mode",description:"Escolha em qual idioma o modo de armamento é inserido na mensagem."},target:{heading:"Destino",description:"Dispositivo para enviar a notificação."},media_player_entity:{heading:"Entidade de media player",description:"Media player para reproduzir a mensagem."},name:{heading:"Nome",description:"Descrição para esta notificação.",placeholders:{armed:"Notificar {target} ao armar",disarmed:"Notificar {target} ao desarmar",triggered:"Notificar {target} quando disparado",untriggered:"Notificar {target} quando o disparo parar",arm_failure:"Notificar {target} em caso de falha",arming:"Notificar {target} ao sair",pending:"Notificar {target} ao chegar"}},delete:{heading:"Excluir automação",description:"Remover permanentemente esta automação."}},actions:{test:"Testar"}},new_action:{title:"Configurar ação",description:"Acionar luzes ou dispositivos (como sirenes) ao armar/desarmar o alarme, na ativação, etc.",fields:{event:{heading:"Evento",description:"Quando a ação deve ser executada."},area:{heading:"Área",description:"Área do alarme para a qual o evento se aplica."},mode:{heading:"Modo",description:"Limitar a ação a modos de armamento específicos (opcional)."},entity:{heading:"Entidade",description:"Entidade na qual realizar a ação."},action:{heading:"Ação",description:"Ação a realizar na entidade.",no_common_actions:"As ações só podem ser atribuídas em modo YAML para as entidades selecionadas."},name:{heading:"Nome",description:"Descrição para esta ação.",placeholders:{armed:"Definir {entity} para {state} ao armar",disarmed:"Definir {entity} para {state} ao desarmar",triggered:"Definir {entity} para {state} quando disparado",untriggered:"Definir {entity} para {state} quando o disparo parar",arm_failure:"Definir {entity} para {state} em caso de falha",arming:"Definir {entity} para {state} ao sair",pending:"Definir {entity} para {state} ao chegar"}}}}}}},Qt={common:Gt,components:Kt,title:Ft,panels:Zt},Yt=Object.freeze({__proto__:null,common:Gt,components:Kt,default:Qt,panels:Zt,title:Ft});function Wt(e,a){var t=a&&a.cache?a.cache:oi,i=a&&a.serializer?a.serializer:si;return(a&&a.strategy?a.strategy:ai)(e,{cache:t,serializer:i})}function Xt(e,a,t,i){var n,s=null==(n=i)||"number"==typeof n||"boolean"==typeof n?i:t(i),r=a.get(s);return void 0===r&&(r=e.call(this,i),a.set(s,r)),r}function Jt(e,a,t){var i=Array.prototype.slice.call(arguments,3),n=t(i),s=a.get(n);return void 0===s&&(s=e.apply(this,i),a.set(n,s)),s}function ei(e,a,t,i,n){return t.bind(a,e,i,n)}function ai(e,a){return ei(e,this,1===e.length?Xt:Jt,a.cache.create(),a.serializer)}var ti,ii,ni,si=function(){return JSON.stringify(arguments)},ri=function(){function e(){this.cache=Object.create(null)}return e.prototype.get=function(e){return this.cache[e]},e.prototype.set=function(e,a){this.cache[e]=a},e}(),oi={create:function(){return new ri}},di={variadic:function(e,a){return ei(e,this,Jt,a.cache.create(),a.serializer)}};function li(e){return e.type===ii.literal}function ci(e){return e.type===ii.argument}function mi(e){return e.type===ii.number}function hi(e){return e.type===ii.date}function pi(e){return e.type===ii.time}function gi(e){return e.type===ii.select}function ui(e){return e.type===ii.plural}function vi(e){return e.type===ii.pound}function _i(e){return e.type===ii.tag}function bi(e){return!(!e||"object"!=typeof e||e.type!==ni.number)}function fi(e){return!(!e||"object"!=typeof e||e.type!==ni.dateTime)}!function(e){e[e.EXPECT_ARGUMENT_CLOSING_BRACE=1]="EXPECT_ARGUMENT_CLOSING_BRACE",e[e.EMPTY_ARGUMENT=2]="EMPTY_ARGUMENT",e[e.MALFORMED_ARGUMENT=3]="MALFORMED_ARGUMENT",e[e.EXPECT_ARGUMENT_TYPE=4]="EXPECT_ARGUMENT_TYPE",e[e.INVALID_ARGUMENT_TYPE=5]="INVALID_ARGUMENT_TYPE",e[e.EXPECT_ARGUMENT_STYLE=6]="EXPECT_ARGUMENT_STYLE",e[e.INVALID_NUMBER_SKELETON=7]="INVALID_NUMBER_SKELETON",e[e.INVALID_DATE_TIME_SKELETON=8]="INVALID_DATE_TIME_SKELETON",e[e.EXPECT_NUMBER_SKELETON=9]="EXPECT_NUMBER_SKELETON",e[e.EXPECT_DATE_TIME_SKELETON=10]="EXPECT_DATE_TIME_SKELETON",e[e.UNCLOSED_QUOTE_IN_ARGUMENT_STYLE=11]="UNCLOSED_QUOTE_IN_ARGUMENT_STYLE",e[e.EXPECT_SELECT_ARGUMENT_OPTIONS=12]="EXPECT_SELECT_ARGUMENT_OPTIONS",e[e.EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE=13]="EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE",e[e.INVALID_PLURAL_ARGUMENT_OFFSET_VALUE=14]="INVALID_PLURAL_ARGUMENT_OFFSET_VALUE",e[e.EXPECT_SELECT_ARGUMENT_SELECTOR=15]="EXPECT_SELECT_ARGUMENT_SELECTOR",e[e.EXPECT_PLURAL_ARGUMENT_SELECTOR=16]="EXPECT_PLURAL_ARGUMENT_SELECTOR",e[e.EXPECT_SELECT_ARGUMENT_SELECTOR_FRAGMENT=17]="EXPECT_SELECT_ARGUMENT_SELECTOR_FRAGMENT",e[e.EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT=18]="EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT",e[e.INVALID_PLURAL_ARGUMENT_SELECTOR=19]="INVALID_PLURAL_ARGUMENT_SELECTOR",e[e.DUPLICATE_PLURAL_ARGUMENT_SELECTOR=20]="DUPLICATE_PLURAL_ARGUMENT_SELECTOR",e[e.DUPLICATE_SELECT_ARGUMENT_SELECTOR=21]="DUPLICATE_SELECT_ARGUMENT_SELECTOR",e[e.MISSING_OTHER_CLAUSE=22]="MISSING_OTHER_CLAUSE",e[e.INVALID_TAG=23]="INVALID_TAG",e[e.INVALID_TAG_NAME=25]="INVALID_TAG_NAME",e[e.UNMATCHED_CLOSING_TAG=26]="UNMATCHED_CLOSING_TAG",e[e.UNCLOSED_TAG=27]="UNCLOSED_TAG"}(ti||(ti={})),function(e){e[e.literal=0]="literal",e[e.argument=1]="argument",e[e.number=2]="number",e[e.date=3]="date",e[e.time=4]="time",e[e.select=5]="select",e[e.plural=6]="plural",e[e.pound=7]="pound",e[e.tag=8]="tag"}(ii||(ii={})),function(e){e[e.number=0]="number",e[e.dateTime=1]="dateTime"}(ni||(ni={}));var yi=/[ \xA0\u1680\u2000-\u200A\u202F\u205F\u3000]/,ki=/(?:[Eec]{1,6}|G{1,5}|[Qq]{1,5}|(?:[yYur]+|U{1,5})|[ML]{1,5}|d{1,2}|D{1,3}|F{1}|[abB]{1,5}|[hkHK]{1,2}|w{1,2}|W{1}|m{1,2}|s{1,2}|[zZOvVxX]{1,4})(?=([^']*'[^']*')*[^']*$)/g;function wi(e){var a={};return e.replace(ki,function(e){var t=e.length;switch(e[0]){case"G":a.era=4===t?"long":5===t?"narrow":"short";break;case"y":a.year=2===t?"2-digit":"numeric";break;case"Y":case"u":case"U":case"r":throw new RangeError("`Y/u/U/r` (year) patterns are not supported, use `y` instead");case"q":case"Q":throw new RangeError("`q/Q` (quarter) patterns are not supported");case"M":case"L":a.month=["numeric","2-digit","short","long","narrow"][t-1];break;case"w":case"W":throw new RangeError("`w/W` (week) patterns are not supported");case"d":a.day=["numeric","2-digit"][t-1];break;case"D":case"F":case"g":throw new RangeError("`D/F/g` (day) patterns are not supported, use `d` instead");case"E":a.weekday=4===t?"long":5===t?"narrow":"short";break;case"e":if(t<4)throw new RangeError("`e..eee` (weekday) patterns are not supported");a.weekday=["short","long","narrow","short"][t-4];break;case"c":if(t<4)throw new RangeError("`c..ccc` (weekday) patterns are not supported");a.weekday=["short","long","narrow","short"][t-4];break;case"a":a.hour12=!0;break;case"b":case"B":throw new RangeError("`b/B` (period) patterns are not supported, use `a` instead");case"h":a.hourCycle="h12",a.hour=["numeric","2-digit"][t-1];break;case"H":a.hourCycle="h23",a.hour=["numeric","2-digit"][t-1];break;case"K":a.hourCycle="h11",a.hour=["numeric","2-digit"][t-1];break;case"k":a.hourCycle="h24",a.hour=["numeric","2-digit"][t-1];break;case"j":case"J":case"C":throw new RangeError("`j/J/C` (hour) patterns are not supported, use `h/H/K/k` instead");case"m":a.minute=["numeric","2-digit"][t-1];break;case"s":a.second=["numeric","2-digit"][t-1];break;case"S":case"A":throw new RangeError("`S/A` (second) patterns are not supported, use `s` instead");case"z":a.timeZoneName=t<4?"short":"long";break;case"Z":case"O":case"v":case"V":case"X":case"x":throw new RangeError("`Z/O/v/V/X/x` (timeZone) patterns are not supported, use `z` instead")}return""}),a}var zi=/[\t-\r \x85\u200E\u200F\u2028\u2029]/i;function Ai(e){return e.replace(/^(.*?)-/,"")}var ji=/^\.(?:(0+)(\*)?|(#+)|(0+)(#+))$/g,$i=/^(@+)?(\+|#+)?[rs]?$/g,Ti=/(\*)(0+)|(#+)(0+)|(0+)/g,Ei=/^(0+)$/;function Si(e){var a={};return"r"===e[e.length-1]?a.roundingPriority="morePrecision":"s"===e[e.length-1]&&(a.roundingPriority="lessPrecision"),e.replace($i,function(e,t,i){return"string"!=typeof i?(a.minimumSignificantDigits=t.length,a.maximumSignificantDigits=t.length):"+"===i?a.minimumSignificantDigits=t.length:"#"===t[0]?a.maximumSignificantDigits=t.length:(a.minimumSignificantDigits=t.length,a.maximumSignificantDigits=t.length+("string"==typeof i?i.length:0)),""}),a}function Ci(e){switch(e){case"sign-auto":return{signDisplay:"auto"};case"sign-accounting":case"()":return{currencySign:"accounting"};case"sign-always":case"+!":return{signDisplay:"always"};case"sign-accounting-always":case"()!":return{signDisplay:"always",currencySign:"accounting"};case"sign-except-zero":case"+?":return{signDisplay:"exceptZero"};case"sign-accounting-except-zero":case"()?":return{signDisplay:"exceptZero",currencySign:"accounting"};case"sign-never":case"+_":return{signDisplay:"never"}}}function Oi(e){var a;if("E"===e[0]&&"E"===e[1]?(a={notation:"engineering"},e=e.slice(2)):"E"===e[0]&&(a={notation:"scientific"},e=e.slice(1)),a){var t=e.slice(0,2);if("+!"===t?(a.signDisplay="always",e=e.slice(2)):"+?"===t&&(a.signDisplay="exceptZero",e=e.slice(2)),!Ei.test(e))throw new Error("Malformed concise eng/scientific notation");a.minimumIntegerDigits=e.length}return a}function Mi(e){var a=Ci(e);return a||{}}function xi(e){for(var a={},i=0,n=e;i1)throw new RangeError("integer-width stems only accept a single optional option");s.options[0].replace(Ti,function(e,t,i,n,s,r){if(t)a.minimumIntegerDigits=i.length;else{if(n&&s)throw new Error("We currently do not support maximum integer digits");if(r)throw new Error("We currently do not support exact integer digits")}return""});continue}if(Ei.test(s.stem))a.minimumIntegerDigits=s.stem.length;else if(ji.test(s.stem)){if(s.options.length>1)throw new RangeError("Fraction-precision stems only accept a single optional option");s.stem.replace(ji,function(e,t,i,n,s,r){return"*"===i?a.minimumFractionDigits=t.length:n&&"#"===n[0]?a.maximumFractionDigits=n.length:s&&r?(a.minimumFractionDigits=s.length,a.maximumFractionDigits=s.length+r.length):(a.minimumFractionDigits=t.length,a.maximumFractionDigits=t.length),""});var r=s.options[0];"w"===r?a=t(t({},a),{trailingZeroDisplay:"stripIfInteger"}):r&&(a=t(t({},a),Si(r)))}else if($i.test(s.stem))a=t(t({},a),Si(s.stem));else{var o=Ci(s.stem);o&&(a=t(t({},a),o));var d=Oi(s.stem);d&&(a=t(t({},a),d))}}return a}var Ni,Li={"001":["H","h"],419:["h","H","hB","hb"],AC:["H","h","hb","hB"],AD:["H","hB"],AE:["h","hB","hb","H"],AF:["H","hb","hB","h"],AG:["h","hb","H","hB"],AI:["H","h","hb","hB"],AL:["h","H","hB"],AM:["H","hB"],AO:["H","hB"],AR:["h","H","hB","hb"],AS:["h","H"],AT:["H","hB"],AU:["h","hb","H","hB"],AW:["H","hB"],AX:["H"],AZ:["H","hB","h"],BA:["H","hB","h"],BB:["h","hb","H","hB"],BD:["h","hB","H"],BE:["H","hB"],BF:["H","hB"],BG:["H","hB","h"],BH:["h","hB","hb","H"],BI:["H","h"],BJ:["H","hB"],BL:["H","hB"],BM:["h","hb","H","hB"],BN:["hb","hB","h","H"],BO:["h","H","hB","hb"],BQ:["H"],BR:["H","hB"],BS:["h","hb","H","hB"],BT:["h","H"],BW:["H","h","hb","hB"],BY:["H","h"],BZ:["H","h","hb","hB"],CA:["h","hb","H","hB"],CC:["H","h","hb","hB"],CD:["hB","H"],CF:["H","h","hB"],CG:["H","hB"],CH:["H","hB","h"],CI:["H","hB"],CK:["H","h","hb","hB"],CL:["h","H","hB","hb"],CM:["H","h","hB"],CN:["H","hB","hb","h"],CO:["h","H","hB","hb"],CP:["H"],CR:["h","H","hB","hb"],CU:["h","H","hB","hb"],CV:["H","hB"],CW:["H","hB"],CX:["H","h","hb","hB"],CY:["h","H","hb","hB"],CZ:["H"],DE:["H","hB"],DG:["H","h","hb","hB"],DJ:["h","H"],DK:["H"],DM:["h","hb","H","hB"],DO:["h","H","hB","hb"],DZ:["h","hB","hb","H"],EA:["H","h","hB","hb"],EC:["h","H","hB","hb"],EE:["H","hB"],EG:["h","hB","hb","H"],EH:["h","hB","hb","H"],ER:["h","H"],ES:["H","hB","h","hb"],ET:["hB","hb","h","H"],FI:["H"],FJ:["h","hb","H","hB"],FK:["H","h","hb","hB"],FM:["h","hb","H","hB"],FO:["H","h"],FR:["H","hB"],GA:["H","hB"],GB:["H","h","hb","hB"],GD:["h","hb","H","hB"],GE:["H","hB","h"],GF:["H","hB"],GG:["H","h","hb","hB"],GH:["h","H"],GI:["H","h","hb","hB"],GL:["H","h"],GM:["h","hb","H","hB"],GN:["H","hB"],GP:["H","hB"],GQ:["H","hB","h","hb"],GR:["h","H","hb","hB"],GT:["h","H","hB","hb"],GU:["h","hb","H","hB"],GW:["H","hB"],GY:["h","hb","H","hB"],HK:["h","hB","hb","H"],HN:["h","H","hB","hb"],HR:["H","hB"],HU:["H","h"],IC:["H","h","hB","hb"],ID:["H"],IE:["H","h","hb","hB"],IL:["H","hB"],IM:["H","h","hb","hB"],IN:["h","H"],IO:["H","h","hb","hB"],IQ:["h","hB","hb","H"],IR:["hB","H"],IS:["H"],IT:["H","hB"],JE:["H","h","hb","hB"],JM:["h","hb","H","hB"],JO:["h","hB","hb","H"],JP:["H","K","h"],KE:["hB","hb","H","h"],KG:["H","h","hB","hb"],KH:["hB","h","H","hb"],KI:["h","hb","H","hB"],KM:["H","h","hB","hb"],KN:["h","hb","H","hB"],KP:["h","H","hB","hb"],KR:["h","H","hB","hb"],KW:["h","hB","hb","H"],KY:["h","hb","H","hB"],KZ:["H","hB"],LA:["H","hb","hB","h"],LB:["h","hB","hb","H"],LC:["h","hb","H","hB"],LI:["H","hB","h"],LK:["H","h","hB","hb"],LR:["h","hb","H","hB"],LS:["h","H"],LT:["H","h","hb","hB"],LU:["H","h","hB"],LV:["H","hB","hb","h"],LY:["h","hB","hb","H"],MA:["H","h","hB","hb"],MC:["H","hB"],MD:["H","hB"],ME:["H","hB","h"],MF:["H","hB"],MG:["H","h"],MH:["h","hb","H","hB"],MK:["H","h","hb","hB"],ML:["H"],MM:["hB","hb","H","h"],MN:["H","h","hb","hB"],MO:["h","hB","hb","H"],MP:["h","hb","H","hB"],MQ:["H","hB"],MR:["h","hB","hb","H"],MS:["H","h","hb","hB"],MT:["H","h"],MU:["H","h"],MV:["H","h"],MW:["h","hb","H","hB"],MX:["h","H","hB","hb"],MY:["hb","hB","h","H"],MZ:["H","hB"],NA:["h","H","hB","hb"],NC:["H","hB"],NE:["H"],NF:["H","h","hb","hB"],NG:["H","h","hb","hB"],NI:["h","H","hB","hb"],NL:["H","hB"],NO:["H","h"],NP:["H","h","hB"],NR:["H","h","hb","hB"],NU:["H","h","hb","hB"],NZ:["h","hb","H","hB"],OM:["h","hB","hb","H"],PA:["h","H","hB","hb"],PE:["h","H","hB","hb"],PF:["H","h","hB"],PG:["h","H"],PH:["h","hB","hb","H"],PK:["h","hB","H"],PL:["H","h"],PM:["H","hB"],PN:["H","h","hb","hB"],PR:["h","H","hB","hb"],PS:["h","hB","hb","H"],PT:["H","hB"],PW:["h","H"],PY:["h","H","hB","hb"],QA:["h","hB","hb","H"],RE:["H","hB"],RO:["H","hB"],RS:["H","hB","h"],RU:["H"],RW:["H","h"],SA:["h","hB","hb","H"],SB:["h","hb","H","hB"],SC:["H","h","hB"],SD:["h","hB","hb","H"],SE:["H"],SG:["h","hb","H","hB"],SH:["H","h","hb","hB"],SI:["H","hB"],SJ:["H"],SK:["H"],SL:["h","hb","H","hB"],SM:["H","h","hB"],SN:["H","h","hB"],SO:["h","H"],SR:["H","hB"],SS:["h","hb","H","hB"],ST:["H","hB"],SV:["h","H","hB","hb"],SX:["H","h","hb","hB"],SY:["h","hB","hb","H"],SZ:["h","hb","H","hB"],TA:["H","h","hb","hB"],TC:["h","hb","H","hB"],TD:["h","H","hB"],TF:["H","h","hB"],TG:["H","hB"],TH:["H","h"],TJ:["H","h"],TL:["H","hB","hb","h"],TM:["H","h"],TN:["h","hB","hb","H"],TO:["h","H"],TR:["H","hB"],TT:["h","hb","H","hB"],TW:["hB","hb","h","H"],TZ:["hB","hb","H","h"],UA:["H","hB","h"],UG:["hB","hb","H","h"],UM:["h","hb","H","hB"],US:["h","hb","H","hB"],UY:["h","H","hB","hb"],UZ:["H","hB","h"],VA:["H","h","hB"],VC:["h","hb","H","hB"],VE:["h","H","hB","hb"],VG:["h","hb","H","hB"],VI:["h","hb","H","hB"],VN:["H","h"],VU:["h","H"],WF:["H","hB"],WS:["h","H"],XK:["H","hB","h"],YE:["h","hB","hb","H"],YT:["H","hB"],ZA:["H","h","hb","hB"],ZM:["h","hb","H","hB"],ZW:["H","h"],"af-ZA":["H","h","hB","hb"],"ar-001":["h","hB","hb","H"],"ca-ES":["H","h","hB"],"en-001":["h","hb","H","hB"],"en-HK":["h","hb","H","hB"],"en-IL":["H","h","hb","hB"],"en-MY":["h","hb","H","hB"],"es-BR":["H","h","hB","hb"],"es-ES":["H","h","hB","hb"],"es-GQ":["H","h","hB","hb"],"fr-CA":["H","h","hB"],"gl-ES":["H","h","hB"],"gu-IN":["hB","hb","h","H"],"hi-IN":["hB","h","H"],"it-CH":["H","h","hB"],"it-IT":["H","h","hB"],"kn-IN":["hB","h","H"],"ml-IN":["hB","h","H"],"mr-IN":["hB","hb","h","H"],"pa-IN":["hB","hb","h","H"],"ta-IN":["hB","h","hb","H"],"te-IN":["hB","h","H"],"zu-ZA":["H","hB","hb","h"]};function Hi(e){var a=e.hourCycle;if(void 0===a&&e.hourCycles&&e.hourCycles.length&&(a=e.hourCycles[0]),a)switch(a){case"h24":return"k";case"h23":return"H";case"h12":return"h";case"h11":return"K";default:throw new Error("Invalid hourCycle")}var t,i=e.language;return"root"!==i&&(t=e.maximize().region),(Li[t||""]||Li[i||""]||Li["".concat(i,"-001")]||Li["001"])[0]}var Di=new RegExp("^".concat(yi.source,"*")),Pi=new RegExp("".concat(yi.source,"*$"));function Bi(e,a){return{start:e,end:a}}var qi=!!String.prototype.startsWith&&"_a".startsWith("a",1),Vi=!!String.fromCodePoint,Ii=!!Object.fromEntries,Ri=!!String.prototype.codePointAt,Ui=!!String.prototype.trimStart,Gi=!!String.prototype.trimEnd,Ki=!!Number.isSafeInteger?Number.isSafeInteger:function(e){return"number"==typeof e&&isFinite(e)&&Math.floor(e)===e&&Math.abs(e)<=9007199254740991},Fi=!0;try{Fi="a"===(null===(Ni=an("([^\\p{White_Space}\\p{Pattern_Syntax}]*)","yu").exec("a"))||void 0===Ni?void 0:Ni[0])}catch(V){Fi=!1}var Zi,Qi=qi?function(e,a,t){return e.startsWith(a,t)}:function(e,a,t){return e.slice(t,t+a.length)===a},Yi=Vi?String.fromCodePoint:function(){for(var e=[],a=0;as;){if((t=e[s++])>1114111)throw RangeError(t+" is not a valid code point");i+=t<65536?String.fromCharCode(t):String.fromCharCode(55296+((t-=65536)>>10),t%1024+56320)}return i},Wi=Ii?Object.fromEntries:function(e){for(var a={},t=0,i=e;t=t)){var i,n=e.charCodeAt(a);return n<55296||n>56319||a+1===t||(i=e.charCodeAt(a+1))<56320||i>57343?n:i-56320+(n-55296<<10)+65536}},Ji=Ui?function(e){return e.trimStart()}:function(e){return e.replace(Di,"")},en=Gi?function(e){return e.trimEnd()}:function(e){return e.replace(Pi,"")};function an(e,a){return new RegExp(e,a)}if(Fi){var tn=an("([^\\p{White_Space}\\p{Pattern_Syntax}]*)","yu");Zi=function(e,a){var t;return tn.lastIndex=a,null!==(t=tn.exec(e)[1])&&void 0!==t?t:""}}else Zi=function(e,a){for(var t=[];;){var i=Xi(e,a);if(void 0===i||dn(i)||ln(i))break;t.push(i),a+=i>=65536?2:1}return Yi.apply(void 0,t)};var nn,sn=function(){function e(e,a){void 0===a&&(a={}),this.message=e,this.position={offset:0,line:1,column:1},this.ignoreTag=!!a.ignoreTag,this.locale=a.locale,this.requiresOtherClause=!!a.requiresOtherClause,this.shouldParseSkeletons=!!a.shouldParseSkeletons}return e.prototype.parse=function(){if(0!==this.offset())throw Error("parser can only be used once");return this.parseMessage(0,"",!1)},e.prototype.parseMessage=function(e,a,t){for(var i=[];!this.isEOF();){var n=this.char();if(123===n){if((s=this.parseArgument(e,t)).err)return s;i.push(s.val)}else{if(125===n&&e>0)break;if(35!==n||"plural"!==a&&"selectordinal"!==a){if(60===n&&!this.ignoreTag&&47===this.peek()){if(t)break;return this.error(ti.UNMATCHED_CLOSING_TAG,Bi(this.clonePosition(),this.clonePosition()))}if(60===n&&!this.ignoreTag&&rn(this.peek()||0)){if((s=this.parseTag(e,a)).err)return s;i.push(s.val)}else{var s;if((s=this.parseLiteral(e,a)).err)return s;i.push(s.val)}}else{var r=this.clonePosition();this.bump(),i.push({type:ii.pound,location:Bi(r,this.clonePosition())})}}}return{val:i,err:null}},e.prototype.parseTag=function(e,a){var t=this.clonePosition();this.bump();var i=this.parseTagName();if(this.bumpSpace(),this.bumpIf("/>"))return{val:{type:ii.literal,value:"<".concat(i,"/>"),location:Bi(t,this.clonePosition())},err:null};if(this.bumpIf(">")){var n=this.parseMessage(e+1,a,!0);if(n.err)return n;var s=n.val,r=this.clonePosition();if(this.bumpIf("")?{val:{type:ii.tag,value:i,children:s,location:Bi(t,this.clonePosition())},err:null}:this.error(ti.INVALID_TAG,Bi(r,this.clonePosition())))}return this.error(ti.UNCLOSED_TAG,Bi(t,this.clonePosition()))}return this.error(ti.INVALID_TAG,Bi(t,this.clonePosition()))},e.prototype.parseTagName=function(){var e=this.offset();for(this.bump();!this.isEOF()&&on(this.char());)this.bump();return this.message.slice(e,this.offset())},e.prototype.parseLiteral=function(e,a){for(var t=this.clonePosition(),i="";;){var n=this.tryParseQuote(a);if(n)i+=n;else{var s=this.tryParseUnquoted(e,a);if(s)i+=s;else{var r=this.tryParseLeftAngleBracket();if(!r)break;i+=r}}}var o=Bi(t,this.clonePosition());return{val:{type:ii.literal,value:i,location:o},err:null}},e.prototype.tryParseLeftAngleBracket=function(){return this.isEOF()||60!==this.char()||!this.ignoreTag&&(rn(e=this.peek()||0)||47===e)?null:(this.bump(),"<");var e},e.prototype.tryParseQuote=function(e){if(this.isEOF()||39!==this.char())return null;switch(this.peek()){case 39:return this.bump(),this.bump(),"'";case 123:case 60:case 62:case 125:break;case 35:if("plural"===e||"selectordinal"===e)break;return null;default:return null}this.bump();var a=[this.char()];for(this.bump();!this.isEOF();){var t=this.char();if(39===t){if(39!==this.peek()){this.bump();break}a.push(39),this.bump()}else a.push(t);this.bump()}return Yi.apply(void 0,a)},e.prototype.tryParseUnquoted=function(e,a){if(this.isEOF())return null;var t=this.char();return 60===t||123===t||35===t&&("plural"===a||"selectordinal"===a)||125===t&&e>0?null:(this.bump(),Yi(t))},e.prototype.parseArgument=function(e,a){var t=this.clonePosition();if(this.bump(),this.bumpSpace(),this.isEOF())return this.error(ti.EXPECT_ARGUMENT_CLOSING_BRACE,Bi(t,this.clonePosition()));if(125===this.char())return this.bump(),this.error(ti.EMPTY_ARGUMENT,Bi(t,this.clonePosition()));var i=this.parseIdentifierIfPossible().value;if(!i)return this.error(ti.MALFORMED_ARGUMENT,Bi(t,this.clonePosition()));if(this.bumpSpace(),this.isEOF())return this.error(ti.EXPECT_ARGUMENT_CLOSING_BRACE,Bi(t,this.clonePosition()));switch(this.char()){case 125:return this.bump(),{val:{type:ii.argument,value:i,location:Bi(t,this.clonePosition())},err:null};case 44:return this.bump(),this.bumpSpace(),this.isEOF()?this.error(ti.EXPECT_ARGUMENT_CLOSING_BRACE,Bi(t,this.clonePosition())):this.parseArgumentOptions(e,a,i,t);default:return this.error(ti.MALFORMED_ARGUMENT,Bi(t,this.clonePosition()))}},e.prototype.parseIdentifierIfPossible=function(){var e=this.clonePosition(),a=this.offset(),t=Zi(this.message,a),i=a+t.length;return this.bumpTo(i),{value:t,location:Bi(e,this.clonePosition())}},e.prototype.parseArgumentOptions=function(e,a,i,n){var s,r=this.clonePosition(),o=this.parseIdentifierIfPossible().value,d=this.clonePosition();switch(o){case"":return this.error(ti.EXPECT_ARGUMENT_TYPE,Bi(r,d));case"number":case"date":case"time":this.bumpSpace();var l=null;if(this.bumpIf(",")){this.bumpSpace();var c=this.clonePosition();if((b=this.parseSimpleArgStyleIfPossible()).err)return b;if(0===(g=en(b.val)).length)return this.error(ti.EXPECT_ARGUMENT_STYLE,Bi(this.clonePosition(),this.clonePosition()));l={style:g,styleLocation:Bi(c,this.clonePosition())}}if((f=this.tryParseArgumentClose(n)).err)return f;var m=Bi(n,this.clonePosition());if(l&&Qi(null==l?void 0:l.style,"::",0)){var h=Ji(l.style.slice(2));if("number"===o)return(b=this.parseNumberSkeletonFromString(h,l.styleLocation)).err?b:{val:{type:ii.number,value:i,location:m,style:b.val},err:null};if(0===h.length)return this.error(ti.EXPECT_DATE_TIME_SKELETON,m);var p=h;this.locale&&(p=function(e,a){for(var t="",i=0;i>1),d=Hi(a);for("H"!=d&&"k"!=d||(o=0);o-- >0;)t+="a";for(;r-- >0;)t=d+t}else t+="J"===n?"H":n}return t}(h,this.locale));var g={type:ni.dateTime,pattern:p,location:l.styleLocation,parsedOptions:this.shouldParseSkeletons?wi(p):{}};return{val:{type:"date"===o?ii.date:ii.time,value:i,location:m,style:g},err:null}}return{val:{type:"number"===o?ii.number:"date"===o?ii.date:ii.time,value:i,location:m,style:null!==(s=null==l?void 0:l.style)&&void 0!==s?s:null},err:null};case"plural":case"selectordinal":case"select":var u=this.clonePosition();if(this.bumpSpace(),!this.bumpIf(","))return this.error(ti.EXPECT_SELECT_ARGUMENT_OPTIONS,Bi(u,t({},u)));this.bumpSpace();var v=this.parseIdentifierIfPossible(),_=0;if("select"!==o&&"offset"===v.value){if(!this.bumpIf(":"))return this.error(ti.EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE,Bi(this.clonePosition(),this.clonePosition()));var b;if(this.bumpSpace(),(b=this.tryParseDecimalInteger(ti.EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE,ti.INVALID_PLURAL_ARGUMENT_OFFSET_VALUE)).err)return b;this.bumpSpace(),v=this.parseIdentifierIfPossible(),_=b.val}var f,y=this.tryParsePluralOrSelectOptions(e,o,a,v);if(y.err)return y;if((f=this.tryParseArgumentClose(n)).err)return f;var k=Bi(n,this.clonePosition());return"select"===o?{val:{type:ii.select,value:i,options:Wi(y.val),location:k},err:null}:{val:{type:ii.plural,value:i,options:Wi(y.val),offset:_,pluralType:"plural"===o?"cardinal":"ordinal",location:k},err:null};default:return this.error(ti.INVALID_ARGUMENT_TYPE,Bi(r,d))}},e.prototype.tryParseArgumentClose=function(e){return this.isEOF()||125!==this.char()?this.error(ti.EXPECT_ARGUMENT_CLOSING_BRACE,Bi(e,this.clonePosition())):(this.bump(),{val:!0,err:null})},e.prototype.parseSimpleArgStyleIfPossible=function(){for(var e=0,a=this.clonePosition();!this.isEOF();){switch(this.char()){case 39:this.bump();var t=this.clonePosition();if(!this.bumpUntil("'"))return this.error(ti.UNCLOSED_QUOTE_IN_ARGUMENT_STYLE,Bi(t,this.clonePosition()));this.bump();break;case 123:e+=1,this.bump();break;case 125:if(!(e>0))return{val:this.message.slice(a.offset,this.offset()),err:null};e-=1;break;default:this.bump()}}return{val:this.message.slice(a.offset,this.offset()),err:null}},e.prototype.parseNumberSkeletonFromString=function(e,a){var t=[];try{t=function(e){if(0===e.length)throw new Error("Number skeleton cannot be empty");for(var a=e.split(zi).filter(function(e){return e.length>0}),t=[],i=0,n=a;i=48&&r<=57))break;n=!0,s=10*s+(r-48),this.bump()}var o=Bi(i,this.clonePosition());return n?Ki(s*=t)?{val:s,err:null}:this.error(a,o):this.error(e,o)},e.prototype.offset=function(){return this.position.offset},e.prototype.isEOF=function(){return this.offset()===this.message.length},e.prototype.clonePosition=function(){return{offset:this.position.offset,line:this.position.line,column:this.position.column}},e.prototype.char=function(){var e=this.position.offset;if(e>=this.message.length)throw Error("out of bound");var a=Xi(this.message,e);if(void 0===a)throw Error("Offset ".concat(e," is at invalid UTF-16 code unit boundary"));return a},e.prototype.error=function(e,a){return{val:null,err:{kind:e,message:this.message,location:a}}},e.prototype.bump=function(){if(!this.isEOF()){var e=this.char();10===e?(this.position.line+=1,this.position.column=1,this.position.offset+=1):(this.position.column+=1,this.position.offset+=e<65536?1:2)}},e.prototype.bumpIf=function(e){if(Qi(this.message,e,this.offset())){for(var a=0;a=0?(this.bumpTo(t),!0):(this.bumpTo(this.message.length),!1)},e.prototype.bumpTo=function(e){if(this.offset()>e)throw Error("targetOffset ".concat(e," must be greater than or equal to the current offset ").concat(this.offset()));for(e=Math.min(e,this.message.length);;){var a=this.offset();if(a===e)break;if(a>e)throw Error("targetOffset ".concat(e," is at invalid UTF-16 code unit boundary"));if(this.bump(),this.isEOF())break}},e.prototype.bumpSpace=function(){for(;!this.isEOF()&&dn(this.char());)this.bump()},e.prototype.peek=function(){if(this.isEOF())return null;var e=this.char(),a=this.offset(),t=this.message.charCodeAt(a+(e>=65536?2:1));return null!=t?t:null},e}();function rn(e){return e>=97&&e<=122||e>=65&&e<=90}function on(e){return 45===e||46===e||e>=48&&e<=57||95===e||e>=97&&e<=122||e>=65&&e<=90||183==e||e>=192&&e<=214||e>=216&&e<=246||e>=248&&e<=893||e>=895&&e<=8191||e>=8204&&e<=8205||e>=8255&&e<=8256||e>=8304&&e<=8591||e>=11264&&e<=12271||e>=12289&&e<=55295||e>=63744&&e<=64975||e>=65008&&e<=65533||e>=65536&&e<=983039}function dn(e){return e>=9&&e<=13||32===e||133===e||e>=8206&&e<=8207||8232===e||8233===e}function ln(e){return e>=33&&e<=35||36===e||e>=37&&e<=39||40===e||41===e||42===e||43===e||44===e||45===e||e>=46&&e<=47||e>=58&&e<=59||e>=60&&e<=62||e>=63&&e<=64||91===e||92===e||93===e||94===e||96===e||123===e||124===e||125===e||126===e||161===e||e>=162&&e<=165||166===e||167===e||169===e||171===e||172===e||174===e||176===e||177===e||182===e||187===e||191===e||215===e||247===e||e>=8208&&e<=8213||e>=8214&&e<=8215||8216===e||8217===e||8218===e||e>=8219&&e<=8220||8221===e||8222===e||8223===e||e>=8224&&e<=8231||e>=8240&&e<=8248||8249===e||8250===e||e>=8251&&e<=8254||e>=8257&&e<=8259||8260===e||8261===e||8262===e||e>=8263&&e<=8273||8274===e||8275===e||e>=8277&&e<=8286||e>=8592&&e<=8596||e>=8597&&e<=8601||e>=8602&&e<=8603||e>=8604&&e<=8607||8608===e||e>=8609&&e<=8610||8611===e||e>=8612&&e<=8613||8614===e||e>=8615&&e<=8621||8622===e||e>=8623&&e<=8653||e>=8654&&e<=8655||e>=8656&&e<=8657||8658===e||8659===e||8660===e||e>=8661&&e<=8691||e>=8692&&e<=8959||e>=8960&&e<=8967||8968===e||8969===e||8970===e||8971===e||e>=8972&&e<=8991||e>=8992&&e<=8993||e>=8994&&e<=9e3||9001===e||9002===e||e>=9003&&e<=9083||9084===e||e>=9085&&e<=9114||e>=9115&&e<=9139||e>=9140&&e<=9179||e>=9180&&e<=9185||e>=9186&&e<=9254||e>=9255&&e<=9279||e>=9280&&e<=9290||e>=9291&&e<=9311||e>=9472&&e<=9654||9655===e||e>=9656&&e<=9664||9665===e||e>=9666&&e<=9719||e>=9720&&e<=9727||e>=9728&&e<=9838||9839===e||e>=9840&&e<=10087||10088===e||10089===e||10090===e||10091===e||10092===e||10093===e||10094===e||10095===e||10096===e||10097===e||10098===e||10099===e||10100===e||10101===e||e>=10132&&e<=10175||e>=10176&&e<=10180||10181===e||10182===e||e>=10183&&e<=10213||10214===e||10215===e||10216===e||10217===e||10218===e||10219===e||10220===e||10221===e||10222===e||10223===e||e>=10224&&e<=10239||e>=10240&&e<=10495||e>=10496&&e<=10626||10627===e||10628===e||10629===e||10630===e||10631===e||10632===e||10633===e||10634===e||10635===e||10636===e||10637===e||10638===e||10639===e||10640===e||10641===e||10642===e||10643===e||10644===e||10645===e||10646===e||10647===e||10648===e||e>=10649&&e<=10711||10712===e||10713===e||10714===e||10715===e||e>=10716&&e<=10747||10748===e||10749===e||e>=10750&&e<=11007||e>=11008&&e<=11055||e>=11056&&e<=11076||e>=11077&&e<=11078||e>=11079&&e<=11084||e>=11085&&e<=11123||e>=11124&&e<=11125||e>=11126&&e<=11157||11158===e||e>=11159&&e<=11263||e>=11776&&e<=11777||11778===e||11779===e||11780===e||11781===e||e>=11782&&e<=11784||11785===e||11786===e||11787===e||11788===e||11789===e||e>=11790&&e<=11798||11799===e||e>=11800&&e<=11801||11802===e||11803===e||11804===e||11805===e||e>=11806&&e<=11807||11808===e||11809===e||11810===e||11811===e||11812===e||11813===e||11814===e||11815===e||11816===e||11817===e||e>=11818&&e<=11822||11823===e||e>=11824&&e<=11833||e>=11834&&e<=11835||e>=11836&&e<=11839||11840===e||11841===e||11842===e||e>=11843&&e<=11855||e>=11856&&e<=11857||11858===e||e>=11859&&e<=11903||e>=12289&&e<=12291||12296===e||12297===e||12298===e||12299===e||12300===e||12301===e||12302===e||12303===e||12304===e||12305===e||e>=12306&&e<=12307||12308===e||12309===e||12310===e||12311===e||12312===e||12313===e||12314===e||12315===e||12316===e||12317===e||e>=12318&&e<=12319||12320===e||12336===e||64830===e||64831===e||e>=65093&&e<=65094}function cn(e){e.forEach(function(e){if(delete e.location,gi(e)||ui(e))for(var a in e.options)delete e.options[a].location,cn(e.options[a].value);else mi(e)&&bi(e.style)||(hi(e)||pi(e))&&fi(e.style)?delete e.style.location:_i(e)&&cn(e.children)})}function mn(e,a){void 0===a&&(a={}),a=t({shouldParseSkeletons:!0,requiresOtherClause:!0},a);var i=new sn(e,a).parse();if(i.err){var n=SyntaxError(ti[i.err.kind]);throw n.location=i.err.location,n.originalMessage=i.err.message,n}return(null==a?void 0:a.captureLocation)||cn(i.val),i.val}!function(e){e.MISSING_VALUE="MISSING_VALUE",e.INVALID_VALUE="INVALID_VALUE",e.MISSING_INTL_API="MISSING_INTL_API"}(nn||(nn={}));var hn,pn=function(e){function t(a,t,i){var n=e.call(this,a)||this;return n.code=t,n.originalMessage=i,n}return a(t,e),t.prototype.toString=function(){return"[formatjs Error: ".concat(this.code,"] ").concat(this.message)},t}(Error),gn=function(e){function t(a,t,i,n){return e.call(this,'Invalid values for "'.concat(a,'": "').concat(t,'". Options are "').concat(Object.keys(i).join('", "'),'"'),nn.INVALID_VALUE,n)||this}return a(t,e),t}(pn),un=function(e){function t(a,t,i){return e.call(this,'Value for "'.concat(a,'" must be of type ').concat(t),nn.INVALID_VALUE,i)||this}return a(t,e),t}(pn),vn=function(e){function t(a,t){return e.call(this,'The intl string context variable "'.concat(a,'" was not provided to the string "').concat(t,'"'),nn.MISSING_VALUE,t)||this}return a(t,e),t}(pn);function _n(e){return"function"==typeof e}function bn(e,a,t,i,n,s,r){if(1===e.length&&li(e[0]))return[{type:hn.literal,value:e[0].value}];for(var o=[],d=0,l=e;d0?new Intl.Locale(a[0]):new Intl.Locale("string"==typeof e?e:e[0])}},e.__parse=mn,e.formats={number:{integer:{maximumFractionDigits:0},currency:{style:"currency"},percent:{style:"percent"}},date:{short:{month:"numeric",day:"numeric",year:"2-digit"},medium:{month:"short",day:"numeric",year:"numeric"},long:{month:"long",day:"numeric",year:"numeric"},full:{weekday:"long",month:"long",day:"numeric",year:"numeric"}},time:{short:{hour:"numeric",minute:"numeric"},medium:{hour:"numeric",minute:"numeric",second:"numeric"},long:{hour:"numeric",minute:"numeric",second:"numeric",timeZoneName:"short"},full:{hour:"numeric",minute:"numeric",second:"numeric",timeZoneName:"short"}}},e}(),wn={af:Pe,ca:Ue,cs:Ye,da:ta,de:da,en:ga,et:$a,es:ya,fr:Ma,hu:Pa,it:Ua,nl:Ya,sk:tt,sv:dt,tr:Pt,vi:gt,"zh-Hans":yt,"zh-Hant":$t,ru:Mt,pl:Ut,pt:Yt};function zn(e,a,...t){const i=a.replace(/['"]+/g,"");var n;try{n=e.split(".").reduce((e,a)=>e[a],wn[i])}catch(a){n=e.split(".").reduce((e,a)=>e[a],wn.en)}if(void 0===n&&(n=e.split(".").reduce((e,a)=>e[a],wn.en)),!t.length)return n;const s={};for(let e=0;e{i=i||{},t=null==t?{}:t;const n=new Event(a,{bubbles:void 0===i.bubbles||i.bubbles,cancelable:Boolean(i.cancelable),composed:void 0===i.composed||i.composed});return n.detail=t,e.dispatchEvent(n),n};function Jn(e){return(e=e.replace("_"," ")).charAt(0).toUpperCase()+e.slice(1)}function es(e){return e?e.attributes&&e.attributes.friendly_name?e.attributes.friendly_name:String(e.entity_id.split(".").pop()):"(unrecognized entity)"}function as(e){let a=[];return e.forEach(e=>{a.find(a=>"object"==typeof e?function(...e){return e.every(a=>JSON.stringify(a)===JSON.stringify(e[0]))}(a,e):a===e)||a.push(e)}),a}function ts(e,a){return e.filter(e=>e!==a)}function is(e,a){return e?Object.entries(e).filter(([e])=>a.includes(e)).reduce((e,[a,t])=>Object.assign(e,{[a]:t}),{}):{}}const ns=(e,...a)=>{const t={};let i;for(i in e)a.includes(i)||(t[i]=e[i]);return t};function ss(e){return null!=e}function rs(e,a){if(null===e||null===a)return e===a;const t=Object.keys(e),i=Object.keys(a);if(t.length!==i.length)return!1;for(const i of t)if("object"==typeof e[i]&&"object"==typeof a[i]){if(!rs(e[i],a[i]))return!1}else if(e[i]!==a[i])return!1;return!0}function os(e,a){const t=e instanceof HTMLElement?e:e.target;Xn(t,"show-dialog",{dialogTag:"error-dialog",dialogImport:()=>Promise.resolve().then(function(){return Js}),dialogParams:{error:a}})}function ds(e,a){os(a,K` + Something went wrong! +
+ ${e.body.message?K` + ${e.body.message} +
+
+ `:""} + ${e.error} +
+
+ Please + report + the bug. + `)}const ls=(e,a)=>{var t,i,n,s,r;if(!e)return!1;switch(e){case Un.STATE_ALARM_ARMED_AWAY:return null===(t=a[Yn.ArmedAway])||void 0===t?void 0:t.enabled;case Un.STATE_ALARM_ARMED_HOME:return null===(i=a[Yn.ArmedHome])||void 0===i?void 0:i.enabled;case Un.STATE_ALARM_ARMED_NIGHT:return null===(n=a[Yn.ArmedNight])||void 0===n?void 0:n.enabled;case Un.STATE_ALARM_ARMED_CUSTOM_BYPASS:return null===(s=a[Yn.ArmedCustom])||void 0===s?void 0:s.enabled;case Un.STATE_ALARM_ARMED_VACATION:return null===(r=a[Yn.ArmedVacation])||void 0===r?void 0:r.enabled;default:return!0}};function cs(e,a){return Object.entries(a).forEach(([a,t])=>{e=a in e&&"object"==typeof e[a]&&null!==e[a]?Object.assign(Object.assign({},e),{[a]:cs(e[a],t)}):Object.assign(Object.assign({},e),{[a]:t})}),e}function ms(e,a){const t=e=>"object"==typeof e?t(e.name):e.trim().toLowerCase();return t(e){t?history.replaceState(null,"",a):history.pushState(null,"",a),Xn(window,"location-changed",{replace:t})};function ps(e){return e.substr(0,e.indexOf("."))}function gs(e){return e.substr(e.indexOf(".")+1)}function us(e,a){const t={alert:"mdi:alert",automation:"mdi:playlist-play",calendar:"mdi:calendar",camera:"mdi:video",climate:"mdi:thermostat",configurator:"mdi:settings",conversation:"mdi:text-to-speech",device_tracker:"mdi:account",fan:"mdi:fan",group:"mdi:google-circles-communities",history_graph:"mdi:chart-line",homeassistant:"mdi:home-assistant",homekit:"mdi:home-automation",image_processing:"mdi:image-filter-frames",input_boolean:"mdi:drawing",input_datetime:"mdi:calendar-clock",input_number:"mdi:ray-vertex",input_select:"mdi:format-list-bulleted",input_text:"mdi:textbox",light:"mdi:lightbulb",mailbox:"mdi:mailbox",notify:"mdi:comment-alert",person:"mdi:account",plant:"mdi:flower",proximity:"mdi:apple-safari",remote:"mdi:remote",scene:"mdi:google-pages",script:"mdi:file-document",sensor:"mdi:eye",simple_alarm:"mdi:bell",sun:"mdi:white-balance-sunny",switch:"mdi:flash",timer:"mdi:timer",updater:"mdi:cloud-upload",vacuum:"mdi:robot-vacuum",water_heater:"mdi:thermometer",weblink:"mdi:open-in-new"};if(e in t)return t[e];switch(e){case"alarm_control_panel":switch(a){case"armed_home":return"mdi:bell-plus";case"armed_night":return"mdi:bell-sleep";case"disarmed":return"mdi:bell-outline";case"triggered":return"mdi:bell-ring";default:return"mdi:bell"}case"binary_sensor":return a&&"off"===a?"mdi:radiobox-blank":"mdi:checkbox-marked-circle";case"cover":return"closed"===a?"mdi:window-closed":"mdi:window-open";case"lock":return a&&"unlocked"===a?"mdi:lock-open":"mdi:lock";case"media_player":return a&&"off"!==a&&"idle"!==a?"mdi:cast-connected":"mdi:cast";case"zwave":switch(a){case"dead":return"mdi:emoticon-dead";case"sleeping":return"mdi:sleep";case"initializing":return"mdi:timer-sand";default:return"mdi:z-wave"}default:return"mdi:bookmark"}}const vs=c` + ha-card { + display: flex; + flex-direction: column; + margin: 5px; + max-width: calc(100vw - 10px); + } + + .card-header { + display: flex; + justify-content: space-between; + } + .card-header .name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + div.warning { + color: var(--error-color); + margin-top: 20px; + } + + div.checkbox-row { + min-height: 40px; + display: flex; + align-items: center; + } + + div.checkbox-row ha-switch { + margin-right: 20px; + } + + div.checkbox-row.right ha-switch { + margin-left: 20px; + position: absolute; + right: 0px; + } + + div.entity-row { + display: flex; + align-items: center; + flex-direction: row; + margin: 10px 0px; + } + div.entity-row .info { + margin-left: 16px; + flex: 1 0 60px; + } + div.entity-row .info, + div.entity-row .info > * { + color: var(--primary-text-color); + transition: color 0.2s ease-in-out; + } + div.entity-row .secondary { + display: block; + color: var(--secondary-text-color); + transition: color 0.2s ease-in-out; + } + div.entity-row state-badge { + flex: 0 0 40px; + } + + ha-dialog div.wrapper { + margin-bottom: -20px; + } + + ha-input { + min-width: 220px; + } + + a, + a:visited { + color: var(--primary-color); + } + mwc-tab { + --mdc-tab-color-default: var(--secondary-text-color); + --mdc-tab-text-label-color-default: var(--secondary-text-color); + } + mwc-tab ha-icon { + --mdc-icon-size: 20px; + } + mwc-tab.disabled { + --mdc-theme-primary: var(--disabled-text-color); + --mdc-tab-color-default: var(--disabled-text-color); + --mdc-tab-text-label-color-default: var(--disabled-text-color); + } + + ha-card alarmo-settings-row:first-child, + ha-card alarmo-settings-row:first-of-type { + border-top: 0px; + } + + ha-card > ha-card { + margin: 10px; + } +`,_s=c` + /* mwc-dialog (ha-dialog) styles */ + ha-dialog { + --mdc-dialog-min-width: 400px; + --mdc-dialog-max-width: 600px; + --mdc-dialog-heading-ink-color: var(--primary-text-color); + --mdc-dialog-content-ink-color: var(--primary-text-color); + --justify-action-buttons: space-between; + } + /* make dialog fullscreen on small screens */ + @media all and (max-width: 450px), all and (max-height: 500px) { + ha-dialog { + --mdc-dialog-min-width: calc(100vw - env(safe-area-inset-right) - env(safe-area-inset-left)); + --mdc-dialog-max-width: calc(100vw - env(safe-area-inset-right) - env(safe-area-inset-left)); + --mdc-dialog-min-height: 100%; + --mdc-dialog-max-height: 100%; + --vertial-align-dialog: flex-end; + --ha-dialog-border-radius: 0px; + } + } + ha-dialog div.description { + margin-bottom: 10px; + } +`,bs=()=>{const e=e=>{let a={};for(var t=0;t3){let i=a.slice(3);if(a.includes("filter")){const a=i.findIndex(e=>"filter"==e),n=i.slice(a+1);i=i.slice(0,a),t=Object.assign(Object.assign({},t),{filter:e(n)})}i.length&&(i.length%2&&(t=Object.assign(Object.assign({},t),{subpage:i.shift()})),i.length&&(t=Object.assign(Object.assign({},t),{params:e(i)})))}return t},fs=(e,...a)=>{let t={page:e,params:{}};a.forEach(e=>{"string"==typeof e?t=Object.assign(Object.assign({},t),{subpage:e}):"params"in e?t=Object.assign(Object.assign({},t),{params:e.params}):"filter"in e&&(t=Object.assign(Object.assign({},t),{filter:e.filter}))});const i=e=>{let a=Object.keys(e);a=a.filter(a=>e[a]),a.sort();let t="";return a.forEach(a=>{let i=e[a];t=t.length?`${t}/${a}/${i}`:`${a}/${i}`}),t};let n=`/alarmo/${t.page}`;return t.subpage&&(n=`${n}/${t.subpage}`),i(t.params).length&&(n=`${n}/${i(t.params)}`),t.filter&&(n=`${n}/filter/${i(t.filter)}`),n},ys=60;let ks=class extends ce{constructor(){super(...arguments),this.min=0,this.max=300,this.value=0,this.step=15,this.disabled=!1,this.showArrows=!0,this.required=!1}render(){return K` +
+ ${this.required?Z:K` +
+ + `} +
+
+
+ + +
:
+
+ ${zn("components.time_picker.minutes",this.hass.language)} +
+
+ + + ${zn("components.time_picker.seconds",this.hass.language)} +
+ ${this.showArrows?K` +
+ + + + + + `:Z} +
+
+ `}_getMinutes(){return Math.floor(this.value/ys)}_getSeconds(){return this.value%ys}_minutesChanged(e){let a=Number(e.target.value),t=a*ys+this._getSeconds();tthis.max&&(t=this.max,a=Math.floor(t/ys),e.target.value=String(a)),this.value=t,this._valueChanged()}_secondsChanged(e){let a=Number(e.target.value);a>=ys&&(a=59,e.target.value=String(a));let t=this._getMinutes()*ys+a;tthis.max&&(t=this.max,a=this.value%ys,e.target.value=String(a)),this.value=t,this._valueChanged()}_validateMinutesInput(e,a){let t=null!==e.match(/^[0-9]+$/);return{valid:t,customError:!t}}_validateSecondsInput(e,a){let t=null!==e.match(/^[0-9]+$/);return{valid:t,customError:!t}}_secondsUpClick(){let e=Math.round(this.value/this.step)*this.step;e+=this.step,e>this.max&&(e=this.max),this.value=e,this._valueChanged()}_secondsDownClick(){let e=Math.round(this.value/this.step)*this.step;e-=this.step,e * { + display: flex; + } + div.row { + display: flex; + flex-direction: row; + } + wa-button { + width: 30px; + height: 30px; + --wa-form-control-border-radius: 8px; + margin-left: 4px; + } + wa-button ha-icon { + color: var(--wa-color-on-normal); + } + span.label { + display: flex; + padding: 2px 0px 0px 0px; + justify-content: center; + color: rgba(var(--rgb-primary-text-color), 0.6); + font-size: 0.9rem; + } + `,i([ue({attribute:!1})],ks.prototype,"hass",void 0),i([ue({type:Number})],ks.prototype,"min",void 0),i([ue({type:Number})],ks.prototype,"max",void 0),i([ue({type:Number})],ks.prototype,"value",void 0),i([ue({type:Number})],ks.prototype,"step",void 0),i([ue({type:Boolean})],ks.prototype,"disabled",void 0),i([ue({type:Boolean})],ks.prototype,"showArrows",void 0),i([ue({type:String})],ks.prototype,"placeholder",void 0),i([ue({type:Boolean})],ks.prototype,"required",void 0),ks=i([he("alarmo-duration-picker")],ks);const ws=[{name:"primary",weight:10},{name:"secondary",weight:8}];let zs=class extends ce{constructor(){super(...arguments),this.label="",this.items=[],this.disabled=!1,this.showSearch=!1,this.clearable=!1,this.invalid=!1,this._valueRenderer=e=>{const a=this.items.find(a=>a.value===e);return K` + ${(null==a?void 0:a.icon)?K``:Z} + + ${a?a.name:e} + + `},this._getItems=()=>this.items.map(e=>Object({id:e.value,primary:e.name,secondary:e.description,icon:e.icon}))}render(){if(this.showSearch)return K` + + + ${this.invalid?K`Invalid`:Z} + `;{const e=this.items.map(e=>({value:e.value,label:e.name,secondary:e.description,iconPath:e.icon}));return K` + {e.stopPropagation()}} + fixedMenuPosition + naturalMenuWidth + > + + ${this.invalid?K`Invalid`:Z} + `}}_renderOptions(){const e=this.items.some(e=>e.icon);return this.items.map(a=>K` + + ${a.icon?K``:Z} + ${a.name} + + `)}_selectChanged(e){e.stopPropagation(),this.value=e.detail.value,Xn(this,"value-changed",{value:this.value})}_pickerChanged(e){e.stopPropagation(),this.value=e.detail.value,Xn(this,"value-changed",{value:this.value})}clearValue(){if(this.showSearch){const e=this.shadowRoot.querySelector("ha-generic-picker");this.value="",e.blur()}else{const e=this.shadowRoot.querySelector("ha-select");this.value=void 0,setTimeout(()=>{e.blur()},50)}}};zs.styles=c` + ha-select { + width: 100%; + } + .mdc-floating-label { + inset-inline-start: var(--ha-space-4); + inset-inline-end: initial; + color: red; + direction: var(--direction); + } + :host([invalid]) { + --mdc-select-label-ink-color: var(--mdc-theme-error, red); + --mdc-select-idle-line-color: var(--mdc-theme-error, red); + --mdc-text-field-idle-line-color: var(--mdc-theme-error, red); + } + span.invalid { + display: flex; + font-size: 0.75rem; + color: var(--mdc-theme-error, red); + margin: 6px 16px 0px 16px; + } + ha-select { + --ha-space-10: var(--ha-space-13); + } + :host([icons]) ha-select { + --ha-space-10: var(--ha-space-15); + } + `,i([ue()],zs.prototype,"hass",void 0),i([ue()],zs.prototype,"label",void 0),i([ue()],zs.prototype,"items",void 0),i([ue({type:String,reflect:!0})],zs.prototype,"value",void 0),i([ue({type:Boolean,reflect:!0})],zs.prototype,"disabled",void 0),i([ue()],zs.prototype,"helper",void 0),i([ue({type:Boolean})],zs.prototype,"showSearch",void 0),i([ue({type:Boolean})],zs.prototype,"clearable",void 0),i([ue({type:Boolean})],zs.prototype,"invalid",void 0),i([_e("ha-input")],zs.prototype,"_menu",void 0),zs=i([he("alarmo-select")],zs);let As=class extends ce{static get styles(){return c` + :host { + display: block; + } + `}render(){return K` + + `}constructor(){super(),this.addEventListener("clickHeader",this.manageSpoilers)}manageSpoilers(e){const a=e.target;a.getAttribute("active")?a.removeAttribute("active"):a.setAttribute("active","true"),this.querySelectorAll("alarmo-collapsible-header[active]").forEach(function(e){e!==a&&e.removeAttribute("active")})}};As=i([he("alarmo-collapsible-group")],As);let js=class extends ce{static get styles(){return c` + :host { + display: block; + } + `}render(){return K` + + `}};js=i([he("alarmo-collapsible-item")],js);let $s=class extends ce{constructor(){super(),this.clickHeader=new CustomEvent("clickHeader",{detail:{message:"clickHeader happened."},bubbles:!0,composed:!0}),this.active=!1,this.addEventListener("click",this.handleClick)}handleClick(){this.dispatchEvent(this.clickHeader)}render(){return K` + + + + + + + `}static get styles(){return c` + :host { + display: block; + cursor: pointer; + } + :host mwc-list-item::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + content: ''; + transition: opacity 15ms linear; + will-change: opacity; + background-color: black; + opacity: 0; + } + :host mwc-list-item:hover::before { + opacity: 0.04; + } + :host([active]) mwc-list-item::before { + opacity: 0.1; + } + :host([active]) mwc-list-item:hover::before { + opacity: 0.12; + } + :host mwc-list-item:active::before, + :host([active]) mwc-list-item:active::before { + opacity: 0.14; + } + ::slotted(ha-icon), ::slotted(ha-svg-icon) { + width: 24px; + height: 24px; + padding: 6px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 16px; + } + :host mwc-list-item { + font-size: 15px; + --mdc-typography-body2-font-size: 14px; + } + :host .chevron { + display: block; + transition: 0.4s; + } + :host([active]) .chevron { + transform: rotate(180deg); + } + `}attributeChangedCallback(e,a,t){this.hasAttribute("active")&&this.nextElementSibling?this.nextElementSibling.style.maxHeight=this.nextElementSibling.scrollHeight+"px":this.nextElementSibling&&(this.nextElementSibling.style.maxHeight="0px"),super.attributeChangedCallback(e,a,t)}};i([ue({type:CustomEvent})],$s.prototype,"clickHeader",void 0),i([ue({type:Boolean,attribute:!0,reflect:!0})],$s.prototype,"active",void 0),$s=i([he("alarmo-collapsible-header")],$s);let Ts=class extends ce{static get styles(){return c` + :host { + display: block; + background-color: var(--card-background-color); + max-height: 0px; + overflow: hidden; + transition: max-height 0.2s ease-out; + } + .wrapper { + } + `}render(){return K` +
+ Default details +
+ `}};Ts=i([he("alarmo-collapsible-body")],Ts);let Es=class extends(Me(ce)){hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){this.hass&&(this.areas=await Ce(this.hass),this.sensors=await ke(this.hass))}async firstUpdated(){await this._fetchData(),this.selectedArea=Object.keys(this.areas)[0],this.data=Object.assign({},this.areas[this.selectedArea].modes)}render(){return this.data?K` + +
+
+ ${zn("panels.general.cards.modes.title",this.hass.language)} +
+ + ${Object.keys(this.areas).length>1?K` + Object({value:e.area_id,name:e.name}))} + value=${this.selectedArea} + label=${this.hass.localize("ui.components.area-picker.area")} + @value-changed=${e=>this.selectArea(e.target.value)} + > + `:""} +
+
+ ${zn("panels.general.cards.modes.description",this.hass.language)} +
+ + + ${Object.entries(Yn).map(([e,a])=>{var t;return K` + + + + + ${this.hass.localize(`component.alarm_control_panel.entity_component._.state.${a}`)} + + + ${(null===(t=this.data[a])||void 0===t?void 0:t.enabled)?K` + ${zn("common.enabled",this.hass.language)}, + + ${zn("panels.general.cards.modes.number_sensors_active",this.hass.language,"number",this.getSensorsByMode(a))} + + `:zn("common.disabled",this.hass.language)} + + + + ${this.renderModeConfig(a)} + + + `})} + +
+ `:K``}getSensorsByMode(e){return Object.values(this.sensors).filter(a=>a.area==this.selectedArea&&(a.modes.includes(e)||a.always_on)).length}renderModeConfig(e){const a=e in this.data?this.data[e]:void 0;return K` +
+ + ${zn(`panels.general.cards.modes.modes.${e}`,this.hass.language)} +
+ + + ${zn("panels.general.cards.modes.fields.status.heading",this.hass.language)} + + + ${zn("panels.general.cards.modes.fields.status.description",this.hass.language)} + +
+ this.saveData(a,e,{enabled:!0})} + > + + ${zn("common.enabled",this.hass.language)} + + this.saveData(a,e,{enabled:!1})} + > + + ${zn("common.disabled",this.hass.language)} + +
+
+ + + ${zn("panels.general.cards.modes.fields.exit_delay.heading",this.hass.language)} + + + ${zn("panels.general.cards.modes.fields.exit_delay.description",this.hass.language)} + + {this.saveData(a,e,{exit_time:a.detail.value})}} + ?disabled=${!(null==a?void 0:a.enabled)||!ss(null==a?void 0:a.exit_time)} + > + + + + ${zn("panels.general.cards.modes.fields.entry_delay.heading",this.hass.language)} + + + ${zn("panels.general.cards.modes.fields.entry_delay.description",this.hass.language)} + + this.saveData(a,e,{entry_time:a.detail.value})} + ?disabled=${!(null==a?void 0:a.enabled)||!ss(null==a?void 0:a.entry_time)} + > + + + + ${zn("panels.general.cards.modes.fields.trigger_time.heading",this.hass.language)} + + + ${zn("panels.general.cards.modes.fields.trigger_time.description",this.hass.language)} + + this.saveData(a,e,{trigger_time:a.detail.value})} + ?disabled=${!(null==a?void 0:a.enabled)||!ss(null==a?void 0:a.trigger_time)} + > + + `}selectArea(e){e!=this.selectedArea&&(this.selectedArea=e,this.data=Object.assign({},this.areas[e].modes))}saveClick(e){Oe(this.hass,{area_id:this.selectedArea,modes:this.data}).catch(a=>ds(a,e)).then()}saveData(e,a,t){this.data=Object.assign(Object.assign({},this.data),{[a]:Object.assign(Object.assign({},this.data[a]||{enabled:!1,exit_time:0,entry_time:0,trigger_time:0}),t)}),Oe(this.hass,{area_id:this.selectedArea,modes:this.data}).catch(a=>ds(a,e.target)).then()}static get styles(){return c` + ${vs} + alarmo-collapsible-header:first-of-type { + border-top: 1px solid var(--divider-color); + } + .description { + margin: 8px; + padding: 12px; + color: var(--primary-color); + filter: brightness(0.85); + font-size: 14px; + line-height: 1.5em; + min-height: 36px; + display: flex; + align-items: center; + position: relative; + } + .description::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + content: ''; + background: rgba(var(--rgb-primary-color), 0.12); + border-radius: 5px; + } + .description ha-icon { + --mdc-icon-size: 36px; + display: inline; + float: left; + margin-right: 12px; + align-self: flex-start; + } + alarmo-select { + display: flex; + min-width: 180px; + } + `}};i([ue()],Es.prototype,"hass",void 0),i([ue({type:Boolean})],Es.prototype,"narrow",void 0),i([ue()],Es.prototype,"config",void 0),i([ue()],Es.prototype,"areas",void 0),i([ue()],Es.prototype,"sensors",void 0),i([ue()],Es.prototype,"data",void 0),i([ue()],Es.prototype,"selectedArea",void 0),Es=i([he("alarm-mode-card")],Es);let Ss=class extends ce{constructor(){super(...arguments),this.threeLine=!1}render(){return K` +
+ +
+
+ + `}static get styles(){return c` + :host { + display: flex; + flex-direction: row; + padding: 0px 16px; + align-items: center; + min-height: 72px; + } + :host([large]) { + align-items: normal; + flex-direction: column; + border-top: 1px solid var(--divider-color); + border-bottom: 1px solid var(--divider-color); + padding: 16px 16px; + } + :host([narrow]) { + align-items: normal; + flex-direction: column; + border-bottom: none; + border-top: 1px solid var(--divider-color); + padding: 16px 16px; + } + :host([nested]) { + border: none; + padding: 8px 16px 0px 16px; + margin-top: -16px; + min-height: 40px; + } + :host([nested]:not([narrow])) { + padding: 16px 16px 0px 32px; + } + :host([first]) { + border-top: none; + } + :host([last]) { + border-bottom: none; + } + :host([dialog]) { + border: none; + padding: 12px 0px; + } + ::slotted(ha-switch) { + padding: 16px 0; + } + .info { + flex: 1 0 60px; + margin-bottom: 4px; + } + :host([large]) .info, + :host([narrow]) .info { + flex: 1 0 40px; + } + :host([nested]) .info { + flex: 1 0 26px; + } + :host([dialog]) .info { + flex: 1 0 40px; + padding-bottom: 8px; + } + .secondary { + color: var(--secondary-text-color); + margin-top: 4px; + } + :host(:not([large]):not([narrow])):not([dialog])) ::slotted(*) { + max-width: 66%; + } + `}};i([ue({type:Boolean,reflect:!0})],Ss.prototype,"narrow",void 0),i([ue({type:Boolean,reflect:!0})],Ss.prototype,"large",void 0),i([ue({type:Boolean,attribute:"three-line"})],Ss.prototype,"threeLine",void 0),i([ue({type:Boolean})],Ss.prototype,"nested",void 0),i([ue({type:Boolean})],Ss.prototype,"dialog",void 0),Ss=i([he("alarmo-settings-row")],Ss);let Cs=class extends ce{constructor(){super(...arguments),this.header="",this.open=!1}render(){return K` + ${this.open?K` +
+ {this.open=!1}}>${this.header} + {this.open=!1}}> + +
+ +
+ {this.open=!1}}>${this.header} + {this.open=!1}}> + +
+ `:K` +
+ {this.open=!0}}>${this.header} + {this.open=!0}}> + +
+ `} + `}static get styles(){return c` + :host { + } + + div.header { + display: flex; + align-items: center; + padding: 0px 16px; + cursor: pointer; + } + div.header.open:first-of-type { + border-bottom: 1px solid var(--divider-color); + } + div.header.open:last-of-type { + border-top: 1px solid var(--divider-color); + } + + :host([narrow]) div.header { + border-top: 1px solid var(--divider-color); + border-bottom: none; + } + + div.header span { + display: flex; + flex-grow: 1; + } + + div.seperator { + height: 1px; + background: var(--divider-color); + } + `}};i([ue({type:Boolean,reflect:!0})],Cs.prototype,"narrow",void 0),i([ue()],Cs.prototype,"header",void 0),i([ue()],Cs.prototype,"open",void 0),Cs=i([he("alarmo-collapsible-section")],Cs);let Os=class extends(Me(ce)){constructor(){super(...arguments),this.areas={}}hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){if(!this.hass)return;const e=await ye(this.hass);this.config=e,this.areas=await Ce(this.hass),this.selection=e.mqtt}firstUpdated(){(async()=>{await be()})()}render(){return this.hass&&this.selection?K` + +
+
${zn("panels.general.cards.mqtt.title",this.hass.language)}
+ +
+
${zn("panels.general.cards.mqtt.description",this.hass.language)}
+ + + + ${zn("panels.general.cards.mqtt.fields.state_topic.heading",this.hass.language)} + + + ${zn("panels.general.cards.mqtt.fields.state_topic.description",this.hass.language)} + + {this.selection=Object.assign(Object.assign({},this.selection),{state_topic:e.target.value})}} + > + + + + ${Object.values(Un).filter(e=>Object.values(this.areas).some(a=>ls(e,a.modes))).map(e=>K` + + ${Jn(e)} + + ${zn("panels.general.cards.mqtt.fields.state_payload.item",this.hass.language,"{state}",Jn(e))} + + {this.selection=cs(this.selection,{state_payload:{[e]:a.target.value}})}} + > + + `)} + + + + + ${zn("panels.general.cards.mqtt.fields.event_topic.heading",this.hass.language)} + + + ${zn("panels.general.cards.mqtt.fields.event_topic.description",this.hass.language)} + + {this.selection=Object.assign(Object.assign({},this.selection),{event_topic:e.target.value})}} + > + + + + + ${zn("panels.general.cards.mqtt.fields.command_topic.heading",this.hass.language)} + + + ${zn("panels.general.cards.mqtt.fields.command_topic.description",this.hass.language)} + + {this.selection=Object.assign(Object.assign({},this.selection),{command_topic:e.target.value})}} + > + + + + ${Object.values(Gn).filter(e=>Object.values(this.areas).some(a=>ls((e=>{switch(e){case Gn.COMMAND_ALARM_DISARM:return Un.STATE_ALARM_DISARMED;case Gn.COMMAND_ALARM_ARM_HOME:return Un.STATE_ALARM_ARMED_HOME;case Gn.COMMAND_ALARM_ARM_AWAY:return Un.STATE_ALARM_ARMED_AWAY;case Gn.COMMAND_ALARM_ARM_NIGHT:return Un.STATE_ALARM_ARMED_NIGHT;case Gn.COMMAND_ALARM_ARM_CUSTOM_BYPASS:return Un.STATE_ALARM_ARMED_CUSTOM_BYPASS;case Gn.COMMAND_ALARM_ARM_VACATION:return Un.STATE_ALARM_ARMED_VACATION;default:return}})(e),a.modes))).map(e=>K` + + ${Jn(e)} + + ${zn("panels.general.cards.mqtt.fields.command_payload.item",this.hass.language,"{command}",Jn(e))} + + {this.selection=cs(this.selection,{command_payload:{[e]:a.target.value}})}} + > + + `)} + + + + + ${zn("panels.general.cards.mqtt.fields.require_code.heading",this.hass.language)} + + + ${zn("panels.general.cards.mqtt.fields.require_code.description",this.hass.language)} + + {this.selection=Object.assign(Object.assign({},this.selection),{require_code:e.target.checked})}} + > + + +
+ + ${this.hass.localize("ui.common.save")} + +
+
+ `:K``}saveClick(e){this.hass&&je(this.hass,{mqtt:Object.assign(Object.assign({},this.selection),{enabled:!0})}).catch(a=>ds(a,e)).then(()=>{this.cancelClick()})}cancelClick(){hs(0,fs("general"),!0)}};Os.styles=vs,i([ue()],Os.prototype,"narrow",void 0),i([ue()],Os.prototype,"config",void 0),i([ue()],Os.prototype,"areas",void 0),i([ue()],Os.prototype,"selection",void 0),Os=i([he("mqtt-config-card")],Os); +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const Ms=2;class xs{constructor(e){}get _$AU(){return this._$AM._$AU}_$AT(e,a,t){this._$Ct=e,this._$AM=a,this._$Ci=t}_$AS(e,a){return this.update(e,a)}update(e,a){return this.render(...a)}} +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */class Ns extends xs{constructor(e){if(super(e),this.it=Z,e.type!==Ms)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(e){if(e===Z||null==e)return this._t=void 0,this.it=e;if(e===F)return e;if("string"!=typeof e)throw Error(this.constructor.directiveName+"() called with a non-string value");if(e===this.it)return this._t;this.it=e;const a=[e];return a.raw=a,this._t={_$litType$:this.constructor.resultType,strings:a,values:[]}}}Ns.directiveName="unsafeHTML",Ns.resultType=1;const Ls=(e=>(...a)=>({_$litDirective$:e,values:a}))(Ns);let Hs=class extends ce{constructor(){super(...arguments),this.active=!1}render(){return K` +
+
+ ${this.renderIcon()} + + ${this.renderTrailingIcon()} +
+ `}renderIcon(){var e;return this.icon||this.toggleable?this.toggleable?K` +
+ +
+ `:K` +
+ ${(null===(e=this.icon)||void 0===e?void 0:e.startsWith("mdi:"))?K``:K``} +
+ `:Z}renderTrailingIcon(){if(!this.removable&&!this.badge)return Z;if(this.badge)return K` +
+ ${this.badge} +
+ `;const e=Math.random().toString(36).substring(2,9);return K` +
+ + ${this.hass.localize("ui.common.remove")} +
+ `}_handleClick(e){if(this.toggleable){this.active=!this.active;const e=new CustomEvent("click",{detail:{active:this.active,value:this.value}});this.dispatchEvent(e)}else{const e=new CustomEvent("click",{detail:{value:this.value}});this.dispatchEvent(e)}e.stopPropagation()}_iconClick(e){const a=new CustomEvent("icon-clicked",{detail:{value:this.value}});this.dispatchEvent(a),e.stopPropagation()}static get styles(){return c` + :host { + margin: 4px; + } + .chip { + display: inline-flex; + position: relative; + height: var(--chip-height, 32px); + background: none; + user-select: none; + z-index: 1; + align-items: center; + justify-content: center; + } + .chip:before { + position: absolute; + pointer-events: none; + top: 0; + right: 0; + bottom: 0; + left: 0; + content: ''; + border: 1px solid var(--chip-color, rgb(168, 225, 251)); + border-radius: var(--chip-border-radius, 32px); + background: rgba(0, 0, 0, 0); + opacity: var(--background-opacity, 1); + z-index: -2; + } + .chip.active:before { + background: var(--chip-color, rgb(168, 225, 251)); + } + .icon { + position: relative; + width: 32px; + height: 32px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + --mdc-icon-size: 20px; + margin-right: -8px; + color: rgba(0, 0, 0, 0.54); + } + .icon.filled:before { + position: absolute; + pointer-events: none; + top: 0; + right: 0; + bottom: 0; + left: 0; + content: ''; + background: var(--chip-color, rgb(168, 225, 251)); + border-radius: 32px; + z-index: -2; + } + .value { + color: var(--primary-text-color); + font-size: var(--chip-font-size, 0.875rem); + font-weight: 400; + display: flex; + align-items: center; + padding: 0px 12px; + opacity: 0.9; + } + .trailing-icon { + position: relative; + width: 26px; + height: 26px; + border-radius: 13px; + display: flex; + align-items: center; + justify-content: center; + --mdc-icon-size: 16px; + margin: 0px 3px 0px -8px; + color: var(--icon-color, rgba(0, 0, 0, 0.54)); + cursor: pointer; + } + .trailing-icon:before { + position: absolute; + pointer-events: none; + top: 0; + right: 0; + bottom: 0; + left: 0; + content: ''; + background: var(--chip-color, var(--secondary-text-color)); + border-radius: 26px; + z-index: -2; + opacity: 0; + transition: opacity 0.1s ease-in-out; + } + .trailing-icon:hover:before { + opacity: 0.15; + } + .trailing-icon:active:before { + opacity: 0.3; + } + :host([selectable]) .chip, :host([toggleable]) .chip { + cursor: pointer; + } + .overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: -1; + background: rgba(0, 0, 0, 0); + border-radius: var(--chip-border-radius, 32px); + transition: background-color 0.1s ease-in-out, border 0.1s ease-in-out; + border: 1px solid rgba(0, 0, 0, 0); + } + :host([selectable]) .chip:hover .overlay, :host([toggleable]) .chip:hover .overlay { + border: 1px solid rgba(0, 0, 0, 0.05); + background: rgba(0, 0, 0, 0.05); + } + :host([selectable]) .chip:active .overlay, :host([toggleable]) .chip:active .overlay { + border: 1px solid rgba(0, 0, 0, 0.1); + background: rgba(0, 0, 0, 0.1); + } + :host([selectable]) .chip:hover .value, :host([toggleable]) .chip:hover .value { + opacity: 1; + } + :host([active]):host([selectable]) .chip:hover .overlay, :host([active]):host([toggleable]) .chip:hover .overlay { + background: rgba(0, 0, 0, 0.1); + border: 1px solid rgba(0, 0, 0, 0); + } + :host([active]):host([selectable]) .chip:active .overlay, :host([active]):host([toggleable]) .chip:active .overlay { + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(0, 0, 0, 0); + } + + :host([toggleable]) .icon { + width: 0px; + transition: width 0.1s ease-in-out; + overflow: hidden; + display: flex; + align-items: center; + margin-left: 12px; + } + :host([toggleable]) .active .icon { + width: 20px; + } + .badge { + position: relative; + display: flex; + height: 26px; + min-width: 26px; + border-radius: 13px; + font-size: var(--chip-font-size, 0.875rem); + align-items: center; + justify-content: center; + margin: 0px 3px 0px -8px; + } + .badge:before { + position: absolute; + pointer-events: none; + top: 0; + right: 0; + bottom: 0; + left: 0; + content: ''; + background: var(--chip-color, var(--secondary-text-color)); + border-radius: 26px; + z-index: -2; + transition: opacity 0.1s ease-in-out; + opacity: 0.1; + } + `}};i([ue({attribute:!1})],Hs.prototype,"hass",void 0),i([ue({type:String})],Hs.prototype,"icon",void 0),i([ue({type:Boolean})],Hs.prototype,"selectable",void 0),i([ue({type:Boolean})],Hs.prototype,"removable",void 0),i([ue({type:Boolean})],Hs.prototype,"toggleable",void 0),i([ue({type:Boolean})],Hs.prototype,"active",void 0),i([ue({type:String})],Hs.prototype,"badge",void 0),i([ue({type:String})],Hs.prototype,"value",void 0),Hs=i([he("alarmo-chip")],Hs);let Ds=class extends ce{constructor(){super(...arguments),this.value=[]}render(){return this.items?K` + ${Object.values(this.items).map(e=>K` + + ${e.name} + + `)} + `:K``}_handleClick(e){if(this.toggleable){const a=e.detail.value,t=e.detail.active;this.value.includes(a)&&!t?this.value=this.value.filter(e=>e!=a):!this.value.includes(a)&&a&&(this.value=[...this.value,a]);const i=new CustomEvent("value-changed",{detail:this.value});this.dispatchEvent(i)}else{const a=new CustomEvent("value-changed",{detail:e.detail.value});this.dispatchEvent(a)}}static get styles(){return c` + :host { + display: flex; + flex-direction: row; + flex: 1; + margin: 0px -4px; + flex-wrap: wrap; + } + `}};i([ue({attribute:!1})],Ds.prototype,"hass",void 0),i([ue({attribute:!1})],Ds.prototype,"items",void 0),i([ue({attribute:!1})],Ds.prototype,"value",void 0),i([ue({type:Boolean})],Ds.prototype,"selectable",void 0),i([ue({type:Boolean})],Ds.prototype,"toggleable",void 0),i([ue({type:Boolean})],Ds.prototype,"removable",void 0),Ds=i([he("alarmo-chip-set")],Ds);let Ps=class extends ce{set filters(e){this.filterConfig||(this.filterConfig=e)}shouldUpdate(e){return e.get("filters")&&!this.filterConfig&&(this.filterConfig=e.get("filters")),!0}render(){if(!this.columns||!this.data)return K``;const e=this.data.filter(e=>this.filterTableData(e,this.filterConfig));return K` + ${this.renderFilterRow()} +
+ ${this.renderHeaderRow()} + ${e.length?e.map(e=>this.renderDataRow(e)):K` +
+
+ +
+
+ `} +
+ `}renderHeaderRow(){return this.columns?K` +
+ ${Object.values(this.columns).map(e=>e.hide?"":K` +
+ ${e.title||""} +
+ `)} +
+ `:K``}renderDataRow(e){return this.columns?K` +
this.handleClick(String(e.id))} + > + ${Object.entries(this.columns).map(([a,t])=>t.hide?"":K` +
+ ${t.renderer?t.renderer(e):e[a]} +
+ `)} +
+ `:K``}filterTableData(e,a){return!a||Object.keys(a).every(t=>{if(!Object.keys(e).includes(t))return!0;const i=a[t].value;return!i||!i.length||(Array.isArray(e[t])?e[t].some(e=>i.includes(e)):i.includes(e[t]))})}_getFilteredItems(){return this.data.filter(e=>!this.filterTableData(e,this.filterConfig)).length}handleClick(e){if(!this.selectable)return;const a=new CustomEvent("row-click",{detail:{id:e}});this.dispatchEvent(a)}renderFilterRow(){var e;return this.filterConfig?K` +
+ + + ${this.renderFilterMenu()} + + + ${this._getFilteredItems()?K` + + ${zn("components.table.filter.hidden_items",this.hass.language,"number",this._getFilteredItems())} + + `:""} +
+ `:K``}_showFilterMenu(){this.filterSelection=Object.entries(this.filterConfig).reduce((e,[a,t])=>Object.assign(Object.assign({},e),{[a]:is(t,["value"])}),{})}renderFilterMenu(){return this.filterConfig&&this.filterSelection?K` + + ${zn("components.table.filter.label",this.hass.language)} + + {e.target.parentElement.parentElement.querySelector("ha-icon-button").click()}} + > + ${Object.keys(this.filterConfig).map(e=>{if(this.filterConfig[e].binary)return K` + + `;let a=this.filterConfig[e].items;a=a.map(a=>{var t;return a.badge&&"function"==typeof a.badge?Object.assign(Object.assign({},a),{badge:a.badge(null===(t=this.data)||void 0===t?void 0:t.filter(a=>this.filterTableData(a,ns(this.filterSelection,e))))}):a});const t=this.filterSelection[e].value;return K` + + `})} + `:K``}_updateFilterSelection(e,a){"boolean"==typeof a&&(a=a?this.filterConfig[e].items[0].value:[],1==Object.keys(this.filterConfig).length&&(this._menu.open=!1)),this.filterSelection=Object.assign(Object.assign({},this.filterSelection),{[e]:{value:a}})}_clearFilters(){Object.keys(this.filterConfig).forEach(e=>{this.filterConfig=Object.assign(Object.assign({},this.filterConfig),{[e]:Object.assign(Object.assign({},this.filterConfig[e]),{value:[]})})})}_applyFilterSelection(){Object.keys(this.filterConfig).forEach(e=>{this.filterConfig=Object.assign(Object.assign({},this.filterConfig),{[e]:Object.assign(Object.assign({},this.filterConfig[e]),this.filterSelection[e])})})}};Ps.styles=c` + :host { + width: 100%; + } + div.table { + display: inline-flex; + flex-direction: column; + box-sizing: border-box; + width: 100%; + } + div.table .header { + font-weight: bold; + } + div.table-row { + display: flex; + width: 100%; + height: 52px; + border-top: 1px solid var(--divider-color); + flex-direction: row; + position: relative; + } + div.table-cell { + align-self: center; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; + box-sizing: border-box; + } + div.table-cell.text { + padding: 4px 16px; + } + div.table-cell.grow { + flex-grow: 1; + flex-shrink: 1; + } + + div.table-cell > ha-switch { + width: 68px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + } + + div.table-cell.center { + display: flex; + align-items: center; + justify-content: center; + } + + div.table-cell.right { + display: flex; + align-items: center; + justify-content: flex-end; + } + + div.table-cell > ha-icon-button { + color: var(--secondary-text-color); + } + div.table-cell > ha-checkbox { + display: flex; + align-items: center; + } + div.table-cell > * { + transition: color 0.2s ease-in-out; + } + div.table .header div.table-cell span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + div.table-row.selectable { + cursor: pointer; + } + .table-row::before { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + opacity: 0.12; + pointer-events: none; + content: ''; + border-radius: 4px; + } + div.table-row.selectable:hover::before { + background-color: rgba(var(--rgb-primary-text-color), 0.5); + } + div.table-row.warning::before { + background-color: var(--error-color); + opacity: 0.06; + } + div.table-row.warning:hover::before { + background-color: var(--error-color); + opacity: 0.12; + } + div.table-row.warning span { + color: var(--error-color); + } + + ha-icon, ha-svg-icon { + color: var(--state-icon-color); + padding: 8px; + } + + .secondary { + color: var(--secondary-text-color); + display: flex; + padding-top: 4px; + } + + a, + a:visited { + color: var(--primary-color); + } + + span.disabled { + color: var(--secondary-text-color); + } + span.secondary.disabled { + color: var(--disabled-text-color); + } + ha-icon.disabled, ha-svg-icon.disabled { + color: var(--state-unavailable-color); + } + + div.table-filter { + display: flex; + width: 100%; + min-height: 52px; + border-top: 1px solid var(--divider-color); + box-sizing: border-box; + padding: 2px 8px; + flex: 1; + position: relative; + flex-direction: row; + align-items: center; + } + ha-dropdown .header { + display: flex; + padding: 8px 16px; + font-weight: bold; + } + ha-dropdown ha-icon-button.close { + position: absolute; + top: 8px; + right: 8px; + } + div.dropdown-item { + display: flex; + flex-direction: column; + flex-shrink: 0; + padding: 8px 16px; + width: 100%; + min-height: 52px; + box-sizing: border-box; + } + div.dropdown-item .name { + display: inline-flex; + } + div.dropdown-item alarmo-chips { + display: flex; + flex-direction: row; + } + div.dropdown-item.checkbox { + flex-direction: row; + align-items: center; + } + `,i([ue()],Ps.prototype,"hass",void 0),i([ue()],Ps.prototype,"columns",void 0),i([ue()],Ps.prototype,"data",void 0),i([ve()],Ps.prototype,"filterConfig",void 0),i([ve()],Ps.prototype,"filterSelection",void 0),i([ue({type:Boolean})],Ps.prototype,"selectable",void 0),i([_e("ha-dropdown")],Ps.prototype,"_menu",void 0),Ps=i([he("alarmo-table")],Ps);let Bs=class extends ce{async showDialog(e){this._params=e,await this.updateComplete}async closeDialog(){this._params&&this._params.cancel(),this._params=void 0}render(){return this._params?K` + + + +
${this._params.title}
+
+
+ ${this._params.description} +
+ + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.ok")} + + +
+ `:K``}confirmClick(){this._params.confirm()}cancelClick(){this._params.cancel()}static get styles(){return c` + ${vs} + div.wrapper { + color: var(--primary-text-color); + } + `}};i([ue({attribute:!1})],Bs.prototype,"hass",void 0),i([ve()],Bs.prototype,"_params",void 0),Bs=i([he("confirm-delete-dialog")],Bs);var qs=Object.freeze({__proto__:null,get ConfirmDeleteDialog(){return Bs}});let Vs=class extends(Me(ce)){constructor(){super(...arguments),this.areas={},this.sensors={},this.automations={},this.name=""}hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){this.hass&&(this.areas=await Ce(this.hass),this.sensors=await ke(this.hass),this.automations=await ze(this.hass))}async showDialog(e){await this._fetchData(),this._params=e,e.area_id&&(this.area_id=e.area_id,this.name=this.areas[this.area_id].name),await this.updateComplete}async closeDialog(){this._params=void 0,this.area_id=void 0,this.name=""}render(){return this._params?K` + + + +
+ ${this.area_id?zn("panels.general.dialogs.edit_area.title",this.hass.language,"{area}",this.areas[this.area_id].name):zn("panels.general.dialogs.create_area.title",this.hass.language)} +
+
+
+ this.name=e.target.value} + value="${this.name}" + > + ${this.area_id?K` + + ${zn("panels.general.dialogs.edit_area.name_warning",this.hass.language)} + + `:""} + ${this.area_id?"":K` + Object({value:e.area_id,name:e.name}))} + value=${this.selectedArea} + label="${zn("panels.general.dialogs.create_area.fields.copy_from",this.hass.language)}" + clearable=${!0} + @value-changed=${e=>this.selectedArea=e.target.value} + > + `} +
+ + + ${this.hass.localize("ui.common.save")} + + ${this.area_id?K` + + ${this.hass.localize("ui.common.delete")} + + `:""} + +
+ `:K``}saveClick(e){const a=this.name.trim();if(!a.length)return;let t={name:a};this.area_id?t=Object.assign(Object.assign({},t),{area_id:this.area_id}):this.selectedArea&&(t=Object.assign(Object.assign({},t),{modes:Object.assign({},this.areas[this.selectedArea].modes)})),Oe(this.hass,t).catch(a=>ds(a,e)).then(()=>{this.closeDialog()})}async deleteClick(e){if(!this.area_id)return;const a=Object.values(this.sensors).filter(e=>e.area==this.area_id).length,t=Object.values(this.automations).filter(e=>{var a;return null===(a=e.triggers)||void 0===a?void 0:a.map(e=>e.area).includes(this.area_id)}).length;let i=!1;var n,s;i=!a&&!t||await new Promise(i=>{Xn(e.target,"show-dialog",{dialogTag:"confirm-delete-dialog",dialogImport:()=>Promise.resolve().then(function(){return qs}),dialogParams:{title:zn("panels.general.dialogs.remove_area.title",this.hass.language),description:zn("panels.general.dialogs.remove_area.description",this.hass.language,"sensors",String(a),"automations",String(t)),cancel:()=>i(!1),confirm:()=>i(!0)}})}),i&&(n=this.hass,s=this.area_id,n.callApi("POST","alarmo/area",{area_id:s,remove:!0})).catch(a=>ds(a,e)).then(()=>{this.closeDialog()})}static get styles(){return c` + ${vs} + div.wrapper { + color: var(--primary-text-color); + } + span.note { + color: var(--secondary-text-color); + } + ha-input { + display: block; + } + alarmo-select { + margin-top: 10px; + } + `}};i([ue({attribute:!1})],Vs.prototype,"hass",void 0),i([ve()],Vs.prototype,"_params",void 0),i([ue()],Vs.prototype,"areas",void 0),i([ue()],Vs.prototype,"sensors",void 0),i([ue()],Vs.prototype,"automations",void 0),i([ue()],Vs.prototype,"name",void 0),i([ue()],Vs.prototype,"area_id",void 0),i([ue()],Vs.prototype,"selectedArea",void 0),Vs=i([he("create-area-dialog")],Vs);var Is=Object.freeze({__proto__:null,get CreateAreaDialog(){return Vs}});let Rs=class extends(Me(ce)){constructor(){super(...arguments),this.areas={},this.sensors={},this.automations={}}hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){this.hass&&(this.areas=await Ce(this.hass),this.sensors=await ke(this.hass),this.automations=await ze(this.hass))}render(){if(!this.hass)return K``;const e=Object.values(this.areas);e.sort(ms);const a={actions:{width:"48px"},name:{title:this.hass.localize("ui.common.name"),width:"40%",grow:!0,text:!0},remarks:{title:zn("panels.general.cards.areas.table.remarks",this.hass.language),width:"60%",hide:this.narrow,text:!0}},t=Object.values(e).map(a=>{const t=Object.values(this.sensors).filter(e=>e.area==a.area_id).length,i=1==Object.values(e).length?Object.values(this.automations).filter(e=>{var t,i;return(null===(t=e.triggers)||void 0===t?void 0:t.map(e=>e.area).includes(a.area_id))||!(null===(i=e.triggers)||void 0===i?void 0:i.map(e=>e.area).length)}).length:Object.values(this.automations).filter(e=>{var t;return null===(t=e.triggers)||void 0===t?void 0:t.map(e=>e.area).includes(a.area_id)}).length,n=`${zn("panels.general.cards.areas.table.summary_sensors",this.hass.language,"number",t)}`,s=`${zn("panels.general.cards.areas.table.summary_automations",this.hass.language,"number",i)}`;return{id:a.area_id,actions:K` + this.editClick(e,a.area_id)} .path=${"M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z"}> + `,name:Jn(a.name),remarks:Ls(zn("panels.general.cards.areas.table.summary",this.hass.language,"summary_sensors",n,"summary_automations",s))}});return K` + +
+ ${zn("panels.general.cards.areas.description",this.hass.language)} +
+ + + ${zn("panels.general.cards.areas.no_items",this.hass.language)} + +
+ + ${zn("panels.general.cards.areas.actions.add",this.hass.language)} + +
+
+ `}addClick(e){const a=e.target;Xn(a,"show-dialog",{dialogTag:"create-area-dialog",dialogImport:()=>Promise.resolve().then(function(){return Is}),dialogParams:{}})}editClick(e,a){const t=e.target;Xn(t,"show-dialog",{dialogTag:"create-area-dialog",dialogImport:()=>Promise.resolve().then(function(){return Is}),dialogParams:{area_id:a}})}};Rs.styles=vs,i([ue()],Rs.prototype,"narrow",void 0),i([ue()],Rs.prototype,"path",void 0),i([ue()],Rs.prototype,"config",void 0),i([ue()],Rs.prototype,"areas",void 0),i([ue()],Rs.prototype,"sensors",void 0),i([ue()],Rs.prototype,"automations",void 0),Rs=i([he("area-config-card")],Rs);let Us=class extends ce{constructor(){super(...arguments),this.name=""}async showDialog(e){this._params=e;const a=await ye(this.hass);this.name=a.master.name||"",await this.updateComplete}async closeDialog(){this._params=void 0}render(){return this._params?K` + + + +
${zn("panels.general.dialogs.edit_master.title",this.hass.language)}
+
+
+ this.name=e.target.value} + value="${this.name}" + > + ${zn("panels.general.dialogs.edit_area.name_warning",this.hass.language)} +
+ + + ${this.hass.localize("ui.common.save")} + + + ${this.hass.localize("ui.common.cancel")} + + +
+ `:K``}saveClick(){const e=this.name.trim();e.length&&je(this.hass,{master:{enabled:!0,name:e}}).catch().then(()=>{this.closeDialog()})}static get styles(){return c` + div.wrapper { + color: var(--primary-text-color); + } + span.note { + color: var(--secondary-text-color); + } + ha-input { + display: block; + } + `}};i([ue({attribute:!1})],Us.prototype,"hass",void 0),i([ve()],Us.prototype,"_params",void 0),i([ue()],Us.prototype,"name",void 0),Us=i([he("edit-master-dialog")],Us);var Gs=Object.freeze({__proto__:null,get EditMasterDialog(){return Us}});let Ks=class extends(Me(ce)){constructor(){super(...arguments),this.areas={},this.automations={}}hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){this.hass&&(this.config=await ye(this.hass),this.areas=await Ce(this.hass),this.automations=await ze(this.hass),this.data=is(this.config,["trigger_time","disarm_after_trigger","ignore_blocking_sensors_after_trigger","mqtt","master"]))}firstUpdated(){(async()=>{await be()})()}render(){var e,a,t,i,n,s,r,o,d;return this.hass&&this.config&&this.data?"mqtt_configuration"==this.path.subpage?K` + + `:this.path.params.edit_area?K` + + `:K` + +
+ ${zn("panels.general.cards.general.description",this.hass.language)} +
+ + + + ${zn("panels.general.cards.general.fields.disarm_after_trigger.heading",this.hass.language)} + + + ${zn("panels.general.cards.general.fields.disarm_after_trigger.description",this.hass.language)} + + {this.saveData({disarm_after_trigger:e.target.checked})}} + > + + + ${!1===(null===(e=this.data)||void 0===e?void 0:e.disarm_after_trigger)?K` + + + ${zn("panels.general.cards.general.fields.ignore_blocking_sensors_after_trigger.heading",this.hass.language)} + + + ${zn("panels.general.cards.general.fields.ignore_blocking_sensors_after_trigger.description",this.hass.language)} + + {this.saveData({ignore_blocking_sensors_after_trigger:e.target.checked})}} + > + + `:""} + + + + ${zn("panels.general.cards.general.fields.enable_mqtt.heading",this.hass.language)} + + + ${zn("panels.general.cards.general.fields.enable_mqtt.description",this.hass.language)} + + {this.saveData({mqtt:Object.assign(Object.assign({},this.data.mqtt),{enabled:e.target.checked})})}} + > + + + ${(null===(n=null===(i=this.data)||void 0===i?void 0:i.mqtt)||void 0===n?void 0:n.enabled)?K` +
+ hs(0,fs("general","mqtt_configuration"),!0)} + > + ${zn("panels.general.cards.general.actions.setup_mqtt",this.hass.language)} + + +
+ `:""} + ${Object.keys(this.areas).length>=2?K` + + + ${zn("panels.general.cards.general.fields.enable_master.heading",this.hass.language)} + + + ${zn("panels.general.cards.general.fields.enable_master.description",this.hass.language)} + + =2} + ?disabled=${Object.keys(this.areas).length<2} + @change=${this.toggleEnableMaster} + > + + `:""} + ${(null===(d=null===(o=this.data)||void 0===o?void 0:o.master)||void 0===d?void 0:d.enabled)&&Object.keys(this.areas).length>=2?K` +
+ + ${zn("panels.general.cards.general.actions.setup_master",this.hass.language)} + + +
+ `:""} +
+ + + + + `:K``}setupMasterClick(e){const a=e.target;Xn(a,"show-dialog",{dialogTag:"edit-master-dialog",dialogImport:()=>Promise.resolve().then(function(){return Gs}),dialogParams:{}})}async toggleEnableMaster(e){const a=e.target;let t=a.checked;if(!t){const i=Object.values(this.automations).filter(e=>e.triggers.some(e=>!e.area));if(i.length){await new Promise(e=>{Xn(a,"show-dialog",{dialogTag:"confirm-delete-dialog",dialogImport:()=>Promise.resolve().then(function(){return qs}),dialogParams:{title:zn("panels.general.dialogs.disable_master.title",this.hass.language),description:zn("panels.general.dialogs.disable_master.description",this.hass.language,"automations",String(i.length)),cancel:()=>e(!1),confirm:()=>e(!0)}})})?!t&&i.length&&i.forEach(a=>{Se(this.hass,a.automation_id).catch(a=>ds(a,e))}):(t=!0,a.checked=!0)}}this.saveData({master:Object.assign(Object.assign({},this.data.master),{enabled:t})})}saveData(e){this.hass&&this.data&&(this.data=Object.assign(Object.assign({},this.data),e),je(this.hass,this.data).catch(e=>ds(e,this.shadowRoot.querySelector("ha-card"))).then())}};Ks.styles=vs,i([ue()],Ks.prototype,"narrow",void 0),i([ue()],Ks.prototype,"path",void 0),i([ue()],Ks.prototype,"data",void 0),i([ue()],Ks.prototype,"config",void 0),i([ue()],Ks.prototype,"areas",void 0),i([ue()],Ks.prototype,"automations",void 0),Ks=i([he("alarm-view-general")],Ks);const Fs=(e,a)=>{const t=function(e){const a="string"==typeof e?e:e.entity_id;return String(a.split(".").shift())}(e.entity_id);if("binary_sensor"==t){if(a)return!0;const t=e.attributes.device_class;return!!t&&!!["carbon_monoxide","door","garage_door","gas","heat","lock","moisture","motion","moving","occupancy","opening","presence","safety","smoke","sound","tamper","vibration","window"].includes(t)}return!1},Zs=e=>{switch(e.attributes.device_class){case"door":case"garage_door":case"lock":case"opening":return Kn.Door;case"window":return Kn.Window;case"carbon_monoxide":case"gas":case"heat":case"moisture":case"smoke":case"safety":return Kn.Environmental;case"motion":case"moving":case"occupancy":case"presence":return Kn.Motion;case"sound":case"vibration":case"tamper":return Kn.Tamper;default:return}},Qs=e=>{const a=a=>a.filter(a=>e.includes(a));return{[Kn.Door]:{modes:a([Yn.ArmedAway,Yn.ArmedHome,Yn.ArmedNight,Yn.ArmedVacation]),always_on:!1,allow_open:!1,arm_on_close:!1,use_entry_delay:!0,use_exit_delay:!0},[Kn.Window]:{modes:a([Yn.ArmedAway,Yn.ArmedHome,Yn.ArmedNight,Yn.ArmedVacation]),always_on:!1,allow_open:!1,arm_on_close:!1,use_entry_delay:!1,use_exit_delay:!1},[Kn.Motion]:{modes:a([Yn.ArmedAway,Yn.ArmedVacation]),always_on:!1,allow_open:!0,arm_on_close:!1,use_entry_delay:!0,use_exit_delay:!0},[Kn.Tamper]:{modes:a([Yn.ArmedAway,Yn.ArmedHome,Yn.ArmedNight,Yn.ArmedVacation,Yn.ArmedCustom]),always_on:!1,allow_open:!1,arm_on_close:!1,use_entry_delay:!1,use_exit_delay:!1},[Kn.Environmental]:{modes:a([Yn.ArmedAway,Yn.ArmedHome,Yn.ArmedNight,Yn.ArmedVacation,Yn.ArmedCustom]),always_on:!0,allow_open:!1,arm_on_close:!1,use_entry_delay:!1,use_exit_delay:!1}}};const Ys=(e,a,t=!1)=>{const i=Object.values(e.states).filter(e=>Fs(e,t)).filter(e=>!a.includes(e.entity_id)).map(e=>Object({id:e.entity_id,name:es(e),icon:Ws(e)}));return i.sort(ms),i},Ws=(e,a)=>{const t="off"===a;switch(null==e?void 0:e.attributes.device_class){case"battery":return t?jn:"M16,20H8V6H16M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.67C6,21.4 6.6,22 7.33,22H16.67A1.33,1.33 0 0,0 18,20.67V5.33C18,4.6 17.4,4 16.67,4Z";case"battery_charging":return t?jn:"M16.67,4H15V2H9V4H7.33A1.33,1.33 0 0,0 6,5.33V20.66C6,21.4 6.6,22 7.33,22H16.66C17.4,22 18,21.4 18,20.67V5.33C18,4.6 17.4,4 16.67,4M11,20V14.5H9L13,7V12.5H15";case"cold":return t?Bn:"M20.79,13.95L18.46,14.57L16.46,13.44V10.56L18.46,9.43L20.79,10.05L21.31,8.12L19.54,7.65L20,5.88L18.07,5.36L17.45,7.69L15.45,8.82L13,7.38V5.12L14.71,3.41L13.29,2L12,3.29L10.71,2L9.29,3.41L11,5.12V7.38L8.5,8.82L6.5,7.69L5.92,5.36L4,5.88L4.47,7.65L2.7,8.12L3.22,10.05L5.55,9.43L7.55,10.56V13.45L5.55,14.58L3.22,13.96L2.7,15.89L4.47,16.36L4,18.12L5.93,18.64L6.55,16.31L8.55,15.18L11,16.62V18.88L9.29,20.59L10.71,22L12,20.71L13.29,22L14.7,20.59L13,18.88V16.62L15.5,15.17L17.5,16.3L18.12,18.63L20,18.12L19.53,16.35L21.3,15.88L20.79,13.95M9.5,10.56L12,9.11L14.5,10.56V13.44L12,14.89L9.5,13.44V10.56Z";case"connectivity":return t?"M13,19H14A1,1 0 0,1 15,20H15.73L13,17.27V19M22,20V21.18L20.82,20H22M21,22.72L19.73,24L17.73,22H15A1,1 0 0,1 14,23H10A1,1 0 0,1 9,22H2V20H9A1,1 0 0,1 10,19H11V17H4A1,1 0 0,1 3,16V12A1,1 0 0,1 4,11H6.73L4.73,9H4A1,1 0 0,1 3,8V7.27L1,5.27L2.28,4L21,22.72M4,3H20A1,1 0 0,1 21,4V8A1,1 0 0,1 20,9H9.82L7,6.18V5H5.82L3.84,3C3.89,3 3.94,3 4,3M20,11A1,1 0 0,1 21,12V16A1,1 0 0,1 20,17H17.82L11.82,11H20M9,7H10V5H9V7M9,15H10V14.27L9,13.27V15M5,13V15H7V13H5Z":"M13,19H14A1,1 0 0,1 15,20H22V22H15A1,1 0 0,1 14,23H10A1,1 0 0,1 9,22H2V20H9A1,1 0 0,1 10,19H11V17H4A1,1 0 0,1 3,16V12A1,1 0 0,1 4,11H20A1,1 0 0,1 21,12V16A1,1 0 0,1 20,17H13V19M4,3H20A1,1 0 0,1 21,4V8A1,1 0 0,1 20,9H4A1,1 0 0,1 3,8V4A1,1 0 0,1 4,3M9,7H10V5H9V7M9,15H10V13H9V15M5,5V7H7V5H5M5,13V15H7V13H5Z";case"door":return t?Cn:On;case"garage_door":return t?"M19,20H17V11H7V20H5V9L12,5L19,9V20M8,12H16V14H8V12M8,15H16V17H8V15M16,18V20H8V18H16Z":"M19,20H17V11H7V20H5V9L12,5L19,9V20M8,12H16V14H8V12Z";case"power":case"plug":return t?Pn:Dn;case"gas":case"problem":case"safety":case"tamper":return t?$n:An;case"smoke":return t?$n:"M17 19V22H15V19C15 17.9 14.1 17 13 17H10C7.2 17 5 14.8 5 12C5 10.8 5.4 9.8 6.1 8.9C3.8 8.5 2 6.4 2 4C2 3.3 2.2 2.6 2.4 2H4.8C4.3 2.5 4 3.2 4 4C4 5.7 5.3 7 7 7H10V9C8.3 9 7 10.3 7 12S8.3 15 10 15H13C15.2 15 17 16.8 17 19M17.9 8.9C20.2 8.5 22 6.4 22 4C22 3.3 21.8 2.6 21.6 2H19.2C19.7 2.5 20 3.2 20 4C20 5.7 18.7 7 17 7H15.8C15.9 7.3 16 7.6 16 8C16 9.7 14.7 11 13 11V13C15.8 13 18 15.2 18 18V22H20V18C20 15.3 18.5 13 16.2 11.8C17.1 11.1 17.7 10.1 17.9 8.9Z";case"heat":return t?Bn:Mn;case"light":return t?"M12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6A6,6 0 0,1 18,12A6,6 0 0,1 12,18M20,15.31L23.31,12L20,8.69V4H15.31L12,0.69L8.69,4H4V8.69L0.69,12L4,15.31V20H8.69L12,23.31L15.31,20H20V15.31Z":"M12,8A4,4 0 0,0 8,12A4,4 0 0,0 12,16A4,4 0 0,0 16,12A4,4 0 0,0 12,8M12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6A6,6 0 0,1 18,12A6,6 0 0,1 12,18M20,8.69V4H15.31L12,0.69L8.69,4H4V8.69L0.69,12L4,15.31V20H8.69L12,23.31L15.31,20H20V15.31L23.31,12L20,8.69Z";case"lock":return t?Ln:Hn;case"moisture":return t?"M20.84 22.73L16.29 18.18C15.2 19.3 13.69 20 12 20C8.69 20 6 17.31 6 14C6 12.67 6.67 11.03 7.55 9.44L1.11 3L2.39 1.73L22.11 21.46L20.84 22.73M18 14C18 10 12 3.25 12 3.25S10.84 4.55 9.55 6.35L17.95 14.75C18 14.5 18 14.25 18 14Z":"M12,20A6,6 0 0,1 6,14C6,10 12,3.25 12,3.25C12,3.25 18,10 18,14A6,6 0 0,1 12,20Z";case"motion":return t?"M14.12,10H19V8.2H15.38L13.38,4.87C13.08,4.37 12.54,4.03 11.92,4.03C11.74,4.03 11.58,4.06 11.42,4.11L6,5.8V11H7.8V7.33L9.91,6.67L6,22H7.8L10.67,13.89L13,17V22H14.8V15.59L12.31,11.05L13.04,8.18M14,3.8C15,3.8 15.8,3 15.8,2C15.8,1 15,0.2 14,0.2C13,0.2 12.2,1 12.2,2C12.2,3 13,3.8 14,3.8Z":"M13.5,5.5C14.59,5.5 15.5,4.58 15.5,3.5C15.5,2.38 14.59,1.5 13.5,1.5C12.39,1.5 11.5,2.38 11.5,3.5C11.5,4.58 12.39,5.5 13.5,5.5M9.89,19.38L10.89,15L13,17V23H15V15.5L12.89,13.5L13.5,10.5C14.79,12 16.79,13 19,13V11C17.09,11 15.5,10 14.69,8.58L13.69,7C13.29,6.38 12.69,6 12,6C11.69,6 11.5,6.08 11.19,6.08L6,8.28V13H8V9.58L9.79,8.88L8.19,17L3.29,16L2.89,18L9.89,19.38Z";case"occupancy":case"presence":return t?Nn:xn;case"opening":return t?"M3,3V21H21V3":"M3,3H21V21H3V3M5,5V19H19V5H5Z";case"running":return t?"M18,18H6V6H18V18Z":"M8,5.14V19.14L19,12.14L8,5.14Z";case"sound":return t?"M4.27 3L3 4.27L12 13.27V13.55C11.41 13.21 10.73 13 10 13C7.79 13 6 14.79 6 17S7.79 21 10 21 14 19.21 14 17V15.27L19.73 21L21 19.73L4.27 3M14 7H18V3H12V8.18L14 10.18Z":"M12 3V13.55C11.41 13.21 10.73 13 10 13C7.79 13 6 14.79 6 17S7.79 21 10 21 14 19.21 14 17V7H18V3H12Z";case"update":return t?"M5.12,5H18.87L17.93,4H5.93L5.12,5M20.54,5.23C20.83,5.57 21,6 21,6.5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V6.5C3,6 3.17,5.57 3.46,5.23L4.84,3.55C5.12,3.21 5.53,3 6,3H18C18.47,3 18.88,3.21 19.15,3.55L20.54,5.23M6,18H12V15H6V18Z":"M20.54,5.23C20.83,5.57 21,6 21,6.5V19A2,2 0 0,1 19,21H5A2,2 0 0,1 3,19V6.5C3,6 3.17,5.57 3.46,5.23L4.84,3.55C5.12,3.21 5.53,3 6,3H18C18.47,3 18.88,3.21 19.15,3.55L20.54,5.23M5.12,5H18.87L17.93,4H5.93L5.12,5M12,9.5L6.5,15H10V17H14V15H17.5L12,9.5Z";case"vibration":return t?Sn:qn;case"window":return t?Vn:In;default:return t?"M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z":"M10,17L5,12L6.41,10.58L10,14.17L17.59,6.58L19,8M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"}};let Xs=class extends ce{async showDialog(e){this._params=e,await this.updateComplete}async closeDialog(){this._params=void 0}render(){return this._params?K` + + + +
${this.hass.localize("state_badge.default.error")}
+
+
+ ${this._params.error||""} +
+ + + ${this.hass.localize("ui.common.ok")} + + +
+ `:K``}static get styles(){return c` + div.wrapper { + color: var(--primary-text-color); + } + `}};i([ue({attribute:!1})],Xs.prototype,"hass",void 0),i([ve()],Xs.prototype,"_params",void 0),Xs=i([he("error-dialog")],Xs);var Js=Object.freeze({__proto__:null,get ErrorDialog(){return Xs}});let er=class extends(Me(ce)){constructor(){super(...arguments),this.sensorGroups={},this.sensors={}}hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){this.hass&&(this.sensorGroups=await Ae(this.hass),this.sensors=await ke(this.hass))}async showDialog(e){await this._fetchData(),this._params=e,e.group_id&&Object.keys(this.sensorGroups).includes(e.group_id)?this.data=Object.assign({},this.sensorGroups[e.group_id]):this.data={name:"",entities:[],timeout:600,event_count:2},await this.updateComplete}async closeDialog(){this._params=void 0}render(){return this._params?K` + + + +
+ ${this.data.group_id?zn("panels.sensors.dialogs.edit_group.title",this.hass.language,"{name}",this.sensorGroups[this.data.group_id].name):zn("panels.sensors.dialogs.create_group.title",this.hass.language)} +
+
+
+ + + ${zn("panels.sensors.dialogs.create_group.fields.name.heading",this.hass.language)} + + + ${zn("panels.sensors.dialogs.create_group.fields.name.description",this.hass.language)} + + this.data=Object.assign(Object.assign({},this.data),{name:String(e.target.value).trim()})} + value="${this.data.name}" + > + + + + + ${zn("panels.sensors.dialogs.create_group.fields.sensors.heading",this.hass.language)} + + + ${zn("panels.sensors.dialogs.create_group.fields.sensors.description",this.hass.language)} + +
+ ${this.renderSensorOptions()} +
+
+ + + + ${zn("panels.sensors.dialogs.create_group.fields.timeout.heading",this.hass.language)} + + + ${zn("panels.sensors.dialogs.create_group.fields.timeout.description",this.hass.language)} + + this.data=Object.assign(Object.assign({},this.data),{timeout:e.detail.value})} + > + + + ${this.data.entities.length>2?K` + + + ${zn("panels.sensors.dialogs.create_group.fields.event_count.heading",this.hass.language)} + + + ${zn("panels.sensors.dialogs.create_group.fields.event_count.description",this.hass.language)} + + {this.data=Object.assign(Object.assign({},this.data),{event_count:Number(e.detail.value)})}} + .value=${String(this.data.event_count>this.data.entities.length?this.data.entities.length:this.data.event_count)} + > + + `:""} +
+ + + ${this.hass.localize("ui.common.save")} + + ${this.data.group_id?K` + + ${this.hass.localize("ui.common.delete")} + + `:""} + +
+ `:K``}renderSensorOptions(){const e=Object.keys(this.sensors).filter(e=>!ss(this.sensors[e].group)||this.sensors[e].group===this.data.group_id).map(e=>{const a=this.hass.states[e],t=Object.entries(Kn).find(([,a])=>a==this.sensors[e].type)[0];return{value:e,name:Jn(es(a)),icon:Fn[t]}});return e.sort(ms),e.length?K` + this.data=Object.assign(Object.assign({},this.data),{entities:e.detail})} + > + `:zn("panels.sensors.cards.sensors.no_items",this.hass.language)}renderSensorCountOptions(){let e=[];for(let a=2;a<=this.data.entities.length;a++)e=[...e,{name:`${a}`,value:`${a}`}];return e}saveClick(e){var a,t;this.data.name.length&&(this.data.group_id&&this.data.name==this.sensorGroups[this.data.group_id].name||!Object.values(this.sensorGroups).find(e=>e.name.toLowerCase()==this.data.name.toLowerCase()))?this.data.entities.length<2?os(e,zn("panels.sensors.dialogs.create_group.errors.insufficient_sensors",this.hass.language)):(this.data.event_count>this.data.entities.length&&(this.data=Object.assign(Object.assign({},this.data),{event_count:this.data.entities.length})),(a=this.hass,t=this.data,a.callApi("POST","alarmo/sensor_groups",t)).catch(a=>ds(a,e)).then(()=>{this.closeDialog()})):os(e,zn("panels.sensors.dialogs.create_group.errors.invalid_name",this.hass.language))}deleteClick(e){var a,t;this.data.group_id&&(a=this.hass,t=this.data.group_id,a.callApi("POST","alarmo/sensor_groups",{group_id:t,remove:!0})).catch(a=>ds(a,e)).then(()=>{this.closeDialog()})}static get styles(){return c` + ${_s} + div.wrapper { + color: var(--primary-text-color); + } + `}};i([ue({attribute:!1})],er.prototype,"hass",void 0),i([ve()],er.prototype,"_params",void 0),i([ue()],er.prototype,"sensorGroups",void 0),i([ue()],er.prototype,"sensors",void 0),i([ue()],er.prototype,"data",void 0),er=i([he("create-sensor-group-dialog")],er);var ar=Object.freeze({__proto__:null,get CreateSensorGroupDialog(){return er}});let tr=class extends(Me(ce)){constructor(){super(...arguments),this.sensorGroups={},this.sensors={}}hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){this.hass&&(this.sensorGroups=await Ae(this.hass),this.sensors=await ke(this.hass))}async showDialog(e){await this._fetchData(),this._params=e,await this.updateComplete}async closeDialog(){this._params=void 0}render(){return this._params?K` + + + +
${zn("panels.sensors.dialogs.manage_groups.title",this.hass.language)}
+
+
+
+ ${zn("panels.sensors.dialogs.manage_groups.description",this.hass.language)} +
+
+ ${Object.keys(this.sensorGroups).length?Object.values(this.sensorGroups).map(e=>this.renderGroup(e)):zn("panels.sensors.dialogs.manage_groups.no_items",this.hass.language)} +
+
+ + + ${zn("panels.sensors.dialogs.manage_groups.actions.new_group",this.hass.language)} + +
+ `:K``}renderGroup(e){return K` + this.editGroupClick(a,e.group_id)} + > + +
+ ${e.name} + ${zn("panels.general.cards.areas.table.summary_sensors",this.hass.language,"{number}",String(e.entities.length))} +
+ + +
+ `}createGroupClick(e){const a=e.target;Xn(a,"show-dialog",{dialogTag:"create-sensor-group-dialog",dialogImport:()=>Promise.resolve().then(function(){return ar}),dialogParams:{}})}editGroupClick(e,a){const t=e.target;Xn(t,"show-dialog",{dialogTag:"create-sensor-group-dialog",dialogImport:()=>Promise.resolve().then(function(){return ar}),dialogParams:{group_id:a}})}static get styles(){return c` + ${_s} + + div.wrapper { + color: var(--primary-text-color); + } + div.container { + display: flex; + flex-wrap: wrap; + } + ha-card { + width: 100%; + text-align: center; + margin: 4px; + box-sizing: border-box; + padding: 8px; + color: var(--primary-text-color); + font-size: 16px; + cursor: pointer; + display: flex; + flex-direction: row; + } + ha-card:hover { + background: rgba(var(--rgb-secondary-text-color), 0.1); + } + ha-card ha-icon { + --mdc-icon-size: 24px; + display: flex; + flex: 0 0 40px; + margin: 0px 10px; + align-items: center; + color: var(--state-icon-color); + } + ha-card ha-icon-button { + --mdc-icon-size: 24px; + display: flex; + flex: 0 0 40px; + margin: 0px 10px; + align-items: center; + } + ha-card div { + display: flex; + flex-wrap: wrap; + flex: 1; + } + ha-card span { + display: flex; + flex: 0 0 100%; + } + ha-card span.description { + color: var(--secondary-text-color); + } + ha-button ha-icon { + padding-right: 11px; + } + `}};i([ue({attribute:!1})],tr.prototype,"hass",void 0),i([ve()],tr.prototype,"_params",void 0),i([ue()],tr.prototype,"sensorGroups",void 0),i([ue()],tr.prototype,"sensors",void 0),tr=i([he("manage-sensor-groups-dialog")],tr);var ir=Object.freeze({__proto__:null,get ManageSensorGroupsDialog(){return tr}});let nr=class extends(Me(ce)){constructor(){super(...arguments),this.showBypassModes=!1,this.sensorsList=[],this.entityIdUnlocked=!1}hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){var e;if(!this.hass)return;const a=await Ce(this.hass);this.areas=a;const t=await Ae(this.hass);this.sensorGroups=t;const i=await ke(this.hass);this.data=Object.keys(i).includes(this.item)?i[this.item]:void 0,this.data&&!(null===(e=this.data)||void 0===e?void 0:e.area)&&1==Object.keys(a).length&&(this.data=Object.assign(Object.assign({},this.data),{area:Object.keys(this.areas)[0]}));let n=Ys(this.hass,Object.keys(i),!0);this.sensorsList=n.map(e=>Object(Object.assign(Object.assign({},e),{description:e.id,value:e.id}))),this.hass.states[this.item]||(this.entityIdUnlocked=!0)}render(){if(!this.data)return K``;let e=[...this.sensorsList];return e.find(e=>e.value==this.data.entity_id)||(e=[{value:this.data.entity_id,description:this.data.entity_id,name:es(this.hass.states[this.item]),icon:Ws(this.hass.states[this.item])},...e]),this.hass.states[this.data.entity_id],K` + +
+
${zn("panels.sensors.cards.editor.title",this.hass.language)}
+ +
+
+ ${zn("panels.sensors.cards.editor.description",this.hass.language,"{entity}",es(this.hass.states[this.item]))} +
+ + + + ${zn("panels.sensors.cards.editor.fields.entity.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.entity.description",this.hass.language)} + + +
+ {this.data=Object.assign(Object.assign({},this.data),{new_entity_id:e.target.value})}} + ?disabled=${!this.entityIdUnlocked} + ?icons=${!0} + ?invalid=${void 0===this.hass.states[this.data.new_entity_id||this.data.entity_id]} + > + + {this.entityIdUnlocked=!this.entityIdUnlocked}} + style="--mdc-icon-size: 20px; --mdc-icon-button-size: 48px" + > + + +
+
+ + + ${Object.keys(this.areas).length>1?K` + + + ${zn("panels.sensors.cards.editor.fields.area.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.area.description",this.hass.language)} + + + Object({value:e.area_id,name:e.name}))} + value=${this.data.area} + label=${zn("panels.sensors.cards.editor.fields.area.heading",this.hass.language)} + @value-changed=${e=>this.data=Object.assign(Object.assign({},this.data),{area:e.target.value})} + ?invalid=${!this.data.area} + > + + `:""} + + + + ${zn("panels.sensors.cards.editor.fields.device_type.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.device_type.description",this.hass.language)} + + + e!=Kn.Other).map(([e,t])=>Object({value:t,name:zn(`panels.sensors.cards.editor.fields.device_type.choose.${t}.name`,a.language),description:zn(`panels.sensors.cards.editor.fields.device_type.choose.${t}.description`,a.language),icon:Fn[e]}))} + label=${zn("panels.sensors.cards.editor.fields.device_type.heading",this.hass.language)} + clearable=${!0} + icons=${!0} + value=${this.data.type} + @value-changed=${e=>this.setType(e.target.value||Kn.Other)} + > + + + 3}> + + ${zn("panels.sensors.cards.editor.fields.modes.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.modes.description",this.hass.language)} + + +
+ ${this.modesByArea(this.data.area).map(e=>K` + {this.setMode(e)}} + ?disabled=${this.data.always_on} + > + a==e)[0]]}> + ${zn(`common.modes_short.${e}`,this.hass.language)} + + `)} +
+
+ + + + ${zn("panels.sensors.cards.editor.fields.group.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.group.description",this.hass.language)} + + +
+ ${Object.keys(this.sensorGroups).length?K` + {this.data=Object.assign(Object.assign({},this.data),{group:e.detail.value})}} + > + `:""} + + ${zn("panels.sensors.cards.editor.actions.setup_groups",this.hass.language)} + + +
+
+ + + ${!this.data.type||[Kn.Environmental,Kn.Tamper,Kn.Other].includes(this.data.type)?K` + + + ${zn("panels.sensors.cards.editor.fields.always_on.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.always_on.description",this.hass.language)} + + + this._SetData({always_on:e.target.checked})} + > + + `:""} + ${!this.data.type||[Kn.Window,Kn.Door,Kn.Motion,Kn.Other].includes(this.data.type)?K` + + + ${zn("panels.sensors.cards.editor.fields.use_exit_delay.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.use_exit_delay.description",this.hass.language)} + + + this._SetData({use_exit_delay:e.target.checked})} + > + + + ${this.data.type==Kn.Motion&&this.data.use_exit_delay?K` + + + ${zn("panels.sensors.cards.editor.fields.allow_open.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.allow_open.description",this.hass.language)} + + + this._SetData({allow_open:e.target.checked})} + > + + `:""} + `:""} + ${!this.data.type||[Kn.Window,Kn.Door,Kn.Motion,Kn.Other].includes(this.data.type)?K` + + + ${zn("panels.sensors.cards.editor.fields.use_entry_delay.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.use_entry_delay.description",this.hass.language)} + + + this._SetData({use_entry_delay:e.target.checked})} + > + + `:""} + + + ${this.data.type&&![Kn.Window,Kn.Door,Kn.Motion,Kn.Other].includes(this.data.type)||!this.data.use_entry_delay?"":K` + + + ${zn("panels.sensors.cards.editor.fields.entry_delay.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.entry_delay.description",this.hass.language)} + + + this._SetData({entry_delay:e.detail.value})} + > + + `} + + ${!this.data.type||[Kn.Window,Kn.Door,Kn.Motion,Kn.Other].includes(this.data.type)?K` + + + ${zn("panels.sensors.cards.editor.fields.delay_on.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.delay_on.description",this.hass.language)} + + + this._SetData({delay_on:e.detail.value})} + > + + `:""} + + ${!this.data.type||[Kn.Door,Kn.Other].includes(this.data.type)?K` + + + ${zn("panels.sensors.cards.editor.fields.arm_on_close.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.arm_on_close.description",this.hass.language)} + + + this._SetData({arm_on_close:e.target.checked})} + > + + `:""} + ${!this.data.type||[Kn.Window,Kn.Door,Kn.Other].includes(this.data.type)?K` + + + ${zn("panels.sensors.cards.editor.fields.auto_bypass.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.auto_bypass.description",this.hass.language)} + + + this._SetData({auto_bypass:e.target.checked})} + > + + + ${this.data.auto_bypass?K` + 2} nested> + + ${zn("panels.sensors.cards.editor.fields.auto_bypass.modes",this.hass.language)} + +
+ ${this.modesByArea(this.data.area).map(e=>K` + {this.setBypassMode(e)}} + > + a==e)[0]]} + > + ${zn(`common.modes_short.${e}`,this.hass.language)} + + `)} +
+
+ `:""} + `:""} + + ${!this.data.type||[Kn.Window,Kn.Door,Kn.Other].includes(this.data.type)?K` + + + ${zn("panels.sensors.cards.editor.fields.allow_open.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.allow_open.description",this.hass.language)} + + + this._SetData({allow_open:e.target.checked})} + > + + `:""} + + + + ${zn("panels.sensors.cards.editor.fields.trigger_unavailable.heading",this.hass.language)} + + + ${zn("panels.sensors.cards.editor.fields.trigger_unavailable.description",this.hass.language)} + + + this._SetData({trigger_unavailable:e.target.checked})} + > + +
+ +
+ + ${this.hass.localize("ui.common.save")} + + + + ${zn("panels.sensors.cards.editor.actions.remove",this.hass.language)} + +
+
+ `;var a}modesByArea(e){const a=Object.keys(this.areas).reduce((e,a)=>Object.assign(e,{[a]:Object.entries(this.areas[a].modes).filter(([,e])=>e.enabled).map(([e])=>e)}),{});return e?a[e]:Object.values(a).reduce((e,a)=>e.filter(e=>a.includes(e)))}_SetData(e){if(this.data)for(const[a,t]of Object.entries(e))switch(a){case"always_on":this.data=Object.assign(Object.assign({},this.data),{always_on:1==t}),t&&(this.data=Object.assign(Object.assign({},this.data),{arm_on_close:!1,use_exit_delay:!1,use_entry_delay:!1,allow_open:!1,auto_bypass:!1}));break;case"use_entry_delay":this.data=Object.assign(Object.assign({},this.data),{use_entry_delay:1==t});break;case"use_exit_delay":this.data=Object.assign(Object.assign({},this.data),{use_exit_delay:1==t}),this.data.type!==Kn.Motion||t||(this.data=Object.assign(Object.assign({},this.data),{allow_open:!1}));break;case"arm_on_close":this.data=Object.assign(Object.assign({},this.data),{arm_on_close:1==t}),t&&(this.data=Object.assign(Object.assign({},this.data),{always_on:!1,allow_open:!1}));break;case"allow_open":this.data=Object.assign(Object.assign({},this.data),{allow_open:1==t}),t&&(this.data=Object.assign(Object.assign({},this.data),{arm_on_close:!1,always_on:!1}));break;case"auto_bypass":this.data=Object.assign(Object.assign({},this.data),{auto_bypass:1==t}),t&&(this.data=Object.assign(Object.assign({},this.data),{always_on:!1}));break;case"trigger_unavailable":this.data=Object.assign(Object.assign({},this.data),{trigger_unavailable:1==t});break;case"entry_delay":this.data=Object.assign(Object.assign({},this.data),{entry_delay:t});break;case"delay_on":this.data=Object.assign(Object.assign({},this.data),{delay_on:t})}}setMode(e){this.data&&(this.data=Object.assign(Object.assign({},this.data),{modes:this.data.modes.includes(e)?ts(this.data.modes,e):as(this.data.modes.concat([e]))}))}setBypassMode(e){this.data&&(this.data=Object.assign(Object.assign({},this.data),{auto_bypass_modes:this.data.auto_bypass_modes.includes(e)?ts(this.data.auto_bypass_modes,e):as(this.data.auto_bypass_modes.concat([e]))}))}setType(e){if(!this.data)return;const a=e!=Kn.Other?Qs(this.modesByArea(this.data.area))[e]:{};this.data=Object.assign(Object.assign(Object.assign({},this.data),{type:e}),a)}deleteClick(e){var a,t;(a=this.hass,t=this.item,a.callApi("POST","alarmo/sensors",{entity_id:t,remove:!0})).catch(a=>ds(a,e)).then(()=>{this.cancelClick()})}saveClick(e){if(!this.data)return;const a=[];this.data.new_entity_id&&this.data.new_entity_id==this.data.entity_id&&(this.data=ns(this.data,"new_entity_id")),this.data=Object.assign(Object.assign({},this.data),{auto_bypass_modes:this.data.auto_bypass_modes.filter(e=>this.data.modes.includes(e))}),this.data.area||a.push(zn("panels.sensors.cards.editor.errors.no_area",this.hass.language)),this.data.modes.length||this.data.always_on||a.push(zn("panels.sensors.cards.editor.errors.no_modes",this.hass.language)),this.data.auto_bypass&&!this.data.auto_bypass_modes.length&&a.push(zn("panels.sensors.cards.editor.errors.no_auto_bypass_modes",this.hass.language)),a.length?os(e,K` + ${zn("panels.sensors.cards.editor.errors.description",this.hass.language)} +
    + ${a.map(e=>K` +
  • ${e}
  • + `)} +
+ `):$e(this.hass,Object.assign({},this.data)).catch(a=>ds(a,e)).then(()=>{this.cancelClick()})}cancelClick(){hs(0,fs("sensors"),!0)}manageGroupsClick(e){const a=e.target;Xn(a,"show-dialog",{dialogTag:"manage-sensor-groups-dialog",dialogImport:()=>Promise.resolve().then(function(){return ir}),dialogParams:{}})}getSensorGroups(){return Object.keys(this.sensorGroups).map(e=>Object({value:e,name:this.sensorGroups[e].name}))}};nr.styles=vs,i([ue()],nr.prototype,"hass",void 0),i([ue()],nr.prototype,"narrow",void 0),i([ue()],nr.prototype,"item",void 0),i([ue()],nr.prototype,"data",void 0),i([ue()],nr.prototype,"showBypassModes",void 0),i([ve()],nr.prototype,"entityIdUnlocked",void 0),nr=i([he("sensor-editor-card")],nr);const sr=e=>Object.keys(e.modes).filter(a=>e.modes[a].enabled),rr=e=>{let a=[];return Object.values(e).forEach(e=>{a=[...a,...sr(e)]}),a=as(a),a.sort((e,a)=>{const t=Object.values(Yn),i=t.findIndex(a=>a==e),n=t.findIndex(e=>e==a);return i-n}),a},or="no_area";let dr=class extends(Me(ce)){hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){this.hass&&(this.areas=await Ce(this.hass),this.sensors=await ke(this.hass))}async firstUpdated(){this.path&&2==this.path.length&&"filter"==this.path[0]&&(this.selectedArea=this.path[1])}shouldUpdate(e){const a=e.get("hass");return!a||1!=e.size||!this.sensors||Object.keys(this.sensors).some(e=>a.states[e]!==this.hass.states[e])}render(){return this.hass&&this.areas&&this.sensors?K` + +
+ ${zn("panels.sensors.cards.sensors.description",this.hass.language)} +
+ + hs(0,fs("sensors",{params:{edit:e.detail.id}}),!0)} + > + ${zn("panels.sensors.cards.sensors.table.no_items",this.hass.language)} + +
+ `:K``}tableColumns(){const e=(...e)=>e.map(e=>e.replace(".","_")).join("_");return{icon:{width:"40px",renderer:a=>{const t=this.hass.states[a.entity_id],i=Object.keys(Kn).find(e=>Kn[e]==a.type),n=t?"on"===t.state?Zn[i]:Fn[i]:"M11,18H13V16H11V18M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,6A4,4 0 0,0 8,10H10A2,2 0 0,1 12,8A2,2 0 0,1 14,10C14,12 11,11.75 11,15H13C13,12.75 16,12.5 16,10A4,4 0 0,0 12,6Z";return a.area==or?K` + + ${zn("panels.sensors.cards.sensors.table.no_area_warning",this.hass.language)} + `:K` + + ${t?zn(`panels.sensors.cards.editor.fields.device_type.choose.${a.type}.name`,this.hass.language):this.hass.localize("state_badge.default.entity_not_found")} + `}},name:{title:this.hass.localize("ui.components.entity.entity-picker.entity"),width:"60%",grow:!0,text:!0,renderer:a=>K` + + ${a.name} + + ${a.area==or?K`${zn("panels.sensors.cards.sensors.table.no_area_warning",this.hass.language)}`:Z} + + ${a.entity_id} + + ${a.area==or?K`${zn("panels.sensors.cards.sensors.table.no_area_warning",this.hass.language)}`:Z} + `},modes:{title:zn("panels.sensors.cards.sensors.table.arm_modes",this.hass.language),width:"25%",hide:this.narrow,text:!0,renderer:a=>K` + + ${a.always_on?zn("panels.sensors.cards.sensors.table.always_on",this.hass.language):a.modes.length?a.modes.map(e=>zn(`common.modes_short.${e}`,this.hass.language)).join(", "):this.hass.localize("state_attributes.climate.preset_mode.none")} + + ${a.area==or?K`${zn("panels.sensors.cards.sensors.table.no_area_warning",this.hass.language)}`:Z} + `},enabled:{title:zn("common.enabled",this.hass.language),width:"68px",align:"center",renderer:e=>K` + e.stopPropagation()} + ?checked=${e.enabled} + @change=${a=>this.toggleEnabled(a,e.entity_id)} + > + `}}}getTableData(){const e=Object.keys(this.sensors).map(e=>{const a=this.hass.states[e],t=this.sensors[e],i=t.area?sr(this.areas[t.area]):rr(this.areas),n=Object.assign(Object.assign({},t),{id:e,name:es(a),modes:t.always_on?i:t.modes.filter(e=>i.includes(e)),warning:!t.area,area:t.area||or});return n});return e.sort(ms),e}toggleEnabled(e,a){const t=e.target.checked;$e(this.hass,{entity_id:a,enabled:t}).catch(a=>ds(a,e)).then()}removeCustomName(e){const a={entity_id:e,name:""};$e(this.hass,a)}getTableFilterOptions(){let e=Object.values(this.areas).map(e=>Object({value:e.area_id,name:e.name,badge:a=>a.filter(a=>a.area==e.area_id).length})).sort(ms);Object.values(this.sensors).filter(e=>!e.area).length&&(e=[{value:or,name:this.hass.localize("state_attributes.climate.preset_mode.none"),badge:e=>e.filter(e=>e.area==or).length},...e]);const a=rr(this.areas).map(e=>Object({value:e,name:zn(`common.modes_short.${e}`,this.hass.language),badge:a=>a.filter(a=>a.modes.includes(e)).length}));return{area:{name:zn("components.table.filter.item",this.hass.language,"name",zn("panels.actions.cards.new_action.fields.area.heading",this.hass.language)),items:e,value:this.selectedArea?[this.selectedArea]:[]},modes:{name:zn("components.table.filter.item",this.hass.language,"name",zn("panels.actions.cards.new_action.fields.mode.heading",this.hass.language)),items:a,value:this.selectedMode?[this.selectedMode]:[]}}}};dr.styles=vs,i([ue()],dr.prototype,"hass",void 0),i([ue()],dr.prototype,"narrow",void 0),i([ue()],dr.prototype,"areas",void 0),i([ue()],dr.prototype,"sensors",void 0),i([ue()],dr.prototype,"selectedArea",void 0),i([ue()],dr.prototype,"selectedMode",void 0),i([ue()],dr.prototype,"path",void 0),dr=i([he("sensors-overview-card")],dr);let lr=class extends(Me(ce)){constructor(){super(...arguments),this.addSelection=[],this.areas={},this.sensors={}}hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){this.hass&&(this.areas=await Ce(this.hass))}async firstUpdated(){this.areas=await Ce(this.hass),this.sensors=await ke(this.hass)}render(){const e={checkbox:{width:"48px",renderer:e=>K` + this.toggleSelect(a,e.id)} + ?checked=${this.addSelection.includes(e.id)} + > + `},icon:{width:"40px",renderer:e=>K` + + `},name:{title:this.hass.localize("ui.components.entity.entity-picker.entity"),width:"40%",grow:!0,text:!0,renderer:e=>K` + ${Jn(e.name)} + ${e.id} + `},type:{title:zn("panels.sensors.cards.add_sensors.table.type",this.hass.language),width:"40%",hide:this.narrow,text:!0,renderer:e=>e.type?zn(`panels.sensors.cards.editor.fields.device_type.choose.${e.type}.name`,this.hass.language):this.hass.localize("state.default.unknown")}},a=Ys(this.hass,Object.keys(this.sensors),!0).map(e=>Object.assign(Object.assign({},e),{type:Zs(this.hass.states[e.id]),isSupportedType:void 0!==Zs(this.hass.states[e.id])?"true":"false"}));return K` + +
+ ${zn("panels.sensors.cards.add_sensors.description",this.hass.language)} +
+ + + ${zn("panels.sensors.cards.add_sensors.no_items",this.hass.language)} + + +
+ + ${zn("panels.sensors.cards.add_sensors.actions.add_to_alarm",this.hass.language)} + +
+
+ `}toggleSelect(e,a){const t=e.target.checked;this.addSelection=t&&!this.addSelection.includes(a)?[...this.addSelection,a]:t?this.addSelection:this.addSelection.filter(e=>e!=a)}addSelected(e){if(!this.hass)return;const a=Object.values(this.areas).map(e=>Object.entries(e.modes).filter(([,e])=>e.enabled).map(([e])=>e)).reduce((e,a)=>e.filter(e=>a.includes(e))),t=this.addSelection.map(e=>function(e,a){if(!e)return null;const t=ps(e.entity_id);let i={entity_id:e.entity_id,modes:[],use_entry_delay:!0,use_exit_delay:!0,entry_delay:null,delay_on:null,arm_on_close:!1,allow_open:!1,always_on:!1,auto_bypass:!1,auto_bypass_modes:[],trigger_unavailable:!1,type:Kn.Other,enabled:!0};if("binary_sensor"==t){const t=Zs(e);t&&(i=Object.assign(Object.assign(Object.assign({},i),{type:t}),Qs(a)[t]))}return i}(this.hass.states[e],a)).map(e=>1==Object.keys(this.areas).length?Object.assign(e,{area:Object.keys(this.areas)[0]}):e).filter(e=>e);t.forEach(a=>{$e(this.hass,a).catch(a=>ds(a,e)).then()}),this.addSelection=[]}getTableFilterOptions(){return{isSupportedType:{name:zn("panels.sensors.cards.add_sensors.actions.filter_supported",this.hass.language),items:[{value:"true",name:"true"}],value:["true"],binary:!0}}}};lr.styles=vs,i([ue()],lr.prototype,"hass",void 0),i([ue()],lr.prototype,"narrow",void 0),i([ue()],lr.prototype,"addSelection",void 0),i([ue()],lr.prototype,"areas",void 0),i([ue()],lr.prototype,"sensors",void 0),lr=i([he("add-sensors-card")],lr);let cr=class extends ce{firstUpdated(){(async()=>{await be()})()}render(){var e,a;if(!this.hass)return K``;if(this.path.params.edit)return K` + + `;{const t=null===(e=this.path.filter)||void 0===e?void 0:e.area,i=null===(a=this.path.filter)||void 0===a?void 0:a.mode;return K` + + + `}}};i([ue()],cr.prototype,"hass",void 0),i([ue()],cr.prototype,"narrow",void 0),i([ue()],cr.prototype,"path",void 0),cr=i([he("alarm-view-sensors")],cr);let mr=class extends ce{constructor(){super(...arguments),this.data={can_arm:!0,can_disarm:!0,is_override_code:!1},this.repeatCode="",this.areas={}}async firstUpdated(){if(this.users=await we(this.hass),this.areas=await Ce(this.hass),this.item){const e=this.users[this.item];this.data=ns(e,"code","code_format","code_length")}this.data=Object.assign(Object.assign({},this.data),{area_limit:(this.data.area_limit||[]).filter(e=>Object.keys(this.areas).includes(e))}),(this.data.area_limit||[]).length||(this.data=Object.assign(Object.assign({},this.data),{area_limit:Object.keys(this.areas)}))}render(){var e;return this.users?K` + +
+
+ ${this.item?zn("panels.codes.cards.edit_user.title",this.hass.language):zn("panels.codes.cards.new_user.title",this.hass.language)} +
+ +
+
+ ${this.item?zn("panels.codes.cards.edit_user.description",this.hass.language,"{name}",this.users[this.item].name):zn("panels.codes.cards.new_user.description",this.hass.language)} +
+ + + ${zn("panels.codes.cards.new_user.fields.name.heading",this.hass.language)} + + ${zn("panels.codes.cards.new_user.fields.name.description",this.hass.language)} + + + this.data=Object.assign(Object.assign({},this.data),{name:e.target.value})} + > + + + ${this.item?K` + + + ${zn("panels.codes.cards.edit_user.fields.old_code.heading",this.hass.language)} + + + ${zn("panels.codes.cards.edit_user.fields.old_code.description",this.hass.language)} + + + this.data=Object.assign(Object.assign({},this.data),{old_code:String(e.target.value).trim()})} + > + + `:""} + ${this.item&&!(null===(e=this.data.old_code)||void 0===e?void 0:e.length)?"":K` + + + ${zn("panels.codes.cards.new_user.fields.code.heading",this.hass.language)} + + + ${zn("panels.codes.cards.new_user.fields.code.description",this.hass.language)} + + + this.data=Object.assign(Object.assign({},this.data),{code:String(e.target.value).trim()})} + > + + + + + ${zn("panels.codes.cards.new_user.fields.confirm_code.heading",this.hass.language)} + + + ${zn("panels.codes.cards.new_user.fields.confirm_code.description",this.hass.language)} + + + this.repeatCode=String(e.target.value).trim()} + > + + `} + + + + ${zn("panels.codes.cards.new_user.fields.can_arm.heading",this.hass.language)} + + + ${zn("panels.codes.cards.new_user.fields.can_arm.description",this.hass.language)} + + + this.data=Object.assign(Object.assign({},this.data),{can_arm:e.target.checked})} + > + + + + + ${zn("panels.codes.cards.new_user.fields.can_disarm.heading",this.hass.language)} + + + ${zn("panels.codes.cards.new_user.fields.can_disarm.description",this.hass.language)} + + + this.data=Object.assign(Object.assign({},this.data),{can_disarm:e.target.checked})} + > + + + ${this.getAreaOptions().length>=2?K` + + + ${zn("panels.codes.cards.new_user.fields.area_limit.heading",this.hass.language)} + + + ${zn("panels.codes.cards.new_user.fields.area_limit.description",this.hass.language)} + + +
+ ${this.getAreaOptions().map(e=>{var a;const t=(this.data.area_limit||[]).includes(e.value)||!(null===(a=this.data.area_limit)||void 0===a?void 0:a.length);return K` +
+ this.toggleSelectArea(e.value,a.target.checked)} + ?disabled=${t&&(this.data.area_limit||[]).length<=1} + ?checked=${t} + > + this.toggleSelectArea(e.value,!t)}> + ${e.name} + +
+ `})} +
+
+ `:""} + + + + ${zn("panels.codes.cards.new_user.fields.is_override_code.heading",this.hass.language)} + + + ${zn("panels.codes.cards.new_user.fields.is_override_code.description",this.hass.language)} + + + this.data=Object.assign(Object.assign({},this.data),{is_override_code:e.target.checked})} + > + + +
+ + ${this.hass.localize("ui.common.save")} + + + ${this.item?K` + + ${this.hass.localize("ui.common.delete")} + + `:""} +
+
+ `:K``}getAreaOptions(){let e=Object.keys(this.areas||{}).map(e=>Object({value:e,name:this.areas[e].name}));return e.sort(ms),e}toggleSelectArea(e,a){if((this.data.area_limit||[]).length<=1&&!a)return;let t=this.data.area_limit||[];t=a?t.includes(e)?t:[...t,e]:t.includes(e)?t.filter(a=>a!=e):t,this.data=Object.assign(Object.assign({},this.data),{area_limit:t})}deleteClick(e){var a,t;this.item&&(a=this.hass,t=this.item,a.callApi("POST","alarmo/users",{user_id:t,remove:!0})).catch(a=>ds(a,e)).then(()=>{this.cancelClick()})}saveClick(e){var a,t,i;let n=Object.assign({},this.data);(null===(a=n.name)||void 0===a?void 0:a.length)?(null===(t=n.code)||void 0===t?void 0:t.length)&&!(n.code.length<4)||this.item&&!(null===(i=n.old_code)||void 0===i?void 0:i.length)?(n.code||"").length&&n.code!==this.repeatCode?(os(e,zn("panels.codes.cards.new_user.errors.code_mismatch",this.hass.language)),this.data=ns(this.data,"code"),this.repeatCode=""):(this.item&&(n.old_code||"").length<4&&ns(n,"old_code","code"),this.getAreaOptions().length&&!this.getAreaOptions().every(e=>(this.data.area_limit||[]).includes(e.value))||(n=Object.assign(Object.assign({},n),{area_limit:[]})),Te(this.hass,n).catch(a=>{ds(a,e)}).then(a=>{if((a||{}).success)this.cancelClick();else{const t=(a||{}).error||"unknown error";os(e,t)}})):os(e,zn("panels.codes.cards.new_user.errors.no_code",this.hass.language)):os(e,zn("panels.codes.cards.new_user.errors.no_name",this.hass.language))}cancelClick(){hs(0,fs("codes"),!0)}static get styles(){return c` + ${vs} + div.checkbox-list { + display: flex; + flex-direction: row; + } + div.checkbox-list div { + display: flex; + align-items: center; + } + div.checkbox-list div span { + cursor: pointer; + } + `}};i([ue()],mr.prototype,"hass",void 0),i([ue()],mr.prototype,"narrow",void 0),i([ue()],mr.prototype,"item",void 0),i([ue()],mr.prototype,"data",void 0),i([ue()],mr.prototype,"repeatCode",void 0),mr=i([he("user-editor-card")],mr);let hr=class extends(Me(ce)){constructor(){super(...arguments),this.users={}}hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){if(!this.hass)return;const e=await ye(this.hass);this.data=is(e,["code_arm_required","code_disarm_required","code_mode_change_required","code_format"]);const a=await we(this.hass);this.users=a}render(){if(!this.hass||!this.data)return K``;if("new_user"==this.path.subpage)return K` + + `;if(this.path.params.edit_user)return K` + + `;{const e=this.data.code_arm_required||this.data.code_disarm_required||this.data.code_mode_change_required;return K` + +
${zn("panels.codes.cards.codes.description",this.hass.language)}
+ + + + ${zn("panels.codes.cards.codes.fields.code_arm_required.heading",this.hass.language)} + + + ${zn("panels.codes.cards.codes.fields.code_arm_required.description",this.hass.language)} + + {this.saveData({code_arm_required:e.target.checked})}} + > + + + + + ${zn("panels.codes.cards.codes.fields.code_disarm_required.heading",this.hass.language)} + + + ${zn("panels.codes.cards.codes.fields.code_disarm_required.description",this.hass.language)} + + {this.saveData({code_disarm_required:e.target.checked})}} + > + + + + + ${zn("panels.codes.cards.codes.fields.code_mode_change_required.heading",this.hass.language)} + + + ${zn("panels.codes.cards.codes.fields.code_mode_change_required.description",this.hass.language)} + + {this.saveData({code_mode_change_required:e.target.checked})}} + > + + + + + ${zn("panels.codes.cards.codes.fields.code_format.heading",this.hass.language)} + + + ${zn("panels.codes.cards.codes.fields.code_format.description",this.hass.language)} + + {this.saveData({code_format:"number"})}} + ?disabled=${!e} + > + ${zn("panels.codes.cards.codes.fields.code_format.code_format_number",this.hass.language)} + + {this.saveData({code_format:"text"})}} + ?disabled=${!e} + > + ${zn("panels.codes.cards.codes.fields.code_format.code_format_text",this.hass.language)} + + +
+ + ${this.usersPanel()} + `}}usersPanel(){if(!this.hass)return K``;const e=Object.values(this.users);e.sort(ms);const a={icon:{width:"40px"},name:{title:this.hass.localize("ui.common.name"),width:"40%",grow:!0,text:!0},code_format:{title:zn("panels.codes.cards.codes.fields.code_format.heading",this.hass.language),width:"40%",hide:this.narrow,text:!0},enabled:{title:zn("common.enabled",this.hass.language),width:"68px",align:"center"}},t=e.map(e=>({id:e.user_id,icon:K` + + `,name:K` + + ${Jn(e.name)} + + `,code_format:K` + + ${"number"==e.code_format?Jn(zn("panels.codes.cards.codes.fields.code_format.code_format_number",this.hass.language)):"text"==e.code_format?Jn(zn("panels.codes.cards.codes.fields.code_format.code_format_text",this.hass.language)):this.hass.localize("state.default.unknown")} + + `,enabled:K` + e.stopPropagation()} + ?checked=${e.enabled} + @change=${a=>this.toggleEnabled(a,e.user_id)} + > + `}));return K` + +
+ ${zn("panels.codes.cards.user_management.description",this.hass.language)} +
+ + {const a=String(e.detail.id);hs(0,fs("codes",{params:{edit_user:a}}),!0)}} + > + ${zn("panels.codes.cards.user_management.no_items",this.hass.language)} + +
+ + ${zn("panels.codes.cards.user_management.actions.new_user",this.hass.language)} + +
+
+ `}addUserClick(){hs(0,fs("codes","new_user"),!0)}saveData(e){this.hass&&(this.data=Object.assign(Object.assign({},this.data),e),je(this.hass,this.data).catch(e=>ds(e,this.shadowRoot.querySelector("ha-card"))).then())}toggleEnabled(e,a){const t=e.target.checked;Te(this.hass,{user_id:a,enabled:t}).catch(e=>ds(e,this.shadowRoot.querySelector("ha-card"))).then()}};hr.styles=vs,i([ue()],hr.prototype,"hass",void 0),i([ue()],hr.prototype,"narrow",void 0),i([ue()],hr.prototype,"path",void 0),i([ue()],hr.prototype,"data",void 0),i([ue()],hr.prototype,"users",void 0),hr=i([he("alarm-view-codes")],hr);const pr=(e,a)=>{switch(e){case Yn.ArmedAway:return{value:Yn.ArmedAway,name:zn("common.modes_short.armed_away",a.language),icon:Rn.ArmedAway};case Yn.ArmedHome:return{value:Yn.ArmedHome,name:zn("common.modes_short.armed_home",a.language),icon:Rn.ArmedHome};case Yn.ArmedNight:return{value:Yn.ArmedNight,name:zn("common.modes_short.armed_night",a.language),icon:Rn.ArmedNight};case Yn.ArmedCustom:return{value:Yn.ArmedCustom,name:zn("common.modes_short.armed_custom_bypass",a.language),icon:Rn.ArmedCustom};case Yn.ArmedVacation:return{value:Yn.ArmedVacation,name:zn("common.modes_short.armed_vacation",a.language),icon:Rn.ArmedVacation}}},gr=(e,a)=>{switch(e){case Wn.Armed:return{value:Wn.Armed,name:zn("panels.actions.cards.new_notification.fields.event.choose.armed.name",a.language),description:zn("panels.actions.cards.new_notification.fields.event.choose.armed.description",a.language),icon:"hass:shield-check-outline"};case Wn.Disarmed:return{value:Wn.Disarmed,name:zn("panels.actions.cards.new_notification.fields.event.choose.disarmed.name",a.language),description:zn("panels.actions.cards.new_notification.fields.event.choose.disarmed.description",a.language),icon:"hass:shield-off-outline"};case Wn.Triggered:return{value:Wn.Triggered,name:zn("panels.actions.cards.new_notification.fields.event.choose.triggered.name",a.language),description:zn("panels.actions.cards.new_notification.fields.event.choose.triggered.description",a.language),icon:"hass:bell-alert-outline"};case Wn.Untriggered:return{value:Wn.Untriggered,name:zn("panels.actions.cards.new_notification.fields.event.choose.untriggered.name",a.language),description:zn("panels.actions.cards.new_notification.fields.event.choose.untriggered.description",a.language),icon:"hass:bell-off-outline"};case Wn.ArmFailure:return{value:Wn.ArmFailure,name:zn("panels.actions.cards.new_notification.fields.event.choose.arm_failure.name",a.language),description:zn("panels.actions.cards.new_notification.fields.event.choose.arm_failure.description",a.language),icon:"hass:alert-outline"};case Wn.Arming:return{value:Wn.Arming,name:zn("panels.actions.cards.new_notification.fields.event.choose.arming.name",a.language),description:zn("panels.actions.cards.new_notification.fields.event.choose.arming.description",a.language),icon:"hass:home-export-outline"};case Wn.Pending:return{value:Wn.Pending,name:zn("panels.actions.cards.new_notification.fields.event.choose.pending.name",a.language),description:zn("panels.actions.cards.new_notification.fields.event.choose.pending.description",a.language),icon:"hass:home-import-outline"}}},ur=(e,a,t)=>0==e?{name:t.master.name,value:"0"}:Object.keys(a).includes(String(e))?{name:a[e].name,value:String(e)}:{name:String(e),value:String(e)},vr=(e,...a)=>{const t=a.map(a=>{if(a.entity_id){return fr([a.entity_id],e).pop()}if(!a||!a.service)return null;const t=ps(a.service),i=gs(a.service);let n={value:a.service,name:i.replace(/_/g," ").split(" ").map(e=>e.substring(0,1).toUpperCase()+e.substring(1)).join(" "),icon:"hass:home",description:a.service};switch(t){case"notify":const a=e.states[`device_tracker.${i.replace("mobile_app_","")}`];n=a?Object.assign(Object.assign({},n),{name:a.attributes.friendly_name||gs(a.entity_id),icon:a.attributes.icon||"hass:cellphone-text"}):Object.assign(Object.assign({},n),{icon:"hass:comment-alert"});break;case"tts":n=Object.assign(Object.assign({},n),{icon:"hass:microphone"});break;case"telegram_bot":n=Object.assign(Object.assign({},n),{icon:"mdi:send"})}return n}).filter(ss);return t.sort((e,a)=>{const t=ps(e.value),i=ps(a.value);return t!=i?ms(t,i):ms(e,a)}),t},_r=(e,a)=>{let t=[];const i=Object.keys(e).filter(a=>Object.values(e[a].modes).some(e=>e.enabled));return a.master.enabled&&i.length>1&&(t=[...t,0]),t=[...t,...i],t},br=(e,a)=>{const t=e=>Object.keys(e.modes).filter(a=>e.modes[a].enabled);if(ss(e)&&Object.keys(a).includes(String(e)))return t(a[e]);{const e=Object.keys(a).map(e=>t(a[e]));return e[0].filter(a=>e.every(e=>e.includes(a)))}},fr=(e,a)=>{const t=e.map(e=>({value:e,name:e in a.states?a.states[e].attributes.friendly_name||gs(e):e,icon:e in a.states&&a.states[e].attributes.icon||us(ps(e)),description:e}));return t},yr=e=>{let a=[];return"notify"in e.services&&(a=[...a,...Object.keys(e.services.notify).filter(e=>"send_message"!=e).map(e=>Object({service:`notify.${e}`}))]),"tts"in e.services&&(a=[...a,...Object.keys(e.services.tts).filter(e=>"clear_cache"!=e).map(e=>Object({service:`tts.${e}`}))]),"telegram_bot"in e.services&&(a=[...a,{service:"telegram_bot.send_message"}]),Object.keys(e.states).filter(e=>"notify"==ps(e)).map(e=>{a=[...a,{service:"notify.send_message",entity_id:e}]}),a},kr=(...e)=>{if(!e.length||!e.every(e=>e.length))return[];if(1==e.length&&e[0].length>1&&as(e[0].map(ps)).length>1)return kr(...e[0].map(e=>Array(e)));let a=[...e[0]];return e.forEach(e=>{a=a.map(a=>e.includes(a)?a:"script"==ps(a)&&e.map(ps).includes("script")?"script.script":e.map(gs).includes(gs(a))?`homeassistant.${gs(a)}`:null).filter(ss)}),a},wr=(e,a,t=1)=>{if(t>10)return[];if(Array.isArray(e)){const i=e.map(e=>wr(e,a,t+1));return kr(...i)}if(!ss(e))return[];const i=ps(e);switch(i){case"light":case"switch":case"input_boolean":case"siren":return[`${i}.turn_on`,`${i}.turn_off`];case"script":return[e];case"lock":return["lock.lock","lock.unlock"];case"group":const n=e in a.states?a.states[e]:void 0,s=(null==n?void 0:n.attributes.entity_id)||[];return wr(s,a,t+1);default:return[]}},zr=(e,a)=>{let t=[...Object.keys(e.states).filter(a=>"script"!=ps(a)&&wr(a,e).length)];const i=Object.keys(e.services.script||{}).filter(e=>!["turn_on","turn_off","reload","toggle"].includes(e)).map(e=>`script.${e}`);return t=[...t,...i],a&&a.length&&(t=[...t,...a.filter(e=>!t.includes(e))]),t.sort(ms),t},Ar=(e,...a)=>{let t=[...Object.keys(e.states).filter(e=>a.includes(ps(e)))];return t.sort(ms),t},jr=e=>{let a=[{value:"{{arm_mode}}",name:e.translationMetadata.translations.en.nativeName}];return"en"!=e.language&&(a=[...a,{value:`{{arm_mode|lang=${e.language}}}`,name:e.translationMetadata.translations[e.language].nativeName}]),a},$r=e=>"string"==typeof e&&e.trim().length>0,Tr=(e,a)=>$r(e)&&a.services[ps(e)]&&a.services[ps(e)][gs(e)],Er=(e,a)=>$r(e)&&(a.states[e]||"script"==ps(e)&&a.services.script[gs(e)]),Sr=e=>"object"==typeof e&&null!==e&&!Array.isArray(e),Cr=e=>"string"==typeof e;let Or=class extends ce{constructor(){super(...arguments),this.items=[],this.value=[],this.label="",this.invalid=!1,this.showSearch=!1}shouldUpdate(e){return e.get("items")&&(rs(this.items,e.get("items"))||this.firstUpdated()),!0}firstUpdated(){this.value.some(e=>!this.items.map(e=>e.value).includes(e))&&(this.value=this.value.filter(e=>this.items.map(e=>e.value).includes(e)),Xn(this,"value-changed",{value:this.value}))}render(){return K` + this.items.find(a=>a.value==e)).filter(ss)} + removable + @value-changed=${this._removeClick} + > + + !this.value.includes(e.value))} + ?disabled=${this.value.length==this.items.length} + label=${this.label} + icons=${!0} + @value-changed=${this._addClick} + ?invalid=${this.invalid&&this.value.length!=this.items.length} + ?showSearch=${this.showSearch} + > + `}_removeClick(e){const a=e.detail;this.value=this.value.filter(e=>e!==a),Xn(this,"value-changed",{value:this.value})}_addClick(e){e.stopPropagation();const a=e.target,t=a.value;""!==t&&(this.value.includes(t)||(this.value=[...this.value,t]),Xn(this,"value-changed",{value:[...this.value]}),a.clearValue())}};var Mr;i([ue()],Or.prototype,"hass",void 0),i([ue()],Or.prototype,"items",void 0),i([ue({type:Array})],Or.prototype,"value",void 0),i([ue()],Or.prototype,"label",void 0),i([ue({type:Boolean})],Or.prototype,"invalid",void 0),i([ue({type:Boolean})],Or.prototype,"showSearch",void 0),Or=i([he("alarmo-selector")],Or),function(e){e[e.Yaml=0]="Yaml",e[e.UI=1]="UI"}(Mr||(Mr={}));let xr=class extends ce{constructor(){super(...arguments),this.config={type:Qn.Notification,triggers:[{}],actions:[{}]},this.viewMode=Mr.UI,this.errors={}}async firstUpdated(){if(await fe(),this.areas=await Ce(this.hass),this.alarmoConfig=await ye(this.hass),this.item){let e=[...this.item.actions];this.config=Object.assign(Object.assign({},this.item),{actions:[e[0],...e.slice(1)]}),null===this.config.actions[0].entity_id&&Object.assign(this.config.actions,{0:ns(this.config.actions[0],"entity_id")}),this.config.triggers.length>1&&(this.config=Object.assign(Object.assign({},this.config),{triggers:[this.config.triggers[0]]}));let a=this.config.triggers[0].area;ss(a)&&!_r(this.areas,this.alarmoConfig).includes(a)?a=void 0:null===a&&(a=0),this._setArea(new CustomEvent("value-changed",{detail:{value:a}}))}if(!ss(this.config.triggers[0].area)){const e=_r(this.areas,this.alarmoConfig);1==e.length?this._setArea(new CustomEvent("value-changed",{detail:{value:e[0]}})):e.includes(0)&&this._setArea(new CustomEvent("value-changed",{detail:{value:0}}))}}render(){var e,a,t,i,n,s,r;return this.hass&&this.areas&&this.alarmoConfig?K` +
+ +
${zn("panels.actions.cards.new_notification.title",this.hass.language)}
+
+ ${zn("panels.actions.cards.new_notification.description",this.hass.language)} +
+
+
${zn("panels.actions.cards.new_notification.trigger",this.hass.language)}
+ +
+ + + ${zn("panels.actions.cards.new_notification.fields.event.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_notification.fields.event.description",this.hass.language)} + + + gr(e,this.hass))} + label=${zn("panels.actions.cards.new_action.fields.event.heading",this.hass.language)} + icons=${!0} + .value=${this.config.triggers[0].event} + @value-changed=${this._setEvent} + ?invalid=${this.errors.event} + showSearch + > + + + ${Object.keys(this.areas).length>1?K` + + + ${zn("panels.actions.cards.new_action.fields.area.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_action.fields.area.description",this.hass.language)} + + + ur(e,this.areas,this.alarmoConfig))} + label=${zn("panels.actions.cards.new_action.fields.area.heading",this.hass.language)} + .value=${String(this.config.triggers[0].area)} + @value-changed=${this._setArea} + ?invalid=${this.errors.area||!this.config.triggers[0].area&&!this.alarmoConfig.master.enabled} + > + + `:""} + + + + ${zn("panels.actions.cards.new_notification.fields.mode.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_notification.fields.mode.description",this.hass.language)} + + + pr(e,this.hass))} + label=${zn("panels.actions.cards.new_action.fields.mode.heading",this.hass.language)} + .value=${this.config.triggers[0].modes||[]} + @value-changed=${this._setModes} + ?invalid=${this.errors.modes} + > + +
+
+ +
${zn("panels.actions.cards.new_notification.action",this.hass.language)}
+ +
+ ${this.viewMode==Mr.UI?K` + + + ${zn("panels.actions.cards.new_notification.fields.target.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_notification.fields.target.description",this.hass.language)} + + + e.entity_id==this.config.actions[0].entity_id&&e.service==this.config.actions[0].service))||void 0===e?void 0:e.entity_id)||(null===(a=yr(this.hass).find(e=>e.service==this.config.actions[0].service))||void 0===a?void 0:a.service)||void 0} + @value-changed=${this._setService} + ?invalid=${this.errors.service} + showSearch + > + + + ${this.config.actions[0].service&&"notify"!=ps(this.config.actions[0].service)?"":K` + + + ${zn("panels.actions.cards.new_notification.fields.title.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_notification.fields.title.description",this.hass.language)} + + + + + `} + ${this.config.actions[0].service&&"tts"==ps(this.config.actions[0].service)?K` + + + ${zn("panels.actions.cards.new_action.fields.entity.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_action.fields.entity.description",this.hass.language)} + + + + + `:""} + + + ${this.config.actions[0].service&&"tts.speak"==this.config.actions[0].service?K` + + + ${zn("panels.actions.cards.new_notification.fields.media_player_entity.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_notification.fields.media_player_entity.description",this.hass.language)} + + + + + `:""} + + + + ${zn("panels.actions.cards.new_notification.fields.message.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_notification.fields.message.description",this.hass.language)} + + + {this._setMessage(e.detail.value)}} + ?invalid=${this.errors.message} + > + ${this.errors.message?K` + + ${this.hass.localize("ui.common.error_required")} + + `:Z} + ${this.config.triggers[0].event?K` +
+ + ${zn("panels.actions.cards.new_notification.fields.message.insert_wildcard",this.hass.language)}: + + {let t=[];return t=[],e&&![Wn.Pending,Wn.Triggered,Wn.ArmFailure].includes(e)||(t=[...t,{name:"Open Sensors",value:"{{open_sensors}}"}]),e&&![Wn.Armed].includes(e)||(t=[...t,{name:"Bypassed Sensors",value:"{{bypassed_sensors}}"}]),(!e||(null==a?void 0:a.code_arm_required)&&[Wn.Armed,Wn.Arming,Wn.ArmFailure].includes(e)||(null==a?void 0:a.code_disarm_required)&&[Wn.Disarmed,Wn.Untriggered].includes(e))&&(t=[...t,{name:"Changed By",value:"{{changed_by}}"}]),e&&![Wn.Armed,Wn.Arming,Wn.Pending,Wn.Triggered,Wn.ArmFailure].includes(e)||(t=[...t,{name:"Arm Mode",value:"{{arm_mode}}"}]),e&&![Wn.Arming,Wn.Pending].includes(e)||(t=[...t,{name:"Delay",value:"{{delay}}"}]),t})(this.config.triggers[0].event,this.alarmoConfig)} + @value-changed=${e=>this._insertWildCard(e.detail)} + > +
+ `:""} +
+ + ${null!==this._getOpenSensorsFormat()?K` + + + ${zn("panels.actions.cards.new_notification.fields.open_sensors_format.heading",this.hass.language)} + + + + ${zn("panels.actions.cards.new_notification.fields.open_sensors_format.description",this.hass.language)} + + + {let a=[];return a="en"!=e.language?[...a,{value:"{{open_sensors}}",name:`${zn("panels.actions.cards.new_notification.fields.open_sensors_format.options.default",e.language)} (${e.translationMetadata.translations.en.nativeName})`},{value:`{{open_sensors|lang=${e.language}}}`,name:`${zn("panels.actions.cards.new_notification.fields.open_sensors_format.options.default",e.language)} (${e.translationMetadata.translations[e.language].nativeName})`}]:[...a,{value:"{{open_sensors}}",name:zn("panels.actions.cards.new_notification.fields.open_sensors_format.options.default",e.language)}],a=[...a,{value:"{{open_sensors|format=short}}",name:zn("panels.actions.cards.new_notification.fields.open_sensors_format.options.short",e.language)}],a})(this.hass)} + .value=${this._getOpenSensorsFormat(!0)} + @value-changed=${this._setOpenSensorsFormat} + > + + `:""} + ${null!==this._getArmModeFormat()&&(jr(this.hass).length>1||1==jr(this.hass).length&&jr(this.hass)[0].value!=this._getArmModeFormat())?K` + + + ${zn("panels.actions.cards.new_notification.fields.arm_mode_format.heading",this.hass.language)} + + + + ${zn("panels.actions.cards.new_notification.fields.arm_mode_format.description",this.hass.language)} + + + + + `:""} + `:K` +

${zn("components.editor.edit_in_yaml",this.hass.language)}

+ + + + ${this.errors.service||this.errors.title||this.errors.message||this.errors.entity||this.errors.media_player_entity?K` + + ${this.hass.localize("ui.errors.config.key_missing","key",Object.entries(this.errors).find(([e,a])=>a&&["service","title","message","entity","media_player_entity"].includes(e))[0])} + + `:""} + `} +
+ +
+ + + ${this.viewMode==Mr.Yaml?zn("components.editor.ui_mode",this.hass.language):zn("components.editor.yaml_mode",this.hass.language)} + +
+ +
+ + ${zn("panels.actions.cards.new_notification.actions.test",this.hass.language)} + + +
+
+ +
${zn("panels.actions.cards.new_notification.options",this.hass.language)}
+ +
+ + + ${zn("panels.actions.cards.new_notification.fields.name.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_notification.fields.name.description",this.hass.language)} + + + + + + ${(null===(r=this.item)||void 0===r?void 0:r.automation_id)?K` + + + ${zn("panels.actions.cards.new_notification.fields.delete.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_notification.fields.delete.description",this.hass.language)} + +
+ + + ${this.hass.localize("ui.common.delete")} + +
+
+ `:""} +
+
+ +
+ + + ${this.hass.localize("ui.common.save")} + +
+ `:K``}_setEvent(e){e.stopPropagation();const a=e.target.value;let t=this.config.triggers;Object.assign(t,{0:Object.assign(Object.assign({},t[0]),{event:a})}),this.config=Object.assign(Object.assign({},this.config),{triggers:t}),Object.keys(this.errors).includes("event")&&this._validateConfig()}_setArea(e){var a;e.stopPropagation();let t=e.detail.value;"0"===t&&(t=0);let i=this.config.triggers;Object.assign(i,{0:Object.assign(Object.assign({},i[0]),{area:t})});const n=br(t,this.areas);(null===(a=i[0].modes)||void 0===a?void 0:a.length)&&this._setModes(new CustomEvent("value-changed",{detail:{value:i[0].modes.filter(e=>n.includes(e))}})),this.config=Object.assign(Object.assign({},this.config),{triggers:i}),Object.keys(this.errors).includes("area")&&this._validateConfig()}_setModes(e){e.stopPropagation();const a=e.detail.value;let t=this.config.triggers;Object.assign(t,{0:Object.assign(Object.assign({},t[0]),{modes:a})}),this.config=Object.assign(Object.assign({},this.config),{triggers:t}),Object.keys(this.errors).includes("modes")&&this._validateConfig()}_setService(e){e.stopPropagation();const a=String(e.detail.value);let t=yr(this.hass).find(e=>e.entity_id==a||e.service==a),i=this.config.actions;Object.assign(i,{0:Object.assign(Object.assign({},t),ns(i[0],"service","entity_id"))}),this.config=Object.assign(Object.assign({},this.config),{actions:i}),(Object.keys(this.errors).includes("service")||Object.keys(this.errors).includes("entity"))&&this._validateConfig()}_setTitle(e){e.stopPropagation();const a=e.target.value;let t=this.config.actions;Object.assign(t,{0:Object.assign(Object.assign({},t[0]),{service:t[0].service||"",data:Object.assign(Object.assign({},t[0].data||{}),{title:a})})}),this.config=Object.assign(Object.assign({},this.config),{actions:t}),Object.keys(this.errors).includes("title")&&this._validateConfig()}_setEntity(e){e.stopPropagation();const a=e.target.value;let t=this.config.actions;Object.assign(t,{0:Object.assign(Object.assign({},t[0]),{service:t[0].service||"",data:Object.assign(Object.assign({},t[0].data||{}),{entity_id:a})})}),this.config=Object.assign(Object.assign({},this.config),{actions:t}),Object.keys(this.errors).includes("entity")&&this._validateConfig()}_setMediaPlayerEntity(e){e.stopPropagation();const a=e.target.value;let t=this.config.actions;Object.assign(t,{0:Object.assign(Object.assign({},t[0]),{service:t[0].service||"",data:Object.assign(Object.assign({},t[0].data||{}),{media_player_entity_id:a})})}),this.config=Object.assign(Object.assign({},this.config),{actions:t}),Object.keys(this.errors).includes("media_player_entity")&&this._validateConfig()}_setMessage(e){let a=this.config.actions;Object.assign(a,{0:Object.assign(Object.assign({},a[0]),{service:a[0].service||"",data:Object.assign(Object.assign({},a[0].data||{}),{message:e})})}),this.config=Object.assign(Object.assign({},this.config),{actions:a}),Object.keys(this.errors).includes("message")&&this._validateConfig()}_setName(e){e.stopPropagation();const a=e.target.value;this.config=Object.assign(Object.assign({},this.config),{name:a})}_setYaml(e){const a=e.detail.value;let t={};Cr(null==a?void 0:a.service)&&(t=Object.assign(Object.assign({},t),{service:String(a.service)})),Sr(null==a?void 0:a.data)&&(t=Object.assign(Object.assign({},t),{data:a.data})),Object.keys(t).length&&(this.config=Object.assign(Object.assign({},this.config),{actions:Object.assign(this.config.actions,{0:Object.assign(Object.assign({},this.config.actions[0]),t)})})),Object.keys(this.errors).some(e=>["service","message","title"].includes(e))&&this._validateConfig()}_validateConfig(){var e;this.errors={};const a=this._parseAutomation(),t=a.triggers[0];t.event&&Object.values(Wn).includes(t.event)||(this.errors=Object.assign(Object.assign({},this.errors),{event:!0})),ss(t.area)&&_r(this.areas,this.alarmoConfig).includes(t.area)||(this.errors=Object.assign(Object.assign({},this.errors),{area:!0})),(t.modes||[]).every(e=>br(t.area,this.areas).includes(e))||(this.errors=Object.assign(Object.assign({},this.errors),{modes:!0}));const i=a.actions[0];return!i.service||!yr(this.hass).find(e=>e.service==i.service&&e.entity_id==i.entity_id)&&"script"!=ps(i.service)?this.errors=Object.assign(Object.assign({},this.errors),{service:!0}):!i.service||"tts"!=ps(i.service)||Object.keys(i.data||{}).includes("entity_id")&&Ar(this.hass,"media_player","tts").includes(i.data.entity_id)||(this.errors=Object.assign(Object.assign({},this.errors),{entity:!0})),i.service&&"tts.speak"==i.service&&(this.errors.entity||Ar(this.hass,"tts").includes(i.data.entity_id)||(this.errors=Object.assign(Object.assign({},this.errors),{entity:!0})),Object.keys(i.data||{}).includes("media_player_entity_id")&&Ar(this.hass,"media_player").includes(i.data.media_player_entity_id)||(this.errors=Object.assign(Object.assign({},this.errors),{media_player_entity:!0}))),$r(null===(e=i.data)||void 0===e?void 0:e.message)||(this.errors=Object.assign(Object.assign({},this.errors),{message:!0})),$r(a.name)||(this.errors=Object.assign(Object.assign({},this.errors),{name:!0})),!Object.values(this.errors).length}_validAction(){var e;const a=this._parseAutomation().actions[0];return a.service&&("script"==ps(a.service)||yr(this.hass).find(e=>e.service==a.service&&e.entity_id==a.entity_id))&&$r(null===(e=a.data)||void 0===e?void 0:e.message)}_insertWildCard(e){var a,t,i;let n=this.shadowRoot.querySelector("#message"),s=(null===(a=this.config.actions[0].data)||void 0===a?void 0:a.message)||"";if(n){let e=null===(t=n.shadowRoot)||void 0===t?void 0:t.querySelector("ha-selector-text");e&&(n=e),e=null===(i=n.shadowRoot)||void 0===i?void 0:i.querySelector("ha-textarea"),e&&(n=e),n.focus()}s=n&&ss(n.selectionStart)&&ss(n.selectionEnd)?s.substring(0,n.selectionStart)+e+s.substring(n.selectionEnd,s.length):s+e,this._setMessage(s)}_toggleYamlMode(){if(this.viewMode=this.viewMode==Mr.UI?Mr.Yaml:Mr.UI,this.viewMode==Mr.Yaml){let e=Object.assign({},this.config.actions[0]),a="object"==typeof e.data&&ss(e.data)?e.data:{};e=Object.assign(Object.assign({},e),{service:e.service||""}),a.message||(a=Object.assign(Object.assign({},a),{message:""})),yr(this.hass).find(a=>a.service==e.service&&a.entity_id==e.entity_id)&&("notify"!=ps(e.service)||a.title||(a=Object.assign(Object.assign({},a),{title:""})),"tts"!=ps(e.service)||a.entity_id||(a=Object.assign(Object.assign({},a),{entity_id:""}))),e=Object.assign(Object.assign({},e),{data:a}),this.config=Object.assign(Object.assign({},this.config),{actions:Object.assign(this.config.actions,{0:e})})}}_namePlaceholder(){const e=this.config.triggers[0].event,a=this.config.actions[0].service?ps(this.config.actions[0].service):null;if(!e)return"";if("notify"==a){const a=vr(this.hass,this.config.actions[0]);return a.length?zn(`panels.actions.cards.new_notification.fields.name.placeholders.${e}`,this.hass.language,"{target}",a[0].name):""}if("tts"==a){const a="object"==typeof this.config.actions[0].data&&ss(this.config.actions[0].data)?this.config.actions[0].data.entity_id:null;if(!a||!this.hass.states[a])return"";const t=es(this.hass.states[a]);return zn(`panels.actions.cards.new_notification.fields.name.placeholders.${e}`,this.hass.language,"{target}",t)}if("telegram_bot"==a){const t=Jn(a);return zn(`panels.actions.cards.new_notification.fields.name.placeholders.${e}`,this.hass.language,"{target}",t)}return""}_messagePlaceholder(){const e=this.config.triggers[0].event;return e?zn(`panels.actions.cards.new_notification.fields.message.placeholders.${e}`,this.hass.language):""}_parseAutomation(){var e;let a=Object.assign({},this.config),t=a.actions[0];return!$r(null===(e=t.data)||void 0===e?void 0:e.message)&&this.viewMode==Mr.UI&&this._messagePlaceholder()&&(t=Object.assign(Object.assign({},t),{data:Object.assign(Object.assign({},t.data),{message:this._messagePlaceholder()})}),Object.assign(a,{actions:Object.assign(a.actions,{0:t})})),!$r(a.name)&&this._namePlaceholder()&&(a=Object.assign(Object.assign({},a),{name:this._namePlaceholder()})),a}_getOpenSensorsFormat(e=!1){var a;const t=((null===(a=this.config.actions[0].data)||void 0===a?void 0:a.message)||"").match(/{{open_sensors(\|[^}]+)?}}/);return null!==t?t[0]:e?"{{open_sensors}}":null}_setOpenSensorsFormat(e){var a;e.stopPropagation();const t=String(e.detail.value);let i=(null===(a=this.config.actions[0].data)||void 0===a?void 0:a.message)||"";i=i.replace(/{{open_sensors(\|[^}]+)?}}/,t);let n=this.config.actions;Object.assign(n,{0:Object.assign(Object.assign({},n[0]),{service:n[0].service||"",data:Object.assign(Object.assign({},n[0].data||{}),{message:i})})}),this.config=Object.assign(Object.assign({},this.config),{actions:n})}_getArmModeFormat(e=!1){var a;const t=((null===(a=this.config.actions[0].data)||void 0===a?void 0:a.message)||"").match(/{{arm_mode(\|[^}]+)?}}/);return null!==t?t[0]:e?"{{arm_mode}}":null}_setArmModeFormat(e){var a;e.stopPropagation();const t=String(e.detail.value);let i=(null===(a=this.config.actions[0].data)||void 0===a?void 0:a.message)||"";i=i.replace(/{{arm_mode(\|[^}]+)?}}/,t);let n=this.config.actions;Object.assign(n,{0:Object.assign(Object.assign({},n[0]),{service:n[0].service||"",data:Object.assign(Object.assign({},n[0].data||{}),{message:i})})}),this.config=Object.assign(Object.assign({},this.config),{actions:n})}_saveClick(e){if(!this._validateConfig())return;let a=this._parseAutomation();br(a.triggers[0].area,this.areas).every(e=>{var t;return null===(t=a.triggers[0].modes)||void 0===t?void 0:t.includes(e)})&&(a=Object.assign(Object.assign({},a),{triggers:Object.assign(a.triggers,{0:Object.assign(Object.assign({},a.triggers[0]),{modes:[]})})})),this.item&&(a=Object.assign(Object.assign({},a),{automation_id:this.item.automation_id})),Ee(this.hass,a).catch(a=>ds(a,e)).then(()=>this._cancelClick())}_deleteClick(e){var a;(null===(a=this.item)||void 0===a?void 0:a.automation_id)&&Se(this.hass,this.item.automation_id).catch(a=>ds(a,e)).then(()=>this._cancelClick())}async _testClick(e){const a=this._parseAutomation();let t=Object.assign({},a.actions[0]),i=t.data.message;i=i.replace("{{open_sensors|format=short}}","Some Example Sensor"),i=i.replace(/{{open_sensors(\|[^}]+)?}}/,"Some Example Sensor is open"),i=i.replace("{{bypassed_sensors}}","Some Bypassed Sensor"),i=i.replace(/{{arm_mode(\|[^}]+)?}}/,"Armed away"),i=i.replace("{{changed_by}}","Some Example User"),i=i.replace("{{delay}}","30"),t=Object.assign(Object.assign({},t),{data:Object.assign(Object.assign({},t.data),{message:i})});let n=Object.assign(Object.assign({},ns(t,"service")),{action:t.service});this.hass.callWS({type:"execute_script",sequence:n}).then().catch(a=>{os(e,a.message)})}_cancelClick(){hs(0,fs("actions"),!0)}static get styles(){return c` + div.content { + padding: 28px 20px 0; + max-width: 1040px; + margin: 0 auto; + display: flex; + flex-direction: column; + } + div.header { + font-size: 24px; + font-weight: 400; + letter-spacing: -0.012em; + line-height: 32px; + opacity: var(--dark-primary-opacity); + } + div.section-header { + font-size: 18px; + font-weight: 400; + letter-spacing: -0.012em; + line-height: 32px; + opacity: var(--dark-primary-opacity); + margin: 20px 0px 5px 10px; + } + div.actions { + padding: 20px 0px 30px 0px; + } + .toggle-button { + position: absolute; + right: 20px; + top: 20px; + } + h2 { + margin-top: 10px; + font-size: 24px; + font-weight: 400; + letter-spacing: -0.012em; + } + span.error-message { + color: var(--error-color); + } + div.heading { + display: grid; + grid-template-areas: + 'header icon' + 'description icon'; + grid-template-rows: 1fr 1fr; + grid-template-columns: 1fr 48px; + margin: 20px 0px 10px 10px; + } + div.heading .icon { + grid-area: icon; + } + div.heading .header { + grid-area: header; + } + div.heading .description { + grid-area: description; + } + ha-textarea[invalid] { + --mdc-text-field-idle-line-color: var(--mdc-theme-error); + --mdc-text-field-label-ink-color: var(--mdc-theme-error); + } + `}};var Nr;i([ue({attribute:!1})],xr.prototype,"hass",void 0),i([ue()],xr.prototype,"narrow",void 0),i([ue()],xr.prototype,"config",void 0),i([ue()],xr.prototype,"item",void 0),i([ue()],xr.prototype,"areas",void 0),i([ue()],xr.prototype,"alarmoConfig",void 0),i([ue()],xr.prototype,"viewMode",void 0),i([ue()],xr.prototype,"errors",void 0),xr=i([he("notification-editor-card")],xr),function(e){e[e.Yaml=0]="Yaml",e[e.UI=1]="UI"}(Nr||(Nr={}));let Lr=class extends ce{constructor(){super(...arguments),this.config={type:Qn.Action,triggers:[{}],actions:[{}]},this.viewMode=Nr.UI,this.errors={}}async firstUpdated(){if(await fe(),this.areas=await Ce(this.hass),this.alarmoConfig=await ye(this.hass),this.item){let e=this.item.actions.map(e=>e.entity_id?e:ns(e,"entity_id"));this.config=Object.assign(Object.assign({},this.item),{actions:[e[0],...e.slice(1)]}),this.config.triggers.length>1&&(this.config=Object.assign(Object.assign({},this.config),{triggers:[this.config.triggers[0]]}));let a=this.config.triggers[0].area;ss(a)&&!_r(this.areas,this.alarmoConfig).includes(a)?a=void 0:null===a&&(a=0),this._setArea(new CustomEvent("value-changed",{detail:{value:a}})),this._hasCustomEntities()&&(this.viewMode=Nr.Yaml)}if(!ss(this.config.triggers[0].area)){const e=_r(this.areas,this.alarmoConfig);1==e.length?this._setArea(new CustomEvent("value-changed",{detail:{value:e[0]}})):e.includes(0)&&this._setArea(new CustomEvent("value-changed",{detail:{value:0}}))}!this.item||this.config.triggers[0].area||this.alarmoConfig.master.enabled||(this.errors=Object.assign(Object.assign({},this.errors),{area:!0}))}render(){var e;return this.hass&&this.areas&&this.alarmoConfig?K` +
+ +
${zn("panels.actions.cards.new_action.title",this.hass.language)}
+
${zn("panels.actions.cards.new_action.description",this.hass.language)}
+
+
${zn("panels.actions.cards.new_notification.trigger",this.hass.language)}
+ +
+ + + ${zn("panels.actions.cards.new_action.fields.event.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_action.fields.event.description",this.hass.language)} + + + gr(e,this.hass))} + label=${zn("panels.actions.cards.new_action.fields.event.heading",this.hass.language)} + icons=${!0} + .value=${this.config.triggers[0].event} + @value-changed=${this._setEvent} + ?invalid=${this.errors.event} + showSearch + > + + + ${Object.keys(this.areas).length>1?K` + + + ${zn("panels.actions.cards.new_action.fields.area.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_action.fields.area.description",this.hass.language)} + + + ur(e,this.areas,this.alarmoConfig))} + label=${zn("panels.actions.cards.new_action.fields.area.heading",this.hass.language)} + .value=${String(this.config.triggers[0].area)} + @value-changed=${this._setArea} + ?invalid=${this.errors.area} + > + + `:""} + + + + ${zn("panels.actions.cards.new_notification.fields.mode.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_notification.fields.mode.description",this.hass.language)} + + + pr(e,this.hass))} + label=${zn("panels.actions.cards.new_action.fields.mode.heading",this.hass.language)} + .value=${this.config.triggers[0].modes||[]} + @value-changed=${this._setModes} + ?invalid=${this.errors.modes} + > + +
+
+ +
${zn("panels.actions.cards.new_notification.action",this.hass.language)}
+ +
+ ${this.viewMode==Nr.UI?K` + + + ${zn("panels.actions.cards.new_action.fields.entity.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_action.fields.entity.description",this.hass.language)} + + + + + + ${this._getEntities().length?K` + + + ${zn("panels.actions.cards.new_action.fields.action.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_action.fields.action.description",this.hass.language)} + + +
+ ${this.renderActions()||zn("panels.actions.cards.new_action.fields.action.no_common_actions",this.hass.language)} +
+ ${this.errors.service?K` + + ${this.hass.localize("ui.common.error_required",this.hass.language)} + + `:""} +
+ `:""} + `:K` +

${zn("components.editor.edit_in_yaml",this.hass.language)}

+ + + + ${this.errors.service||this.errors.entity_id?K` + + ${this.hass.localize("ui.errors.config.key_missing","key",Object.entries(this.errors).find(([e,a])=>a&&["service","entity_id"].includes(e))[0])} + + `:""} + `} +
+ +
+ + + ${this.viewMode==Nr.Yaml?zn("components.editor.ui_mode",this.hass.language):zn("components.editor.yaml_mode",this.hass.language)} + +
+ +
+ + ${zn("panels.actions.cards.new_notification.actions.test",this.hass.language)} + + +
+
+ +
${zn("panels.actions.cards.new_notification.options",this.hass.language)}
+ +
+ + + ${zn("panels.actions.cards.new_action.fields.name.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_action.fields.name.description",this.hass.language)} + + + + + + ${(null===(e=this.item)||void 0===e?void 0:e.automation_id)?K` + + + ${zn("panels.actions.cards.new_notification.fields.delete.heading",this.hass.language)} + + + ${zn("panels.actions.cards.new_notification.fields.delete.description",this.hass.language)} + +
+ + + ${this.hass.localize("ui.common.delete")} + +
+
+ `:""} +
+
+ +
+ + + ${this.hass.localize("ui.common.save")} + +
+ `:K``}renderActions(){let e=this.config.actions.map(e=>e.entity_id),a=wr(e,this.hass);if(!a.length)return;const t=(...e)=>!!e.every(ss)&&1==as(kr(e.filter(ss))).length;return a.map(e=>K` + this._setAction(e)} + > + ${((e,a)=>{let t=gs(e);switch("script"==ps(e)&&(t="run"),t){case"turn_on":return a.localize("ui.card.media_player.turn_on");case"turn_off":return a.localize("ui.card.media_player.turn_off");case"lock":return a.localize("ui.card.lock.lock");case"unlock":return a.localize("ui.card.lock.unlock");case"run":return a.localize("ui.card.script.run");default:return t}})(e,this.hass)} + + `)}_selectedAction(){let e=this.config.actions.map(e=>e.service);return e.every(ss)?(e=as(kr(e.filter(ss))),1==e.length?e[0]:null):null}_setEvent(e){e.stopPropagation();const a=e.detail.value;let t=this.config.triggers;Object.assign(t,{0:Object.assign(Object.assign({},t[0]),{event:a})}),this.config=Object.assign(Object.assign({},this.config),{triggers:t}),Object.keys(this.errors).includes("event")&&this._validateConfig()}_setArea(e){var a;e.stopPropagation();let t=e.detail.value;"0"===t&&(t=0);let i=this.config.triggers;Object.assign(i,{0:Object.assign(Object.assign({},i[0]),{area:t})});const n=br(t,this.areas);(null===(a=i[0].modes)||void 0===a?void 0:a.length)&&this._setModes(new CustomEvent("value-changed",{detail:{value:i[0].modes.filter(e=>n.includes(e))}})),this.config=Object.assign(Object.assign({},this.config),{triggers:i}),Object.keys(this.errors).includes("area")&&this._validateConfig()}_setModes(e){e.stopPropagation();const a=e.detail.value,t=this.config.triggers;Object.assign(t,{0:Object.assign(Object.assign({},t[0]),{modes:a})}),this.config=Object.assign(Object.assign({},this.config),{triggers:t}),Object.keys(this.errors).includes("service")&&this._validateConfig()}_setEntity(e){e.stopPropagation();const a=e.detail.value;let t=this.config.actions,i=null;if(a.length>t.length&&this._selectedAction()&&(i=this._selectedAction()),t.length>a.length){let e=t.findIndex(e=>!a.includes(e.entity_id||""));e<0&&(e=t.length-1),t.splice(e,1)}a.length||Object.assign(t,{0:ns(t[0],"entity_id")}),a.forEach((e,a)=>{let i=t.length>a?Object.assign({},t[a]):{};i=i.entity_id==e?Object.assign({},i):{entity_id:e},Object.assign(t,{[a]:i})}),this.config=Object.assign(Object.assign({},this.config),{actions:t}),i&&this._setAction(i),Object.keys(this.errors).includes("entity_id")&&this._validateConfig()}_setAction(e){let a=this.config.actions,t=this.config.actions.map(e=>e.entity_id);wr(t,this.hass).length&&(a.forEach((t,i)=>{let n=wr(t.entity_id,this.hass),s=(r=e,n.find(e=>e==r||"turn_on"==gs(r)&&"turn_on"==gs(e)||"turn_off"==gs(r)&&"turn_off"==gs(e)||"script"==ps(r)&&"script"==ps(e)));var r;Object.assign(a,{[i]:Object.assign({service:s},ns(t,"service"))})}),this.config=Object.assign(Object.assign({},this.config),{actions:a}),Object.keys(this.errors).includes("service")&&this._validateConfig())}_setName(e){e.stopPropagation();const a=e.target.value;this.config=Object.assign(Object.assign({},this.config),{name:a})}_setYaml(e){let a=e.detail.value,t=[{}];var i;Sr(a)&&(a=[a]),"object"==typeof(i=a)&&null!==i&&Array.isArray(i)&&(a.forEach((e,a)=>{let i={};Sr(e)&&Cr(e.service)&&(i=Object.assign(Object.assign({},i),{service:e.service})),Sr(e)&&Cr(e.entity_id)&&(i=Object.assign(Object.assign({},i),{entity_id:e.entity_id})),Sr(e)&&Sr(e.data)&&(i=Object.assign(Object.assign({},i),{data:e.data})),Object.assign(t,{[a]:i})}),this.config=Object.assign(Object.assign({},this.config),{actions:t}))}_validateConfig(){this.errors={};const e=this._parseAutomation(),a=e.triggers[0];a.event&&Object.values(Wn).includes(a.event)||(this.errors=Object.assign(Object.assign({},this.errors),{event:!0})),ss(a.area)&&_r(this.areas,this.alarmoConfig).includes(a.area)||(this.errors=Object.assign(Object.assign({},this.errors),{area:!0})),(a.modes||[]).every(e=>br(a.area,this.areas).includes(e))||(this.errors=Object.assign(Object.assign({},this.errors),{modes:!0}));let t=e.actions.map(e=>e.entity_id);this.viewMode==Nr.Yaml&&(t=t.filter(ss)),e.actions.length&&t.every(e=>Er(e,this.hass))||(this.errors=Object.assign(Object.assign({},this.errors),{entity_id:!0}));const i=e.actions.map(e=>e.service).filter(ss);if(!i.length||!i.every(e=>Tr(e,this.hass))){this.errors=Object.assign(Object.assign({},this.errors),{service:!0}),!wr(t,this.hass).length&&i.length&&(this.viewMode=Nr.Yaml)}return $r(e.name)||(this.errors=Object.assign(Object.assign({},this.errors),{name:!0})),!Object.values(this.errors).length}_validAction(){const e=this._parseAutomation(),a=e.actions.map(e=>e.service);let t=e.actions.map(e=>e.entity_id);return this.viewMode==Nr.Yaml&&(t=t.filter(ss)),a.length&&a.every(e=>Tr(e,this.hass))&&t.every(e=>Er(e,this.hass))}_toggleYamlMode(){this.viewMode=this.viewMode==Nr.UI?Nr.Yaml:Nr.UI,this.viewMode==Nr.Yaml&&(this.config=Object.assign(Object.assign({},this.config),{actions:Object.assign(this.config.actions,{0:Object.assign(Object.assign({},this.config.actions[0]),{service:this.config.actions[0].service||"",data:Object.assign({},this.config.actions[0].data||{})})})}))}_namePlaceholder(){var e,a,t,i;if(!this._validAction)return"";const n=this.config.triggers[0].event,s=this.config.actions.map(e=>e.entity_id).filter(ss),r=fr(s,this.hass).map(e=>e.name).join(", "),o=as(this.config.actions.map(e=>e.service).filter(ss).map(e=>gs(e)));let d;return 1==o.length&&(null===(e=o[0])||void 0===e?void 0:e.includes("turn_on"))&&(d=this.hass.localize("state.default.on")),1==o.length&&(null===(a=o[0])||void 0===a?void 0:a.includes("turn_off"))&&(d=this.hass.localize("state.default.off")),1==o.length&&(null===(t=o[0])||void 0===t?void 0:t.includes("lock"))&&(d=this.hass.localize("component.lock.state._.locked")),1==o.length&&(null===(i=o[0])||void 0===i?void 0:i.includes("unlock"))&&(d=this.hass.localize("component.lock.state._.unlocked")),n&&r&&d?zn(`panels.actions.cards.new_action.fields.name.placeholders.${n}`,this.hass.language,"entity",r,"state",d):""}_getEntities(){return as(this.config.actions.map(e=>e.entity_id).filter(ss))||[]}_hasCustomEntities(){return this._getEntities().some(e=>!zr(this.hass).includes(e))}_parseAutomation(){let e=Object.assign({},this.config);return!$r(e.name)&&this._namePlaceholder()&&(e=Object.assign(Object.assign({},e),{name:this._namePlaceholder()})),e}_saveClick(e){if(!this._validateConfig())return;let a=this._parseAutomation();br(a.triggers[0].area,this.areas).every(e=>{var t;return null===(t=a.triggers[0].modes)||void 0===t?void 0:t.includes(e)})&&(a=Object.assign(Object.assign({},a),{triggers:Object.assign(a.triggers,{0:Object.assign(Object.assign({},a.triggers[0]),{modes:[]})})})),Ee(this.hass,a).catch(a=>ds(a,e)).then(()=>this._cancelClick())}_deleteClick(e){var a;(null===(a=this.item)||void 0===a?void 0:a.automation_id)&&Se(this.hass,this.item.automation_id).catch(a=>ds(a,e)).then(()=>this._cancelClick())}_testClick(e){this._parseAutomation().actions.forEach(a=>{const[t,i]=a.service.split(".");let n=Object.assign({},a.data);a.entity_id&&(n=Object.assign(Object.assign({},n),{entity_id:a.entity_id})),this.hass.callService(t,i,n).then().catch(a=>{os(e,a.message)})})}_cancelClick(){hs(0,fs("actions"),!0)}static get styles(){return c` + div.content { + padding: 28px 20px 0; + max-width: 1040px; + margin: 0 auto; + display: flex; + flex-direction: column; + } + div.header { + font-size: 24px; + font-weight: 400; + letter-spacing: -0.012em; + line-height: 32px; + opacity: var(--dark-primary-opacity); + } + div.section-header { + font-size: 18px; + font-weight: 400; + letter-spacing: -0.012em; + line-height: 32px; + opacity: var(--dark-primary-opacity); + margin: 20px 0px 5px 10px; + } + div.actions { + padding: 20px 0px 30px 0px; + } + .toggle-button { + position: absolute; + right: 20px; + top: 20px; + } + h2 { + margin-top: 10px; + font-size: 24px; + font-weight: 400; + letter-spacing: -0.012em; + } + span.error-message { + color: var(--error-color); + font-size: 0.875rem; + display: flex; + margin-top: 10px; + } + div.heading { + display: grid; + grid-template-areas: + 'header icon' + 'description icon'; + grid-template-rows: 1fr 1fr; + grid-template-columns: 1fr 48px; + margin: 20px 0px 10px 10px; + } + div.heading .icon { + grid-area: icon; + } + div.heading .header { + grid-area: header; + } + div.heading .description { + grid-area: description; + } + `}};i([ue({attribute:!1})],Lr.prototype,"hass",void 0),i([ue()],Lr.prototype,"narrow",void 0),i([ue()],Lr.prototype,"config",void 0),i([ue()],Lr.prototype,"item",void 0),i([ue()],Lr.prototype,"areas",void 0),i([ue()],Lr.prototype,"alarmoConfig",void 0),i([ue()],Lr.prototype,"viewMode",void 0),i([ue()],Lr.prototype,"errors",void 0),Lr=i([he("automation-editor-card")],Lr);const Hr="no_area";let Dr=class extends(Me(ce)){constructor(){super(...arguments),this.areas={},this.getAreaForAutomation=e=>{if(!this.config)return;const a=_r(this.areas,this.config);let t=e.triggers[0].area;return ss(t)&&a.includes(t)?t:void 0}}hassSubscribe(){return this._fetchData(),[this.hass.connection.subscribeMessage(()=>this._fetchData(),{type:"alarmo_config_updated"})]}async _fetchData(){if(!this.hass)return;const e=await ze(this.hass);this.automations=Object.values(e),this.areas=await Ce(this.hass),this.config=await ye(this.hass)}firstUpdated(){var e;this.path.filter&&(this.selectedArea=null===(e=this.path.filter)||void 0===e?void 0:e.area),(async()=>{await be()})()}render(){if(!this.hass||!this.automations||!this.config)return K``;if("new_notification"==this.path.subpage)return K` + + `;if(this.path.params.edit_notification){const e=this.automations.find(e=>e.automation_id==this.path.params.edit_notification&&e.type==Qn.Notification);return K` + + `}if("new_action"==this.path.subpage)return K` + + `;if(this.path.params.edit_action){const e=this.automations.find(e=>e.automation_id==this.path.params.edit_action&&e.type==Qn.Action);return K` + + `}{const e=()=>K` + + ${zn("panels.actions.cards.notifications.table.no_area_warning",this.hass.language)} + + `,a={type:{width:"40px",renderer:a=>a.area!=Hr||this.config.master.enabled?a.type==Qn.Notification?K` + + `:K` + + `:K` + ${e()} + + `},name:{title:this.hass.localize("ui.common.name"),renderer:a=>K` + ${a.area!=Hr||this.config.master.enabled?"":e()} + ${a.name} + `,width:"40%",grow:!0,text:!0},enabled:{title:zn("common.enabled",this.hass.language),width:"68px",align:"center",renderer:e=>K` + e.stopPropagation()} + @change=${a=>this.toggleEnable(a,e.automation_id)} + > + `}},t=this.automations.filter(e=>e.type==Qn.Notification).map(e=>Object(Object.assign(Object.assign({},e),{id:e.automation_id,warning:!this.config.master.enabled&&!this.getAreaForAutomation(e),area:this.getAreaForAutomation(e)||Hr}))),i=this.automations.filter(e=>e.type==Qn.Action).map(e=>Object(Object.assign(Object.assign({},e),{id:e.automation_id,warning:!this.config.master.enabled&&!this.getAreaForAutomation(e),area:this.getAreaForAutomation(e)||Hr})));return K` + +
+ ${zn("panels.actions.cards.notifications.description",this.hass.language)} +
+ + hs(0,fs("actions",{params:{edit_notification:e.detail.id}}),!0)} + > + ${zn("panels.actions.cards.notifications.table.no_items",this.hass.language)} + + +
+ + ${zn("panels.actions.cards.notifications.actions.new_notification",this.hass.language)} + +
+
+ + +
${zn("panels.actions.cards.actions.description",this.hass.language)}
+ + hs(0,fs("actions",{params:{edit_action:e.detail.id}}),!0)} + > + ${zn("panels.actions.cards.actions.table.no_items",this.hass.language)} + + +
+ + ${zn("panels.actions.cards.actions.actions.new_action",this.hass.language)} + +
+
+ `}}toggleEnable(e,a){Ee(this.hass,{automation_id:a,enabled:e.target.checked}).catch(a=>ds(a,e)).then()}getTableFilterOptions(){if(!this.hass)return;let e=Object.values(this.areas).map(e=>Object({value:e.area_id,name:e.name,badge:a=>a.filter(a=>a.area==e.area_id).length})).sort(ms);Object.values(this.automations||[]).filter(e=>!this.getAreaForAutomation(e)).length&&(e=[{value:Hr,name:this.config.master.enabled?this.config.master.name:this.hass.localize("state_attributes.climate.preset_mode.none"),badge:e=>e.filter(e=>e.area==Hr).length},...e]);return{area:{name:zn("components.table.filter.item",this.hass.language,"name",zn("panels.actions.cards.new_action.fields.area.heading",this.hass.language)),items:e,value:this.selectedArea?[this.selectedArea]:[]}}}addNotificationClick(){hs(0,fs("actions","new_notification"),!0)}addActionClick(){hs(0,fs("actions","new_action"),!0)}};var Pr;Dr.styles=vs,i([ue()],Dr.prototype,"hass",void 0),i([ue()],Dr.prototype,"narrow",void 0),i([ue()],Dr.prototype,"path",void 0),i([ue()],Dr.prototype,"alarmEntity",void 0),i([ue()],Dr.prototype,"automations",void 0),i([ue()],Dr.prototype,"areas",void 0),i([ue()],Dr.prototype,"config",void 0),i([ue()],Dr.prototype,"selectedArea",void 0),Dr=i([he("alarm-view-actions")],Dr),function(e){e.General="general",e.Sensors="sensors",e.Codes="codes",e.Actions="actions"}(Pr||(Pr={}));let Br=class extends ce{async firstUpdated(){window.addEventListener("location-changed",()=>{window.location.pathname.includes("alarmo")&&this.requestUpdate()}),await be(),this.userConfig=await we(this.hass),this.requestUpdate()}render(){if(!customElements.get("ha-panel-config")||!this.userConfig)return K` + loading... + `;const e=bs();return K` +
+
+ +
+ ${zn("title",this.hass.language)} +
+
+ v${"1.10.18"} +
+
+ + + ${Object.values(Pr).map(a=>K` + + ${zn(`panels.${a}.title`,this.hass.language)} + + `)} + +
+
+ ${this.getView(e)} +
+ `}getView(e){switch(e.page){case"general":return K` + + `;case"sensors":return K` + + `;case"codes":return K` + + `;case"actions":return K` + + `;default:return K` + +
+ The page you are trying to reach cannot be found. Please select a page from the menu above to continue. +
+
+ `}}handlePageSelected(e){const a=e.detail.name;a!==bs().page?(hs(0,fs(a)),this.requestUpdate()):scrollTo(0,0)}static get styles(){return c` + ${vs} :host { + color: var(--primary-text-color); + --paper-card-header-color: var(--primary-text-color); + } + .header { + background-color: var(--app-header-background-color); + color: var(--app-header-text-color, white); + border-bottom: var(--app-header-border-bottom, none); + } + .toolbar { + height: var(--header-height); + display: flex; + align-items: center; + font-size: 20px; + padding: 0 16px; + font-weight: 400; + box-sizing: border-box; + } + .main-title { + margin: 0 0 0 24px; + line-height: 20px; + flex-grow: 1; + } + ha-tab-group { + margin-left: max(env(safe-area-inset-left), 24px); + margin-right: max(env(safe-area-inset-right), 24px); + --ha-tab-active-text-color: var(--app-header-text-color, white); + --ha-tab-indicator-color: var(--app-header-text-color, white); + --ha-tab-track-color: transparent; + } + .view { + height: calc(100vh - 112px); + display: flex; + justify-content: center; + } + .view > * { + width: 600px; + max-width: 600px; + } + .view > *:last-child { + margin-bottom: 20px; + } + .version { + font-size: 14px; + color: var(--primary-text-color); + } + `}};i([ue({attribute:!1})],Br.prototype,"hass",void 0),i([ue({type:Boolean,reflect:!0})],Br.prototype,"narrow",void 0),i([ue({attribute:!1})],Br.prototype,"userConfig",void 0),Br=i([he("alarm-panel")],Br);export{Br as MyAlarmPanel}; diff --git a/custom_components/alarmo/helpers.py b/custom_components/alarmo/helpers.py new file mode 100644 index 0000000..f53cd6c --- /dev/null +++ b/custom_components/alarmo/helpers.py @@ -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} diff --git a/custom_components/alarmo/icons.json b/custom_components/alarmo/icons.json new file mode 100644 index 0000000..532d290 --- /dev/null +++ b/custom_components/alarmo/icons.json @@ -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" + } +} \ No newline at end of file diff --git a/custom_components/alarmo/manifest.json b/custom_components/alarmo/manifest.json new file mode 100644 index 0000000..29d3a2a --- /dev/null +++ b/custom_components/alarmo/manifest.json @@ -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" +} \ No newline at end of file diff --git a/custom_components/alarmo/mqtt.py b/custom_components/alarmo/mqtt.py new file mode 100644 index 0000000..9d23794 --- /dev/null +++ b/custom_components/alarmo/mqtt.py @@ -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 + ) diff --git a/custom_components/alarmo/panel.py b/custom_components/alarmo/panel.py new file mode 100644 index 0000000..db2d28d --- /dev/null +++ b/custom_components/alarmo/panel.py @@ -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") diff --git a/custom_components/alarmo/sensors.py b/custom_components/alarmo/sensors.py new file mode 100644 index 0000000..7ffe95d --- /dev/null +++ b/custom_components/alarmo/sensors.py @@ -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) diff --git a/custom_components/alarmo/services.yaml b/custom_components/alarmo/services.yaml new file mode 100644 index 0000000..b7512ce --- /dev/null +++ b/custom_components/alarmo/services.yaml @@ -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: diff --git a/custom_components/alarmo/store.py b/custom_components/alarmo/store.py new file mode 100644 index 0000000..c504323 --- /dev/null +++ b/custom_components/alarmo/store.py @@ -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) diff --git a/custom_components/alarmo/websockets.py b/custom_components/alarmo/websockets.py new file mode 100644 index 0000000..e817b55 --- /dev/null +++ b/custom_components/alarmo/websockets.py @@ -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, + } + ), + ) diff --git a/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc index 87a432b..8fa24f5 100644 Binary files a/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc index 8e9c2fc..506c059 100644 Binary files a/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc index 47b5fe2..1cf0d43 100644 Binary files a/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc index fbc093d..4b3e2a9 100644 Binary files a/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc index 47fb9a8..f3cd814 100644 Binary files a/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc index ef44364..fa4735c 100644 Binary files a/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc index 2cb500c..5b751ae 100644 Binary files a/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc index 5fd38ff..889a5ed 100644 Binary files a/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc index a1176ca..ba60e2b 100644 Binary files a/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc index b3d616d..f6e8e18 100644 Binary files a/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc index cc58a50..f7dd2a0 100644 Binary files a/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc index a034080..3713308 100644 Binary files a/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc index 1c84261..76947b4 100644 Binary files a/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc index 18ed569..0154c6f 100644 Binary files a/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc index cf17d97..ae70b2d 100644 Binary files a/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/config_flow.py b/custom_components/album_slideshow/config_flow.py index f667c2e..9486ec1 100644 --- a/custom_components/album_slideshow/config_flow.py +++ b/custom_components/album_slideshow/config_flow.py @@ -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. diff --git a/custom_components/album_slideshow/const.py b/custom_components/album_slideshow/const.py index b150b12..d50946f 100644 --- a/custom_components/album_slideshow/const.py +++ b/custom_components/album_slideshow/const.py @@ -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" diff --git a/custom_components/album_slideshow/coordinator.py b/custom_components/album_slideshow/coordinator.py index cbf17c2..09b423f 100644 --- a/custom_components/album_slideshow/coordinator.py +++ b/custom_components/album_slideshow/coordinator.py @@ -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 diff --git a/custom_components/album_slideshow/manifest.json b/custom_components/album_slideshow/manifest.json index 1981911..716cdc8 100644 --- a/custom_components/album_slideshow/manifest.json +++ b/custom_components/album_slideshow/manifest.json @@ -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" } diff --git a/custom_components/album_slideshow/photoprism.py b/custom_components/album_slideshow/photoprism.py new file mode 100644 index 0000000..d26dced --- /dev/null +++ b/custom_components/album_slideshow/photoprism.py @@ -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=``, ``q=subject:``, ``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///`` - 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 diff --git a/custom_components/album_slideshow/strings.json b/custom_components/album_slideshow/strings.json index 60af11b..60000ac 100644 --- a/custom_components/album_slideshow/strings.json +++ b/custom_components/album_slideshow/strings.json @@ -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": { diff --git a/custom_components/album_slideshow/translations/en.json b/custom_components/album_slideshow/translations/en.json index 60af11b..60000ac 100644 --- a/custom_components/album_slideshow/translations/en.json +++ b/custom_components/album_slideshow/translations/en.json @@ -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": { diff --git a/custom_components/album_slideshow/www/album-slideshow-card.js b/custom_components/album_slideshow/www/album-slideshow-card.js index 309af12..e998ad6 100644 --- a/custom_components/album_slideshow/www/album-slideshow-card.js +++ b/custom_components/album_slideshow/www/album-slideshow-card.js @@ -26,7 +26,7 @@ * tap_action: none # none | more-info */ -const VERSION = "1.2.2"; +const VERSION = "1.3.0"; const ANIMATED_TRANSITIONS = [ "fade", diff --git a/custom_components/ha_mcp_tools/__init__.py b/custom_components/ha_mcp_tools/__init__.py index e90cb08..6e6fcde 100644 --- a/custom_components/ha_mcp_tools/__init__.py +++ b/custom_components/ha_mcp_tools/__init__.py @@ -34,6 +34,7 @@ from homeassistant.core import ( SupportsResponse, ) from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.storage import Store from homeassistant.loader import async_get_integration from ruamel.yaml import YAMLError @@ -44,6 +45,7 @@ from .const import ( ALLOWED_WRITE_DIRS, ALLOWED_YAML_CONFIG_FILES, ALLOWED_YAML_KEYS, + COMPONENT_VERSION, CONF_ENTRY_TYPE, DASHBOARD_URL_PATH_PATTERN, DENY_PATH_SEGMENTS, @@ -53,11 +55,19 @@ from .const import ( ENTRY_TYPE_TOOLS, PACKAGES_ONLY_YAML_KEYS, RESERVED_DASHBOARD_URL_PATHS, + TOOLS_ENTRY_LEGACY_TITLE, + TOOLS_ENTRY_TITLE, YAML_KEY_DEFAULT_POST_ACTION, YAML_KEY_POST_ACTIONS, ) from .websocket_api import async_register_commands -from .yaml_rt import apply_seq_indent, detect_seq_indent, make_yaml, yaml_dumps +from .yaml_rt import ( + apply_seq_indent, + detect_seq_indent, + make_yaml, + yaml_dumps, + yaml_jsonify, +) _LOGGER = logging.getLogger(__name__) @@ -145,6 +155,10 @@ SERVICE_READ_FILE_SCHEMA = vol.Schema( # dotted path under ``subtree`` (used by ha-mcp's per-edit auto-backup # to snapshot the prior value before ha_config_set_yaml edits it, #1579). vol.Optional("yaml_path"): cv.string, + # With ``yaml_path``, also return that subtree as JSON-safe parsed data + # under ``parsed`` (ha_config_get_yaml's include_parsed, #1788). HA tags + # are rendered to source form, never resolved. + vol.Optional("include_parsed", default=False): cv.boolean, vol.Optional(CALLER_TOKEN_FIELD): cv.string, } ) @@ -689,10 +703,19 @@ def _follow_include(loader: yaml.Loader, node: yaml.nodes.Node) -> Any: if depth >= 8 or not isinstance(name, str) or not name or name.startswith("<"): return None path = os.path.join(os.path.dirname(name), str(node.value)) + # Recorded BEFORE the open, and even when it fails: the caching layer keys + # on these files' mtimes, so an include target that does not exist yet must + # still be tracked (with a -1 stamp) or creating it later would not + # invalidate. + opened = getattr(loader, "_pkg_opened", None) + if opened is not None: + opened.append(path) try: with open(path, encoding="utf-8") as handle: sub = _PackagesDirLoader(handle) sub._pkg_include_depth = depth + 1 + # Same list object, so a nested include lands in one signature. + sub._pkg_opened = opened try: return sub.get_single_data() finally: @@ -742,6 +765,27 @@ def _extract_package_dir_markers(data: object) -> set[str]: return {str(m) for m in markers} +def _load_package_dir_markers_tracked(config_path: str) -> tuple[set[str], list[str]]: + """``_load_package_dir_markers`` that also reports every file it read. + + The file list (configuration.yaml plus any ``!include`` followed from it) is + what ``_package_dir_markers_cached`` keys its mtime signature on, so the + cache invalidates no matter which of them the packages directive lives in. + """ + opened: list[str] = [config_path] + try: + with open(config_path, encoding="utf-8") as handle: + loader = _PackagesDirLoader(handle) + loader._pkg_opened = opened + try: + data = loader.get_single_data() + finally: + loader.dispose() + except (OSError, yaml.YAMLError): + return set(), opened + return _extract_package_dir_markers(data), opened + + def _load_package_dir_markers(config_path: str) -> set[str]: """Parse configuration.yaml (following ``!include``) and return the raw folder argument(s) of every packages ``!include_dir_*named`` directive. @@ -749,16 +793,52 @@ def _load_package_dir_markers(config_path: str) -> set[str]: Blocking (opens files) — call via an executor. Returns raw strings, possibly absolute; the caller relativizes and filters them against the config dir. """ - try: - with open(config_path, encoding="utf-8") as handle: - loader = _PackagesDirLoader(handle) - try: - data = loader.get_single_data() - finally: - loader.dispose() - except (OSError, yaml.YAMLError): - return set() - return _extract_package_dir_markers(data) + markers, _opened = _load_package_dir_markers_tracked(config_path) + return markers + + +def _mtime_sig(paths: list[str]) -> tuple[tuple[str, int], ...]: + """Stamp each path with its mtime, or -1 when it does not exist. + + The -1 is load-bearing: an ``!include`` target that is missing today and + created tomorrow changes the signature, so the cache invalidates instead of + serving folder-detection that predates the file. + """ + sig: list[tuple[str, int]] = [] + for path in paths: + try: + sig.append((path, os.stat(path).st_mtime_ns)) + except OSError: + sig.append((path, -1)) + return tuple(sig) + + +# config_path -> (signature of the files last read, markers found in them). +# Module-level: the folder binding is per config dir, and HA runs one per +# process. Concurrent first-callers may each miss and parse once before the +# entry lands — bounded, self-healing, and cheaper than holding a lock across +# executor threads. +_PACKAGE_DIR_CACHE: dict[str, tuple[tuple[tuple[str, int], ...], set[str]]] = {} + + +def _package_dir_markers_cached(config_path: str) -> set[str]: + """``_load_package_dir_markers``, skipping the parse when nothing changed. + + Blocking (stats files) — call via an executor. Every file operation resolves + the packages folder, and a ``ha_config_get_yaml`` glob fires one detection + per matched file, so this would otherwise re-parse configuration.yaml (and + whatever it includes) N times per search. Stat-per-file is cheap; the YAML + parse is not. + """ + cached = _PACKAGE_DIR_CACHE.get(config_path) + if cached is not None: + signature, markers = cached + if _mtime_sig([path for path, _ in signature]) == signature: + return set(markers) + markers, opened = _load_package_dir_markers_tracked(config_path) + # Copy in and out: the cached set must not be mutable through a caller. + _PACKAGE_DIR_CACHE[config_path] = (_mtime_sig(opened), set(markers)) + return markers def _package_folder_relative_to_config(raw: str, config_dir: str) -> str | None: @@ -800,7 +880,9 @@ async def _detect_package_dirs(hass: HomeAssistant) -> set[str]: config_path = hass.config.path(ALLOWED_YAML_CONFIG_FILES[0]) # normpath is a pure string transform (no I/O), so ASYNC240 doesn't apply. config_dir = os.path.normpath(hass.config.config_dir) # noqa: ASYNC240 - markers = await hass.async_add_executor_job(_load_package_dir_markers, config_path) + markers = await hass.async_add_executor_job( + _package_dir_markers_cached, config_path + ) for raw in markers: folder = _package_folder_relative_to_config(raw, config_dir) if folder: @@ -823,11 +905,34 @@ def _path_in_package_dir(normalized: str, package_dirs: set[str] | None) -> bool ) +def _dir_in_package_dir(normalized: str, package_dirs: set[str] | None) -> bool: + """True if ``normalized`` IS a configured package folder, or a folder under one. + + The file-level twin is ``_path_in_package_dir``; this one matches the + FOLDER itself (and nested folders) so ``list_files`` can enumerate a + packages directory the way ``read_file`` can already read the ``*.yaml`` + inside it (issue #1854). + + Unlike ``_path_in_package_dir``, this does NOT default to ``{"packages"}`` + when ``package_dirs`` is None: ``_is_path_allowed_for_dir`` is shared with + write_file and delete_file, which must never gain package access + (``edit_yaml_config`` is the only write path to config YAML). Only the + read-side lister passes ``package_dirs``. + """ + if not package_dirs: + return False + return any( + normalized == folder or normalized.startswith(folder + "/") + for folder in package_dirs + ) + + def _is_path_allowed_for_dir( config_dir: Path, rel_path: str, allowed_dirs: list[str], extra_dirs: list[str] | None = None, + package_dirs: set[str] | None = None, ) -> bool: """Check if a path is within allowed directories. @@ -835,6 +940,11 @@ def _is_path_allowed_for_dir( granted in addition to ``allowed_dirs``. The non-overridable deny floor is checked first, so a custom directory can never grant access to ``.storage`` or other floored paths. + + ``package_dirs`` (issue #1854) widens ONLY the allow decision — every + containment, deny-floor and symlink check below still runs — and is passed + by the read-side lister alone; write and delete leave it None so a + packages folder stays non-writable through this helper. """ # Absolute HAOS sibling-volume path (issue #1586) — enforced against its # volume root rather than the config dir. Detected by a POSIX-absolute @@ -856,10 +966,16 @@ def _is_path_allowed_for_dir( return False # Built-in allowlist matches on the first segment; user-configured extra - # dirs match on a path-boundary prefix (so nested entries work). + # dirs match on a path-boundary prefix (so nested entries work). A + # configured packages folder matches literally (it may itself be nested, + # e.g. "conf/packages", so a first-segment test would miss it). parts = normalized.split(os.sep) builtin_ok = bool(parts) and parts[0] in allowed_dirs - if not builtin_ok and not _matches_extra_dir(normalized, extra_dirs): + if ( + not builtin_ok + and not _dir_in_package_dir(normalized, package_dirs) + and not _matches_extra_dir(normalized, extra_dirs) + ): return False # Symlink-safe containment on the REAL path the handler will open (issue @@ -952,11 +1068,16 @@ def _mask_secrets_content(content: str) -> str: """Return secrets.yaml content with every secret value masked. Parses the document structurally (ruamel — the same YAML stack used - elsewhere in this component) and emits ``key: [MASKED]`` for each top-level - key. This closes the gap in the previous line-by-line regex, which masked - only single-line ``key: value`` pairs and leaked multi-line block scalars - (``|``, ``>``) whose continuation lines have no colon — SSH keys, TLS - material, and service-account JSON are commonly stored that way. + elsewhere in this component) and emits ``key: "[MASKED]"`` for each + top-level key. This closes the gap in the previous line-by-line regex, which + masked only single-line ``key: value`` pairs and leaked multi-line block + scalars (``|``, ``>``) whose continuation lines have no colon — SSH keys, + TLS material, and service-account JSON are commonly stored that way. + + The marker is quoted because the masked text is itself valid YAML that gets + re-parsed: read_file's ``yaml_path``/``include_parsed`` views load it, and + an unquoted ``[MASKED]`` is flow-sequence syntax, so the parsed view would + render the mask as the list ``["MASKED"]`` instead of a scalar. Fails closed: any content that cannot be parsed and masked as a key-value mapping is withheld rather than returned raw, so a failure on this path never @@ -973,7 +1094,7 @@ def _mask_secrets_content(content: str) -> str: return ( "# secrets.yaml is empty or not a key-value mapping — content withheld" ) - return "\n".join(f"{key}: [MASKED]" for key in parsed) + return "\n".join(f'{key}: "[MASKED]"' for key in parsed) except YAMLError: return "# secrets.yaml could not be parsed — content withheld to avoid leaking secrets" except Exception: @@ -1230,6 +1351,702 @@ async def _run_config_check(hass: HomeAssistant, rel_path: str) -> dict[str, Any return {"config_check": "ok"} +async def _classify_edit_yaml_target( + hass: HomeAssistant, rel_path: str, normalized: str +) -> tuple[bool, bool, dict[str, Any] | None]: + """Validate the edit target path and classify it as package/theme. + + Returns (is_package, is_theme, error). On rejection the bools are False and + error is the response dict; on success error is None. + """ + # Validate file path — only configuration.yaml, packages/*.yaml, and themes/*.yaml + if normalized.startswith("..") or normalized.startswith("/"): + return ( + False, + False, + { + "success": False, + "error": "Path traversal is not allowed.", + }, + ) + + is_config_yaml = normalized in ALLOWED_YAML_CONFIG_FILES + is_theme = fnmatch.fnmatch(normalized, "themes/*.yaml") + # Package files may live under any folder the user binds via + # ``homeassistant: packages: !include_dir_named `` — not just the + # default ``packages`` (issue #1854). The configured folder(s) are + # detected at runtime (parsing configuration.yaml), so only do that work + # when the target isn't already a config-root or theme file. The set + # always includes ``"packages"`` as a fallback. + package_dirs: set[str] = set() + is_package = False + if not is_config_yaml and not is_theme: + package_dirs = await _detect_package_dirs(hass) + is_package = _path_in_package_dir(normalized, package_dirs) + if not is_config_yaml and not is_package and not is_theme: + allowed_pkg = ", ".join(f"{folder}/*.yaml" for folder in sorted(package_dirs)) + return ( + False, + False, + { + "success": False, + "error": ( + f"File '{rel_path}' is not allowed. " + f"Only {', '.join(ALLOWED_YAML_CONFIG_FILES)}, {allowed_pkg}, and themes/*.yaml are supported." + ), + }, + ) + return is_package, is_theme, None + + +def _check_edit_yaml_containment( + config_dir: Path, rel_path: str, normalized: str, is_theme: bool +) -> dict[str, Any] | None: + """Reject an edit target that escapes the config dir or is a hidden theme path.""" + # Symlink-safe containment (parity with the read path, issue #1586): the + # write target is ``config_dir / normalized``, so a package/theme folder + # that is (or contains) a symlink escaping the config dir — or a path + # resolving into the deny floor (.storage / secrets.yaml) — must be + # rejected before writing, exactly as read_file already does. + if _violates_deny_floor(config_dir, normalized) or not _resolves_within( + config_dir, rel_path + ): + _LOGGER.warning("Rejected YAML edit escaping the config dir: %s", rel_path) + return { + "success": False, + "error": ( + f"Path '{rel_path}' resolves outside the config directory or " + "into a protected location and cannot be edited." + ), + } + + # ``themes/*.yaml`` matches dot-prefixed paths (fnmatch's ``*`` matches a + # leading ``.`` and spans ``/``), but HA's ``!include_dir_merge_named`` + # loader skips them: ``annotatedyaml``'s ``_find_files`` filters every + # walked directory AND file basename through ``_is_file_valid`` (which + # is ``return not name.startswith(".")``), so a dot-prefixed file + # (``themes/.hidden.yaml``) OR directory (``themes/.foo/bar.yaml``) is + # pruned and the theme never loads — a phantom ``reload_performed``. + # Reject if any path segment is dot-prefixed, mirroring the loader, + # rather than only checking the basename. + if is_theme and any(seg.startswith(".") for seg in normalized.split("/")): + return { + "success": False, + "error": ( + f"Theme path '{rel_path}' has a dot-prefixed file or " + "directory segment. Home Assistant's !include_dir_merge_named " + "skips any path segment whose name starts with '.', so it " + "would never load. Use a path with no dot-prefixed segment." + ), + } + return None + + +def _check_packages_key_gate( + call: ServiceCall, yaml_path: str, is_package: bool +) -> dict[str, Any] | None: + """Reject a package-only yaml_path key disabled by the caller's runtime config. + + Returns an error response when the top-level key of a packages/*.yaml write + is in the caller's disabled set, else None. + """ + # Caller-provided per-key opt-out for PACKAGES_ONLY_YAML_KEYS. + # Filter to the recognised set so a caller that types + # ``automatoin`` doesn't accidentally pass through; only + # actually-known keys count. + disabled_packages_keys = { + key + for key in call.data.get("disabled_packages_keys", []) + if key in PACKAGES_ONLY_YAML_KEYS + } + # Per-key gate fires only for packages/*.yaml writes. Writes to + # configuration.yaml fall through to ``_parse_and_validate_yaml_path`` + # which rejects PACKAGES_ONLY_YAML_KEYS with the storage-mode- + # tools advisory regardless of this flag. + top_segment = yaml_path.split(".", 1)[0] if yaml_path else "" + if is_package and top_segment in disabled_packages_keys: + return { + "success": False, + "error": ( + f"yaml_path key {top_segment!r} is disabled by the " + f"caller's runtime configuration. Re-enable it in the " + f"caller (the ha-mcp Server Settings → YAML config " + f"editing → 'Allow {top_segment} in packages/*.yaml' " + f"toggle), or use the storage-mode equivalent." + ), + } + return None + + +async def _apply_replace_file( + hass: HomeAssistant, + config_dir: Path, + rel_path: str, + normalized: str, + action: str, + yaml_path: str, + content: str | None, +) -> dict[str, Any]: + """Handle action='replace_file': validate and atomically write the whole file.""" + if not content: + return { + "success": False, + "error": "'content' is required for action 'replace_file'.", + } + try: + whole = await hass.async_add_executor_job( + lambda: make_yaml().load(StringIO(content)) + ) + except YAMLError as err: + return {"success": False, "error": f"Invalid YAML content: {err}"} + if not isinstance(whole, dict): + return { + "success": False, + "error": "Whole-file content must be a YAML mapping at the root.", + } + target_file = config_dir / normalized + try: + # Write the backup content verbatim (faithful restore). Bundles + # mkdir + atomic temp-write+rename + stat into one offload, like + # _write_file_sync. + write_meta = await hass.async_add_executor_job( + _replace_file_sync, target_file, content + ) + except PermissionError: + _LOGGER.error("Permission denied editing: %s", rel_path) + return { + "success": False, + "error": f"Permission denied: {rel_path}", + } + except OSError as err: + _LOGGER.error("Error editing YAML config %s: %s", rel_path, err) + return {"success": False, "error": str(err)} + + _LOGGER.info("YAML config restored whole-file: %s", rel_path) + restore_result: dict[str, Any] = { + "success": True, + "file": rel_path, + "action": action, + "yaml_path": yaml_path, + # Verbatim whole-file write; shares the write-path response + # shape so ``written`` stays a reliable discriminator. + "written": True, + "size": write_meta["size"], + "modified": datetime.fromtimestamp(write_meta["mtime"]).isoformat(), + # A whole-file restore can touch any number of keys; a restart + # is the always-correct activation path. + "post_action": "restart_required", + } + restore_result.update(await _run_config_check(hass, rel_path)) + return restore_result + + +async def _parse_edit_content( + hass: HomeAssistant, action: str, content: str | None, kind: str +) -> tuple[Any, dict[str, Any] | None]: + """Validate content is valid YAML for add/replace. + + Returns (parsed_content, error). For other actions parsed_content is None. + """ + parsed_content: Any = None + if action in ("add", "replace"): + if not content: + return None, { + "success": False, + "error": f"'content' is required for action '{action}'.", + } + try: + parsed_content = await hass.async_add_executor_job( + lambda: make_yaml().load(StringIO(content)) + ) + except YAMLError as err: + return None, { + "success": False, + "error": f"Invalid YAML content: {err}", + } + if parsed_content is None: + return None, { + "success": False, + "error": "Content parsed as null/empty. Provide non-empty YAML.", + } + # Theme content must be a YAML mapping (theme variables) + if kind == "theme" and not isinstance(parsed_content, dict): + return None, { + "success": False, + "error": "Theme content must be a YAML mapping (theme variables).", + } + return parsed_content, None + + +async def _load_edit_target_data( + hass: HomeAssistant, target_file: Path, action: str, rel_path: str +) -> tuple[dict[str, Any], str, dict[str, Any] | None]: + """Read + parse the existing YAML file, or start empty. + + Returns (data, raw_content, error). + """ + # Read existing file content (or start with empty dict) + target_exists = await hass.async_add_executor_job(target_file.exists) + if target_exists: + raw_content = await hass.async_add_executor_job(target_file.read_text) + try: + data = await hass.async_add_executor_job( + lambda: make_yaml().load(StringIO(raw_content)) or {} + ) + except YAMLError as err: + return ( + {}, + "", + { + "success": False, + "error": f"Cannot parse existing file '{rel_path}': {err}", + }, + ) + if not isinstance(data, dict): + return ( + {}, + "", + { + "success": False, + "error": f"File '{rel_path}' root is not a YAML mapping.", + }, + ) + else: + if action == "remove": + return ( + {}, + "", + { + "success": False, + "error": f"File does not exist: {rel_path}", + }, + ) + data = {} + raw_content = "" + return data, raw_content, None + + +def _walk_lovelace_dashboards( + data: dict[str, Any], action: str, parsed_content: Any +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]: + """Validate dashboard content and walk/create lovelace.dashboards in ``data``. + + Returns (lovelace, dashboards, error). On error the mappings are empty dicts. + """ + # filename validation for add/replace + if action in ("add", "replace"): + if not isinstance(parsed_content, dict): + return ( + {}, + {}, + { + "success": False, + "error": "lovelace.dashboards. content must be a YAML mapping", + }, + ) + fn_err = _validate_dashboard_filename(parsed_content.get("filename", "")) + if fn_err is not None: + return {}, {}, {"success": False, "error": fn_err} + + # Walk/create lovelace.dashboards + lovelace = data.setdefault("lovelace", {}) + if not isinstance(lovelace, dict): + return ( + {}, + {}, + { + "success": False, + "error": "Existing 'lovelace' key is not a YAML mapping", + }, + ) + dashboards = lovelace.setdefault("dashboards", {}) + if not isinstance(dashboards, dict): + return ( + {}, + {}, + { + "success": False, + "error": "Existing 'lovelace.dashboards' is not a YAML mapping", + }, + ) + return lovelace, dashboards, None + + +def _apply_lovelace_dashboard_action( + data: dict[str, Any], + lovelace: dict[str, Any], + dashboards: dict[str, Any], + action: str, + url_path: str, + parsed_content: Any, +) -> dict[str, Any] | None: + """Apply add/replace/remove for a single lovelace dashboard in ``data``.""" + if action == "add": + if url_path in dashboards: + existing = dashboards[url_path] + if isinstance(existing, dict) and isinstance(parsed_content, dict): + existing.update(parsed_content) + else: + return { + "success": False, + "error": ( + f"Type mismatch for dashboard '{url_path}': use 'replace' " + "to overwrite." + ), + } + else: + dashboards[url_path] = parsed_content + elif action == "replace": + dashboards[url_path] = parsed_content + elif action == "remove": + if url_path not in dashboards: + return { + "success": False, + "error": ( + f"Dashboard '{url_path}' not found under lovelace.dashboards." + ), + } + del dashboards[url_path] + # Clean up empty parent containers to keep the file tidy + if not dashboards: + del lovelace["dashboards"] + if not lovelace: + del data["lovelace"] + return None + + +def _apply_lovelace_dashboard_edit( + data: dict[str, Any], action: str, url_path: str, parsed_content: Any +) -> dict[str, Any] | None: + """Apply an add/replace/remove to lovelace.dashboards. in ``data``.""" + lovelace, dashboards, walk_err = _walk_lovelace_dashboards( + data, action, parsed_content + ) + if walk_err is not None: + return walk_err + return _apply_lovelace_dashboard_action( + data, lovelace, dashboards, action, url_path, parsed_content + ) + + +def _apply_single_key_edit( + data: dict[str, Any], + action: str, + yaml_key: str, + label: str, + parsed_content: Any, + rel_path: str, +) -> dict[str, Any] | None: + """Apply add/replace/remove for a single top-level key (or theme) in ``data``.""" + # Single-key apply logic, shared by plain config keys and + # theme names (kind == "theme"): a theme file is a mapping of + # theme-name -> variables, and theme content is pre-validated + # as a mapping above, so the list-merge arm never fires for it. + if action == "add": + if yaml_key in data: + existing = data[yaml_key] + # Merge: list extends list, dict merges dict + if isinstance(existing, list) and isinstance(parsed_content, list): + data[yaml_key] = existing + parsed_content + elif isinstance(existing, dict) and isinstance(parsed_content, dict): + existing.update(parsed_content) + else: + return { + "success": False, + "error": ( + f"Type mismatch for {label.lower()} '{yaml_key}': " + f"existing is {type(existing).__name__}, " + f"new content is {type(parsed_content).__name__}. " + "Use action='replace' to overwrite." + ), + } + else: + data[yaml_key] = parsed_content + elif action == "replace": + data[yaml_key] = parsed_content + elif action == "remove": + if yaml_key not in data: + return { + "success": False, + "error": f"{label} '{yaml_key}' not found in '{rel_path}'.", + } + del data[yaml_key] + return None + + +def _apply_edit_action( + data: dict[str, Any], + action: str, + kind: str, + path_parts: tuple[str, ...], + parsed_content: Any, + rel_path: str, +) -> dict[str, Any] | None: + """Apply the requested edit action to ``data`` in place, returning an error or None.""" + if kind == "lovelace_dashboard": + return _apply_lovelace_dashboard_edit( + data, action, path_parts[2], parsed_content + ) + label = "Theme" if kind == "theme" else "Key" + return _apply_single_key_edit( + data, action, path_parts[0], label, parsed_content, rel_path + ) + + +async def _serialize_and_validate_edit( + hass: HomeAssistant, data: dict[str, Any], raw_content: str +) -> tuple[str, dict[str, Any] | None]: + """Serialize ``data`` to YAML and verify it round-trips unchanged. + + Returns (new_content, error); new_content is "" when error is set. + """ + # Serialize back to YAML, keeping the file's dominant top-level + # sequence-dash style (#1720: "changed indentation I didn't ask + # to change"). ruamel supports only ONE sequence style per dump, + # so in a MIXED-style file the minority-style sequences are + # normalized to the first-detected style — that collateral is + # counted below and surfaced as a warning + visible in ``diff``. + seq_style = detect_seq_indent(raw_content) + + def _dump() -> str: + ry = make_yaml() + apply_seq_indent(ry, seq_style) + return yaml_dumps(ry, data) + + try: + new_content = await hass.async_add_executor_job(_dump) + except YAMLError as err: + return "", { + "success": False, + "error": f"Failed to serialize YAML: {err}", + } + + # Validate the result parses cleanly AND round-trips to the + # same data we intend to write. The emitter must not alter any + # parsed value (e.g. by re-wrapping a long line inside a folded + # scalar, which embeds a literal newline in an untouched string + # — #1720); if it would, refuse to write rather than corrupt + # the file silently. + try: + reparsed = await hass.async_add_executor_job( + lambda: make_yaml().load(StringIO(new_content)) + ) + except YAMLError as err: + return "", { + "success": False, + "error": f"Generated YAML failed validation: {err}", + } + if reparsed != data: + return "", { + "success": False, + "error": ( + "Aborted: re-serializing the file would have altered " + "content outside the requested edit (the YAML " + "serializer changed at least one value elsewhere in " + "the file). The file was NOT modified." + ), + } + return new_content, None + + +def _maybe_build_confirm_preview( + call: ServiceCall, + normalized: str, + new_content: str, + rel_path: str, + action: str, + yaml_path: str, + diff_text: str, + reindent_count: int, +) -> dict[str, Any] | None: + """Return a preview response when confirmation is required but unconfirmed.""" + require_confirm = bool(call.data.get("require_confirm", False)) + supplied_token = call.data.get("confirm_token") + expected_token = _confirm_token(normalized, new_content) + if not (require_confirm and supplied_token != expected_token): + return None + preview: dict[str, Any] = { + "success": True, + "preview": True, + "written": False, + "file": rel_path, + "action": action, + "yaml_path": yaml_path, + "diff": diff_text, + "confirm_token": expected_token, + "message": ( + "Preview only — NOTHING was written. Review the diff " + "for unintended changes outside the requested edit, " + "then repeat the exact same call with confirm_token " + "to apply." + ), + } + if reindent_count: + preview["warnings"] = [_reindent_warning(reindent_count)] + if supplied_token is not None: + preview["confirm_token_mismatch"] = True + preview["message"] = ( + "confirm_token did not match — the file changed " + "since the preview (or the token was wrong). NOTHING " + "was written. Review the fresh diff and retry with " + "the new confirm_token." + ) + return preview + + +async def _resolve_post_action( + hass: HomeAssistant, kind: str, path_parts: tuple[str, ...] +) -> dict[str, Any]: + """Return the post-edit activation info (restart/reload) for the edit kind.""" + # Surface the post-edit action required to activate the change + post_info: dict[str, Any] + if kind == "lovelace_dashboard": + post_info = {"post_action": "restart_required"} + elif kind == "theme": + # Trigger frontend.reload_themes to load the theme change + try: + await hass.services.async_call( + "frontend", + "reload_themes", + {}, + blocking=True, + ) + post_info = { + "post_action": "reload_performed", + "reload_service": "frontend.reload_themes", + } + except Exception as reload_err: + post_info = { + "post_action": "reload_available", + "reload_service": "frontend.reload_themes", + "reload_error": str(reload_err), + } + _LOGGER.warning( + "frontend.reload_themes failed after theme edit: %s", + reload_err, + ) + else: + post_info = YAML_KEY_POST_ACTIONS.get( + path_parts[0], YAML_KEY_DEFAULT_POST_ACTION + ) + return post_info + + +async def _apply_yaml_key_edit( + hass: HomeAssistant, + config_dir: Path, + call: ServiceCall, + rel_path: str, + normalized: str, + action: str, + yaml_path: str, + kind: str, + path_parts: tuple[str, ...], + parsed_content: Any, +) -> dict[str, Any]: + """Read, edit, serialize, and atomically write the requested YAML key change.""" + target_file = config_dir / normalized + + try: + data, raw_content, load_err = await _load_edit_target_data( + hass, target_file, action, rel_path + ) + if load_err is not None: + return load_err + + # Pre-write backups are captured MCP-side by ha-mcp's shared + # auto-backup layer (#1579): ha_config_set_yaml snapshots the + # prior key state before calling this service, so the edit is + # restorable via ha_manage_backup(scope="edits"). The component + # no longer writes its own .ha_mcp_tools_backups/ copy. (Pre-fix + # backups already on disk there stay readable; the separate + # GHSA-g39v-cvjh-8fpf startup migration is unaffected.) + + # Perform the action — branch on kind + apply_err = _apply_edit_action( + data, action, kind, path_parts, parsed_content, rel_path + ) + if apply_err is not None: + return apply_err + + new_content, ser_err = await _serialize_and_validate_edit( + hass, data, raw_content + ) + if ser_err is not None: + return ser_err + + diff_text = _unified_diff(raw_content, new_content, rel_path) + reindent_count = _count_reindented_lines(diff_text) + + preview = _maybe_build_confirm_preview( + call, + normalized, + new_content, + rel_path, + action, + yaml_path, + diff_text, + reindent_count, + ) + if preview is not None: + return preview + + # Create parent directories if needed (for new package files). + # mkdir(exist_ok=True) is idempotent so no pre-check is required. + await hass.async_add_executor_job( + lambda: target_file.parent.mkdir(parents=True, exist_ok=True) + ) + + # Atomic write: write to temp file, then rename into place + def _atomic_write() -> None: + tmp_file = target_file.with_suffix(".tmp") + tmp_file.write_text(new_content) + os.replace(str(tmp_file), str(target_file)) + + await hass.async_add_executor_job(_atomic_write) + + stat = await hass.async_add_executor_job(target_file.stat) + modified_dt = datetime.fromtimestamp(stat.st_mtime) + + _LOGGER.info( + "YAML config edited: %s (action=%s, key=%s)", + rel_path, + action, + yaml_path, + ) + + result: dict[str, Any] = { + "success": True, + "file": rel_path, + "action": action, + "yaml_path": yaml_path, + "size": stat.st_size, + "modified": modified_dt.isoformat(), + "written": True, + "diff": diff_text, + } + if reindent_count: + result["warnings"] = [_reindent_warning(reindent_count)] + + # Surface the post-edit action required to activate the change + result.update(await _resolve_post_action(hass, kind, path_parts)) + result.update(await _run_config_check(hass, rel_path)) + return result + + except PermissionError: + _LOGGER.error("Permission denied editing: %s", rel_path) + return { + "success": False, + "error": f"Permission denied: {rel_path}", + } + except OSError as err: + _LOGGER.error("Error editing YAML config %s: %s", rel_path, err) + return { + "success": False, + "error": str(err), + } + + def _build_edit_yaml_config_handler( hass: HomeAssistant, ) -> Callable[[ServiceCall], Awaitable[dict[str, Any]]]: @@ -1248,85 +2065,18 @@ def _build_edit_yaml_config_handler( action = call.data["action"] yaml_path = call.data["yaml_path"] content = call.data.get("content") - # Caller-provided per-key opt-out for PACKAGES_ONLY_YAML_KEYS. - # Filter to the recognised set so a caller that types - # ``automatoin`` doesn't accidentally pass through; only - # actually-known keys count. - disabled_packages_keys = { - key - for key in call.data.get("disabled_packages_keys", []) - if key in PACKAGES_ONLY_YAML_KEYS - } - # Validate file path — only configuration.yaml, packages/*.yaml, and themes/*.yaml normalized = os.path.normpath(rel_path) # noqa: ASYNC240 - if normalized.startswith("..") or normalized.startswith("/"): - return { - "success": False, - "error": "Path traversal is not allowed.", - } - - is_config_yaml = normalized in ALLOWED_YAML_CONFIG_FILES - is_theme = fnmatch.fnmatch(normalized, "themes/*.yaml") - # Package files may live under any folder the user binds via - # ``homeassistant: packages: !include_dir_named `` — not just the - # default ``packages`` (issue #1854). The configured folder(s) are - # detected at runtime (parsing configuration.yaml), so only do that work - # when the target isn't already a config-root or theme file. The set - # always includes ``"packages"`` as a fallback. - package_dirs: set[str] = set() - is_package = False - if not is_config_yaml and not is_theme: - package_dirs = await _detect_package_dirs(hass) - is_package = _path_in_package_dir(normalized, package_dirs) - if not is_config_yaml and not is_package and not is_theme: - allowed_pkg = ", ".join( - f"{folder}/*.yaml" for folder in sorted(package_dirs) - ) - return { - "success": False, - "error": ( - f"File '{rel_path}' is not allowed. " - f"Only {', '.join(ALLOWED_YAML_CONFIG_FILES)}, {allowed_pkg}, and themes/*.yaml are supported." - ), - } - - # Symlink-safe containment (parity with the read path, issue #1586): the - # write target is ``config_dir / normalized``, so a package/theme folder - # that is (or contains) a symlink escaping the config dir — or a path - # resolving into the deny floor (.storage / secrets.yaml) — must be - # rejected before writing, exactly as read_file already does. - if _violates_deny_floor(config_dir, normalized) or not _resolves_within( - config_dir, rel_path - ): - _LOGGER.warning("Rejected YAML edit escaping the config dir: %s", rel_path) - return { - "success": False, - "error": ( - f"Path '{rel_path}' resolves outside the config directory or " - "into a protected location and cannot be edited." - ), - } - - # ``themes/*.yaml`` matches dot-prefixed paths (fnmatch's ``*`` matches a - # leading ``.`` and spans ``/``), but HA's ``!include_dir_merge_named`` - # loader skips them: ``annotatedyaml``'s ``_find_files`` filters every - # walked directory AND file basename through ``_is_file_valid`` (which - # is ``return not name.startswith(".")``), so a dot-prefixed file - # (``themes/.hidden.yaml``) OR directory (``themes/.foo/bar.yaml``) is - # pruned and the theme never loads — a phantom ``reload_performed``. - # Reject if any path segment is dot-prefixed, mirroring the loader, - # rather than only checking the basename. - if is_theme and any(seg.startswith(".") for seg in normalized.split("/")): - return { - "success": False, - "error": ( - f"Theme path '{rel_path}' has a dot-prefixed file or " - "directory segment. Home Assistant's !include_dir_merge_named " - "skips any path segment whose name starts with '.', so it " - "would never load. Use a path with no dot-prefixed segment." - ), - } + is_package, is_theme, target_err = await _classify_edit_yaml_target( + hass, rel_path, normalized + ) + if target_err is not None: + return target_err + containment_err = _check_edit_yaml_containment( + config_dir, rel_path, normalized, is_theme + ) + if containment_err is not None: + return containment_err # Whole-file replace (restore a legacy .bak wholesale, #1579). The # content IS the entire file, so this bypasses the per-key merge — but @@ -1334,74 +2084,13 @@ def _build_edit_yaml_config_handler( # validation, the atomic temp-write+rename, and the post-write config # check. No new files become writable; yaml_path is ignored here. if action == "replace_file": - if not content: - return { - "success": False, - "error": "'content' is required for action 'replace_file'.", - } - try: - whole = await hass.async_add_executor_job( - lambda: make_yaml().load(StringIO(content)) - ) - except YAMLError as err: - return {"success": False, "error": f"Invalid YAML content: {err}"} - if not isinstance(whole, dict): - return { - "success": False, - "error": "Whole-file content must be a YAML mapping at the root.", - } - target_file = config_dir / normalized - try: - # Write the backup content verbatim (faithful restore). Bundles - # mkdir + atomic temp-write+rename + stat into one offload, like - # _write_file_sync. - write_meta = await hass.async_add_executor_job( - _replace_file_sync, target_file, content - ) - except PermissionError: - _LOGGER.error("Permission denied editing: %s", rel_path) - return { - "success": False, - "error": f"Permission denied: {rel_path}", - } - except OSError as err: - _LOGGER.error("Error editing YAML config %s: %s", rel_path, err) - return {"success": False, "error": str(err)} + return await _apply_replace_file( + hass, config_dir, rel_path, normalized, action, yaml_path, content + ) - _LOGGER.info("YAML config restored whole-file: %s", rel_path) - restore_result: dict[str, Any] = { - "success": True, - "file": rel_path, - "action": action, - "yaml_path": yaml_path, - # Verbatim whole-file write; shares the write-path response - # shape so ``written`` stays a reliable discriminator. - "written": True, - "size": write_meta["size"], - "modified": datetime.fromtimestamp(write_meta["mtime"]).isoformat(), - # A whole-file restore can touch any number of keys; a restart - # is the always-correct activation path. - "post_action": "restart_required", - } - restore_result.update(await _run_config_check(hass, rel_path)) - return restore_result - - # Per-key gate fires only for packages/*.yaml writes. Writes to - # configuration.yaml fall through to ``_parse_and_validate_yaml_path`` - # which rejects PACKAGES_ONLY_YAML_KEYS with the storage-mode- - # tools advisory regardless of this flag. - top_segment = yaml_path.split(".", 1)[0] if yaml_path else "" - if is_package and top_segment in disabled_packages_keys: - return { - "success": False, - "error": ( - f"yaml_path key {top_segment!r} is disabled by the " - f"caller's runtime configuration. Re-enable it in the " - f"caller (the ha-mcp Server Settings → YAML config " - f"editing → 'Allow {top_segment} in packages/*.yaml' " - f"toggle), or use the storage-mode equivalent." - ), - } + gate_err = _check_packages_key_gate(call, yaml_path, is_package) + if gate_err is not None: + return gate_err # Parse and validate yaml_path (replaces the old ALLOWED_YAML_KEYS check) kind, path_parts, path_err = _parse_and_validate_yaml_path( @@ -1410,374 +2099,122 @@ def _build_edit_yaml_config_handler( if path_err is not None: return {"success": False, "error": path_err} - # Validate content is valid YAML for add/replace - parsed_content: Any = None - if action in ("add", "replace"): - if not content: - return { - "success": False, - "error": f"'content' is required for action '{action}'.", - } - try: - parsed_content = await hass.async_add_executor_job( - lambda: make_yaml().load(StringIO(content)) - ) - except YAMLError as err: - return { - "success": False, - "error": f"Invalid YAML content: {err}", - } - if parsed_content is None: - return { - "success": False, - "error": "Content parsed as null/empty. Provide non-empty YAML.", - } - # Theme content must be a YAML mapping (theme variables) - if kind == "theme" and not isinstance(parsed_content, dict): - return { - "success": False, - "error": "Theme content must be a YAML mapping (theme variables).", - } + parsed_content, content_err = await _parse_edit_content( + hass, action, content, kind + ) + if content_err is not None: + return content_err - target_file = config_dir / normalized - - try: - # Read existing file content (or start with empty dict) - target_exists = await hass.async_add_executor_job(target_file.exists) - if target_exists: - raw_content = await hass.async_add_executor_job(target_file.read_text) - try: - data = await hass.async_add_executor_job( - lambda: make_yaml().load(StringIO(raw_content)) or {} - ) - except YAMLError as err: - return { - "success": False, - "error": f"Cannot parse existing file '{rel_path}': {err}", - } - if not isinstance(data, dict): - return { - "success": False, - "error": f"File '{rel_path}' root is not a YAML mapping.", - } - else: - if action == "remove": - return { - "success": False, - "error": f"File does not exist: {rel_path}", - } - data = {} - raw_content = "" - - # Pre-write backups are captured MCP-side by ha-mcp's shared - # auto-backup layer (#1579): ha_config_set_yaml snapshots the - # prior key state before calling this service, so the edit is - # restorable via ha_manage_backup(scope="edits"). The component - # no longer writes its own .ha_mcp_tools_backups/ copy. (Pre-fix - # backups already on disk there stay readable; the separate - # GHSA-g39v-cvjh-8fpf startup migration is unaffected.) - - # Perform the action — branch on kind - if kind == "lovelace_dashboard": - url_path = path_parts[2] - - # filename validation for add/replace - if action in ("add", "replace"): - if not isinstance(parsed_content, dict): - return { - "success": False, - "error": "lovelace.dashboards. content must be a YAML mapping", - } - fn_err = _validate_dashboard_filename( - parsed_content.get("filename", "") - ) - if fn_err is not None: - return {"success": False, "error": fn_err} - - # Walk/create lovelace.dashboards - lovelace = data.setdefault("lovelace", {}) - if not isinstance(lovelace, dict): - return { - "success": False, - "error": "Existing 'lovelace' key is not a YAML mapping", - } - dashboards = lovelace.setdefault("dashboards", {}) - if not isinstance(dashboards, dict): - return { - "success": False, - "error": "Existing 'lovelace.dashboards' is not a YAML mapping", - } - - if action == "add": - if url_path in dashboards: - existing = dashboards[url_path] - if isinstance(existing, dict) and isinstance( - parsed_content, dict - ): - existing.update(parsed_content) - else: - return { - "success": False, - "error": ( - f"Type mismatch for dashboard '{url_path}': use 'replace' " - "to overwrite." - ), - } - else: - dashboards[url_path] = parsed_content - elif action == "replace": - dashboards[url_path] = parsed_content - elif action == "remove": - if url_path not in dashboards: - return { - "success": False, - "error": ( - f"Dashboard '{url_path}' not found under lovelace.dashboards." - ), - } - del dashboards[url_path] - # Clean up empty parent containers to keep the file tidy - if not dashboards: - del lovelace["dashboards"] - if not lovelace: - del data["lovelace"] - - else: - # Single-key apply logic, shared by plain config keys and - # theme names (kind == "theme"): a theme file is a mapping of - # theme-name -> variables, and theme content is pre-validated - # as a mapping above, so the list-merge arm never fires for it. - yaml_key = path_parts[0] - label = "Theme" if kind == "theme" else "Key" - if action == "add": - if yaml_key in data: - existing = data[yaml_key] - # Merge: list extends list, dict merges dict - if isinstance(existing, list) and isinstance( - parsed_content, list - ): - data[yaml_key] = existing + parsed_content - elif isinstance(existing, dict) and isinstance( - parsed_content, dict - ): - existing.update(parsed_content) - else: - return { - "success": False, - "error": ( - f"Type mismatch for {label.lower()} '{yaml_key}': " - f"existing is {type(existing).__name__}, " - f"new content is {type(parsed_content).__name__}. " - "Use action='replace' to overwrite." - ), - } - else: - data[yaml_key] = parsed_content - elif action == "replace": - data[yaml_key] = parsed_content - elif action == "remove": - if yaml_key not in data: - return { - "success": False, - "error": f"{label} '{yaml_key}' not found in '{rel_path}'.", - } - del data[yaml_key] - - # Serialize back to YAML, keeping the file's dominant top-level - # sequence-dash style (#1720: "changed indentation I didn't ask - # to change"). ruamel supports only ONE sequence style per dump, - # so in a MIXED-style file the minority-style sequences are - # normalized to the first-detected style — that collateral is - # counted below and surfaced as a warning + visible in ``diff``. - seq_style = detect_seq_indent(raw_content) - - def _dump() -> str: - ry = make_yaml() - apply_seq_indent(ry, seq_style) - return yaml_dumps(ry, data) - - try: - new_content = await hass.async_add_executor_job(_dump) - except YAMLError as err: - return { - "success": False, - "error": f"Failed to serialize YAML: {err}", - } - - # Validate the result parses cleanly AND round-trips to the - # same data we intend to write. The emitter must not alter any - # parsed value (e.g. by re-wrapping a long line inside a folded - # scalar, which embeds a literal newline in an untouched string - # — #1720); if it would, refuse to write rather than corrupt - # the file silently. - try: - reparsed = await hass.async_add_executor_job( - lambda: make_yaml().load(StringIO(new_content)) - ) - except YAMLError as err: - return { - "success": False, - "error": f"Generated YAML failed validation: {err}", - } - if reparsed != data: - return { - "success": False, - "error": ( - "Aborted: re-serializing the file would have altered " - "content outside the requested edit (the YAML " - "serializer changed at least one value elsewhere in " - "the file). The file was NOT modified." - ), - } - - diff_text = _unified_diff(raw_content, new_content, rel_path) - reindent_count = _count_reindented_lines(diff_text) - - require_confirm = bool(call.data.get("require_confirm", False)) - supplied_token = call.data.get("confirm_token") - expected_token = _confirm_token(normalized, new_content) - if require_confirm and supplied_token != expected_token: - preview: dict[str, Any] = { - "success": True, - "preview": True, - "written": False, - "file": rel_path, - "action": action, - "yaml_path": yaml_path, - "diff": diff_text, - "confirm_token": expected_token, - "message": ( - "Preview only — NOTHING was written. Review the diff " - "for unintended changes outside the requested edit, " - "then repeat the exact same call with confirm_token " - "to apply." - ), - } - if reindent_count: - preview["warnings"] = [_reindent_warning(reindent_count)] - if supplied_token is not None: - preview["confirm_token_mismatch"] = True - preview["message"] = ( - "confirm_token did not match — the file changed " - "since the preview (or the token was wrong). NOTHING " - "was written. Review the fresh diff and retry with " - "the new confirm_token." - ) - return preview - - # Create parent directories if needed (for new package files). - # mkdir(exist_ok=True) is idempotent so no pre-check is required. - await hass.async_add_executor_job( - lambda: target_file.parent.mkdir(parents=True, exist_ok=True) - ) - - # Atomic write: write to temp file, then rename into place - def _atomic_write() -> None: - tmp_file = target_file.with_suffix(".tmp") - tmp_file.write_text(new_content) - os.replace(str(tmp_file), str(target_file)) - - await hass.async_add_executor_job(_atomic_write) - - stat = await hass.async_add_executor_job(target_file.stat) - modified_dt = datetime.fromtimestamp(stat.st_mtime) - - _LOGGER.info( - "YAML config edited: %s (action=%s, key=%s)", - rel_path, - action, - yaml_path, - ) - - result: dict[str, Any] = { - "success": True, - "file": rel_path, - "action": action, - "yaml_path": yaml_path, - "size": stat.st_size, - "modified": modified_dt.isoformat(), - "written": True, - "diff": diff_text, - } - if reindent_count: - result["warnings"] = [_reindent_warning(reindent_count)] - - # Surface the post-edit action required to activate the change - if kind == "lovelace_dashboard": - post_info = {"post_action": "restart_required"} - elif kind == "theme": - # Trigger frontend.reload_themes to load the theme change - try: - await hass.services.async_call( - "frontend", - "reload_themes", - {}, - blocking=True, - ) - post_info = { - "post_action": "reload_performed", - "reload_service": "frontend.reload_themes", - } - except Exception as reload_err: - post_info = { - "post_action": "reload_available", - "reload_service": "frontend.reload_themes", - "reload_error": str(reload_err), - } - _LOGGER.warning( - "frontend.reload_themes failed after theme edit: %s", - reload_err, - ) - else: - post_info = YAML_KEY_POST_ACTIONS.get( - path_parts[0], YAML_KEY_DEFAULT_POST_ACTION - ) - result.update(post_info) - result.update(await _run_config_check(hass, rel_path)) - return result - - except PermissionError: - _LOGGER.error("Permission denied editing: %s", rel_path) - return { - "success": False, - "error": f"Permission denied: {rel_path}", - } - except OSError as err: - _LOGGER.error("Error editing YAML config %s: %s", rel_path, err) - return { - "success": False, - "error": str(err), - } + return await _apply_yaml_key_edit( + hass, + config_dir, + call, + rel_path, + normalized, + action, + yaml_path, + kind, + path_parts, + parsed_content, + ) return handle_edit_yaml_config +def _extract_yaml_views( + content: str, yaml_path: str, include_parsed: bool = False +) -> dict[str, Any]: + """Return the subtree at ``yaml_path`` as round-trip text and, optionally, + as JSON-safe parsed data — from a SINGLE parse of ``content``. + + Runs here, in the component, because the round-trip parse needs ``ruamel`` + (a component requirement) which the MCP server's runtime does not carry. + Comments and HA tags (``!secret`` / ``!include``) are preserved in the text + view and rendered to their source form in the parsed view — never resolved, + so neither view can surface a secret's plaintext. + + ``subtree`` is None when the root is not a mapping or the key is absent + (new-key write — nothing to snapshot). Malformed YAML also yields None, but + additionally sets ``parse_error``, so a caller can tell "this file does not + define the key" apart from "this file could not be read at all" — without + it, one broken package silently reads as a key that is simply absent. + ``parsed`` is present only when ``include_parsed`` and the key resolved. + """ + views: dict[str, Any] = {"subtree": None} + try: + ry = make_yaml() + # The per-thread cached instance may carry a sequence style applied + # by a prior edit's dump on this executor thread — reset it so the + # snapshot never inherits another file's indentation. + apply_seq_indent(ry, None) + node: Any = ry.load(StringIO(content)) + for seg in yaml_path.split("."): + if not isinstance(node, dict) or seg not in node: + return views + node = node[seg] + views["subtree"] = yaml_dumps(ry, node) + if include_parsed: + views["parsed"] = yaml_jsonify(node) + except YAMLError as err: + # Position only, never the error text: ruamel embeds the offending + # source line in its message, which would put file content — possibly + # an inline credential — into a response this path otherwise keeps + # free of resolved values. + mark = getattr(err, "problem_mark", None) + where = ( + f" at line {mark.line + 1}, column {mark.column + 1}" + if mark is not None + else "" + ) + return {"subtree": None, "parse_error": f"not valid YAML{where}"} + return views + + def _extract_yaml_subtree(content: str, yaml_path: str) -> str | None: """Return the YAML subtree at the dotted ``yaml_path`` as round-trip text. Used by ``read_file``'s optional ``yaml_path`` to let ha-mcp's per-edit auto-backup snapshot the prior value of a key before ``edit_yaml_config`` - changes it (#1579). Runs here, in the component, because the round-trip - parse needs ``ruamel`` (a component requirement) which the MCP server's - runtime does not carry. Comments and HA tags (``!secret`` / ``!include``) - are preserved. Returns ``None`` when the root is not a mapping or the key - is absent (new-key write — nothing to snapshot); malformed YAML also - yields ``None`` (the edit itself would then fail and report the error). + changes it (#1579). """ - try: - ry = make_yaml() - # The per-thread cached instance may carry a sequence style applied - # by a prior edit's dump on this executor thread — reset it so the - # snapshot never inherits another file's indentation. - apply_seq_indent(ry, None) - node = ry.load(StringIO(content)) - for seg in yaml_path.split("."): - if not isinstance(node, dict) or seg not in node: - return None - node = node[seg] - return yaml_dumps(ry, node) - except YAMLError: - return None + subtree: str | None = _extract_yaml_views(content, yaml_path)["subtree"] + return subtree + + +def _validate_lovelace_dashboard_path( + yaml_path: str, parts: tuple[str, ...] +) -> tuple[str, tuple[str, ...], str | None]: + """Validate a 'lovelace.dashboards.' yaml_path (shape 2). + + Returns (kind, parts, error); on error kind is '' and parts is (). + """ + if parts[:2] != ("lovelace", "dashboards") or len(parts) != 3: + return ( + "", + (), + ( + f"Dotted yaml_path '{yaml_path}' is not supported. " + "The only accepted dotted form is 'lovelace.dashboards.'." + ), + ) + + url_path = parts[2] + if url_path in RESERVED_DASHBOARD_URL_PATHS: + return ( + "", + (), + f"url_path '{url_path}' is reserved by Home Assistant and cannot be used.", + ) + if not DASHBOARD_URL_PATH_PATTERN.fullmatch(url_path): + return ( + "", + (), + ( + f"url_path '{url_path}' is invalid. Must be lowercase letters/digits " + "separated by hyphens (e.g., 'energy-dashboard')." + ), + ) + return "lovelace_dashboard", parts, None def _parse_and_validate_yaml_path( @@ -1855,33 +2292,7 @@ def _parse_and_validate_yaml_path( ) # Shape 2: lovelace.dashboards. - if parts[:2] != ("lovelace", "dashboards") or len(parts) != 3: - return ( - "", - (), - ( - f"Dotted yaml_path '{yaml_path}' is not supported. " - "The only accepted dotted form is 'lovelace.dashboards.'." - ), - ) - - url_path = parts[2] - if url_path in RESERVED_DASHBOARD_URL_PATHS: - return ( - "", - (), - f"url_path '{url_path}' is reserved by Home Assistant and cannot be used.", - ) - if not DASHBOARD_URL_PATH_PATTERN.fullmatch(url_path): - return ( - "", - (), - ( - f"url_path '{url_path}' is invalid. Must be lowercase letters/digits " - "separated by hyphens (e.g., 'energy-dashboard')." - ), - ) - return "lovelace_dashboard", parts, None + return _validate_lovelace_dashboard_path(yaml_path, parts) def _migrate_legacy_backup_dir(config_dir: Path) -> tuple[int, int]: @@ -1999,93 +2410,22 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await async_remove_server_entry(hass, entry) -async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up the HA MCP Tools services from a config entry.""" +def _current_extra_dirs(hass: HomeAssistant) -> list[str]: + """Return the live user-configured extra directories from hass.data.""" + domain_data = hass.data.get(DOMAIN) + if isinstance(domain_data, dict): + paths = domain_data.get(_HASS_DATA_ALLOWED_PATHS_KEY) + if isinstance(paths, list): + return paths + return [] + + +def _build_list_files_handler( + hass: HomeAssistant, +) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]: + """Build the handle_list_files service handler.""" config_dir = Path(hass.config.config_dir) - # Bootstrap the caller-auth token. Generated on first setup, persisted - # to .storage/ha_mcp_tools_auth, cached in hass.data for fast handler - # access. ha-mcp fetches it via the get_caller_token service. - caller_token = await _load_or_create_caller_token(hass) - hass.data.setdefault(DOMAIN, {})[_HASS_DATA_TOKEN_KEY] = caller_token - - # Load the user-configurable extra read/write directories (issue #1567) - # into hass.data so the file handlers read them with no I/O. set_allowed_paths - # updates this in place, so changes apply live (no HA restart). - hass.data.setdefault(DOMAIN, {})[ - _HASS_DATA_ALLOWED_PATHS_KEY - ] = await _load_allowed_paths(hass) - - def _current_extra_dirs() -> list[str]: - """Return the live user-configured extra directories from hass.data.""" - domain_data = hass.data.get(DOMAIN) - if isinstance(domain_data, dict): - paths = domain_data.get(_HASS_DATA_ALLOWED_PATHS_KEY) - if isinstance(paths, list): - return paths - return [] - - # One-time migration of pre-fix YAML backups out of the publicly-served - # www/ directory (GHSA-g39v-cvjh-8fpf). Wrapped so a migration failure - # cannot prevent the integration from loading — the integration's - # normal value (file ops, edit_yaml_config) is unaffected by this. - try: - moved, failed = await hass.async_add_executor_job( - _migrate_legacy_backup_dir, config_dir - ) - except Exception as err: - # Defensive: a migration failure must not block setup_entry, since - # the integration's normal value (file ops, edit_yaml_config) is - # unaffected by whether old backups got relocated. - _LOGGER.error( - "GHSA-g39v-cvjh-8fpf migration failed: %s. Pre-fix backups " - "may still be present in www/yaml_backups/ and reachable " - "via /local/yaml_backups/ — manual cleanup required.", - err, - ) - moved, failed = 0, 0 - - if moved or failed: - if failed: - heading = ( - f"Migrated {moved} YAML backup file(s); **{failed} could " - "not be moved** and remain in `www/yaml_backups/`, still " - "reachable without authentication via `/local/yaml_backups/`. " - "Move or delete them manually before continuing." - ) - else: - heading = ( - f"Moved {moved} YAML backup file(s) from `www/yaml_backups/` " - "to `.ha_mcp_tools_backups/`. The previous location was " - "reachable without authentication via `/local/yaml_backups/`." - ) - message = ( - f"{heading} See " - "[GHSA-g39v-cvjh-8fpf](https://github.com/homeassistant-ai/ha-mcp/security/advisories/GHSA-g39v-cvjh-8fpf). " - "**Rotate any secrets** that appeared in those YAML files " - "(MQTT/REST credentials, webhook IDs, `shell_command` " - "definitions, geofence coordinates). If you version-control " - "your Home Assistant config, also add `.ha_mcp_tools_backups/` " - "to your `.gitignore` so future backups are not committed." - ) - _LOGGER.error(message) - try: - persistent_notification.async_create( - hass, - message, - title="HA MCP Tools — credential exposure (GHSA-g39v-cvjh-8fpf)", - notification_id="ha_mcp_tools_ghsa_g39v_cvjh_8fpf", - ) - except Exception as err: - # Defensive: log line above is the source of truth; the - # notification is best-effort UX and must not block setup. - _LOGGER.warning( - "Could not create persistent notification for " - "GHSA-g39v-cvjh-8fpf migration: [%s] %s", - type(err).__name__, - err, - ) - async def handle_list_files(call: ServiceCall) -> ServiceResponse: """Handle the list_files service call.""" if not _caller_token_ok(hass, call): @@ -2093,15 +2433,21 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b rel_path = call.data["path"] pattern = call.data.get("pattern") - # Security check - extra_dirs = _current_extra_dirs() + # Security check. Package files may live under a non-default folder + # (issue #1854); read_file already resolves and honours the configured + # folder(s), so the lister does too — otherwise a packages folder is + # readable file-by-file but cannot be enumerated, which is what + # ha_config_get_yaml's cross-file key discovery needs (issue #1788). + extra_dirs = _current_extra_dirs(hass) + package_dirs = await _detect_package_dirs(hass) if not _is_path_allowed_for_dir( - config_dir, rel_path, ALLOWED_READ_DIRS, extra_dirs + config_dir, rel_path, ALLOWED_READ_DIRS, extra_dirs, package_dirs ): _LOGGER.warning("Attempted to list files in disallowed path: %s", rel_path) + allowed_display = ALLOWED_READ_DIRS + sorted(package_dirs) + extra_dirs return { "success": False, - "error": f"Path not allowed. Must be in: {', '.join(ALLOWED_READ_DIRS + extra_dirs)}", + "error": f"Path not allowed. Must be in: {', '.join(allowed_display)}", "files": [], } @@ -2149,6 +2495,87 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b "count": len(files), } + return handle_list_files + + +async def _shape_read_file_response( + hass: HomeAssistant, + rel_path: str, + result: dict[str, Any], + *, + tail_lines: int | None, + yaml_path: str | None, + include_parsed: bool, +) -> dict[str, Any]: + """Shape a successful _read_file_sync result into the read_file response. + + Applies secrets masking, log tailing, optional tail, and yaml_path + subtree extraction. + """ + modified_dt = datetime.fromtimestamp(result["mtime"]) + content = result["content"] + stat_size = result["size"] + + # Apply special handling for specific files + normalized = os.path.normpath(rel_path) # noqa: ASYNC240 + + # Mask secrets.yaml + if normalized == "secrets.yaml": + content = _mask_secrets_content(content) + + # Apply tail for log files + if normalized == "home-assistant.log": + lines = content.split("\n") + limit = tail_lines if tail_lines else DEFAULT_LOG_TAIL_LINES + if len(lines) > limit: + content = "\n".join(lines[-limit:]) + truncated = True + else: + truncated = False + + return { + "success": True, + "path": rel_path, + "content": content, + "size": stat_size, + "modified": modified_dt.isoformat(), + "lines_returned": min(len(lines), limit), + "total_lines": len(lines), + "truncated": truncated, + } + + # Apply tail for other files if requested + full_content = content + if tail_lines: + lines = content.split("\n") + if len(lines) > tail_lines: + content = "\n".join(lines[-tail_lines:]) + + response: dict[str, Any] = { + "success": True, + "path": rel_path, + "content": content, + "size": stat_size, + "modified": modified_dt.isoformat(), + } + if yaml_path: + # Extract from the UNTAILED text: tailing is a display concern, and + # the retained tail is both likely to exclude the key and likely to + # be invalid YAML on its own, which would report the key as absent. + response.update( + await hass.async_add_executor_job( + _extract_yaml_views, full_content, yaml_path, include_parsed + ) + ) + return response + + +def _build_read_file_handler( + hass: HomeAssistant, +) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]: + """Build the handle_read_file service handler.""" + config_dir = Path(hass.config.config_dir) + async def handle_read_file(call: ServiceCall) -> ServiceResponse: """Handle the read_file service call.""" if not _caller_token_ok(hass, call): @@ -2156,12 +2583,13 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b rel_path = call.data["path"] tail_lines = call.data.get("tail_lines") yaml_path = call.data.get("yaml_path") + include_parsed = bool(call.data.get("include_parsed", False)) # Security check. Package files may live under a non-default folder # (issue #1854), so resolve the configured folder(s) — the same way the # YAML editor does — and honour them here too, otherwise the pre-write # backup snapshot (a read of the target) blocks edits to those files. - extra_dirs = _current_extra_dirs() + extra_dirs = _current_extra_dirs(hass) package_dirs = await _detect_package_dirs(hass) if not _is_path_allowed_for_read( config_dir, rel_path, extra_dirs, package_dirs @@ -2214,56 +2642,23 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b "error": f"Path is not a file: {rel_path}", } - modified_dt = datetime.fromtimestamp(result["mtime"]) - content = result["content"] - stat_size = result["size"] + return await _shape_read_file_response( + hass, + rel_path, + result, + tail_lines=tail_lines, + yaml_path=yaml_path, + include_parsed=include_parsed, + ) - # Apply special handling for specific files - normalized = os.path.normpath(rel_path) # noqa: ASYNC240 + return handle_read_file - # Mask secrets.yaml - if normalized == "secrets.yaml": - content = _mask_secrets_content(content) - # Apply tail for log files - if normalized == "home-assistant.log": - lines = content.split("\n") - limit = tail_lines if tail_lines else DEFAULT_LOG_TAIL_LINES - if len(lines) > limit: - content = "\n".join(lines[-limit:]) - truncated = True - else: - truncated = False - - return { - "success": True, - "path": rel_path, - "content": content, - "size": stat_size, - "modified": modified_dt.isoformat(), - "lines_returned": min(len(lines), limit), - "total_lines": len(lines), - "truncated": truncated, - } - - # Apply tail for other files if requested - if tail_lines: - lines = content.split("\n") - if len(lines) > tail_lines: - content = "\n".join(lines[-tail_lines:]) - - response: dict[str, Any] = { - "success": True, - "path": rel_path, - "content": content, - "size": stat_size, - "modified": modified_dt.isoformat(), - } - if yaml_path: - response["subtree"] = await hass.async_add_executor_job( - _extract_yaml_subtree, content, yaml_path - ) - return response +def _build_write_file_handler( + hass: HomeAssistant, +) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]: + """Build the handle_write_file service handler.""" + config_dir = Path(hass.config.config_dir) async def handle_write_file(call: ServiceCall) -> ServiceResponse: """Handle the write_file service call.""" @@ -2275,7 +2670,7 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b create_dirs = call.data.get("create_dirs", True) # Security check - only allow writes to specific directories - extra_dirs = _current_extra_dirs() + extra_dirs = _current_extra_dirs(hass) if not _is_path_allowed_for_dir( config_dir, rel_path, ALLOWED_WRITE_DIRS, extra_dirs ): @@ -2336,6 +2731,15 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b "message": f"File {'created' if is_new else 'updated'} successfully", } + return handle_write_file + + +def _build_delete_file_handler( + hass: HomeAssistant, +) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]: + """Build the handle_delete_file service handler.""" + config_dir = Path(hass.config.config_dir) + async def handle_delete_file(call: ServiceCall) -> ServiceResponse: """Handle the delete_file service call.""" if not _caller_token_ok(hass, call): @@ -2343,7 +2747,7 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b rel_path = call.data["path"] # Security check - only allow deletes from specific directories - extra_dirs = _current_extra_dirs() + extra_dirs = _current_extra_dirs(hass) if not _is_path_allowed_for_dir( config_dir, rel_path, ALLOWED_WRITE_DIRS, extra_dirs ): @@ -2392,7 +2796,13 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b "message": f"File deleted successfully: {rel_path}", } - handle_edit_yaml_config = _build_edit_yaml_config_handler(hass) + return handle_delete_file + + +def _build_get_caller_token_handler( + hass: HomeAssistant, +) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]: + """Build the handle_get_caller_token service handler.""" async def handle_get_caller_token(call: ServiceCall) -> ServiceResponse: """Return the caller-auth token to the ha-mcp bootstrap caller. @@ -2455,6 +2865,14 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b "version": version, } + return handle_get_caller_token + + +def _build_get_allowed_paths_handler( + hass: HomeAssistant, +) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]: + """Build the handle_get_allowed_paths service handler.""" + async def handle_get_allowed_paths(call: ServiceCall) -> ServiceResponse: """Return the user-configurable extra directories plus the built-in allowlists and the non-overridable deny floor (issue #1567). @@ -2474,12 +2892,21 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b } return { "success": True, - "paths": _current_extra_dirs(), + "paths": _current_extra_dirs(hass), "builtin_read_dirs": list(ALLOWED_READ_DIRS), "builtin_write_dirs": list(ALLOWED_WRITE_DIRS), "deny_floor": sorted(DENY_PATH_SEGMENTS | DENY_READ_BASENAMES), } + return handle_get_allowed_paths + + +def _build_set_allowed_paths_handler( + hass: HomeAssistant, +) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]: + """Build the handle_set_allowed_paths service handler.""" + config_dir = Path(hass.config.config_dir) + async def handle_set_allowed_paths(call: ServiceCall) -> ServiceResponse: """Replace the user-configurable extra directories (issues #1567, #1586). @@ -2524,6 +2951,14 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b "rejected": rejected, } + return handle_set_allowed_paths + + +def _build_list_legacy_backups_handler( + hass: HomeAssistant, +) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]: + """Build the handle_list_legacy_backups service handler.""" + config_dir = Path(hass.config.config_dir) legacy_backup_dir = config_dir / _LEGACY_BACKUP_DIRNAME async def handle_list_legacy_backups(call: ServiceCall) -> ServiceResponse: @@ -2539,6 +2974,16 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b return {"success": False, "error": str(err), "backups": []} return {"success": True, "backups": backups, "count": len(backups)} + return handle_list_legacy_backups + + +def _build_read_legacy_backup_handler( + hass: HomeAssistant, +) -> Callable[[ServiceCall], Awaitable[ServiceResponse]]: + """Build the handle_read_legacy_backup service handler.""" + config_dir = Path(hass.config.config_dir) + legacy_backup_dir = config_dir / _LEGACY_BACKUP_DIRNAME + async def handle_read_legacy_backup(call: ServiceCall) -> ServiceResponse: """Read one pre-#1579 YAML backup by filename (read-only).""" if not _caller_token_ok(hass, call): @@ -2597,6 +3042,98 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b "modified": modified_dt.isoformat(), } + return handle_read_legacy_backup + + +async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the File & YAML services (tools entry) from a config entry.""" + config_dir = Path(hass.config.config_dir) + + # Bootstrap the caller-auth token. Generated on first setup, persisted + # to .storage/ha_mcp_tools_auth, cached in hass.data for fast handler + # access. ha-mcp fetches it via the get_caller_token service. + caller_token = await _load_or_create_caller_token(hass) + hass.data.setdefault(DOMAIN, {})[_HASS_DATA_TOKEN_KEY] = caller_token + + # Load the user-configurable extra read/write directories (issue #1567) + # into hass.data so the file handlers read them with no I/O. set_allowed_paths + # updates this in place, so changes apply live (no HA restart). + hass.data.setdefault(DOMAIN, {})[ + _HASS_DATA_ALLOWED_PATHS_KEY + ] = await _load_allowed_paths(hass) + + # One-time migration of pre-fix YAML backups out of the publicly-served + # www/ directory (GHSA-g39v-cvjh-8fpf). Wrapped so a migration failure + # cannot prevent the integration from loading — the integration's + # normal value (file ops, edit_yaml_config) is unaffected by this. + try: + moved, failed = await hass.async_add_executor_job( + _migrate_legacy_backup_dir, config_dir + ) + except Exception as err: + # Defensive: a migration failure must not block setup_entry, since + # the integration's normal value (file ops, edit_yaml_config) is + # unaffected by whether old backups got relocated. + _LOGGER.error( + "GHSA-g39v-cvjh-8fpf migration failed: %s. Pre-fix backups " + "may still be present in www/yaml_backups/ and reachable " + "via /local/yaml_backups/ — manual cleanup required.", + err, + ) + moved, failed = 0, 0 + + if moved or failed: + if failed: + heading = ( + f"Migrated {moved} YAML backup file(s); **{failed} could " + "not be moved** and remain in `www/yaml_backups/`, still " + "reachable without authentication via `/local/yaml_backups/`. " + "Move or delete them manually before continuing." + ) + else: + heading = ( + f"Moved {moved} YAML backup file(s) from `www/yaml_backups/` " + "to `.ha_mcp_tools_backups/`. The previous location was " + "reachable without authentication via `/local/yaml_backups/`." + ) + message = ( + f"{heading} See " + "[GHSA-g39v-cvjh-8fpf](https://github.com/homeassistant-ai/ha-mcp/security/advisories/GHSA-g39v-cvjh-8fpf). " + "**Rotate any secrets** that appeared in those YAML files " + "(MQTT/REST credentials, webhook IDs, `shell_command` " + "definitions, geofence coordinates). If you version-control " + "your Home Assistant config, also add `.ha_mcp_tools_backups/` " + "to your `.gitignore` so future backups are not committed." + ) + _LOGGER.error(message) + try: + persistent_notification.async_create( + hass, + message, + title="HA MCP Tools — credential exposure (GHSA-g39v-cvjh-8fpf)", + notification_id="ha_mcp_tools_ghsa_g39v_cvjh_8fpf", + ) + except Exception as err: + # Defensive: log line above is the source of truth; the + # notification is best-effort UX and must not block setup. + _LOGGER.warning( + "Could not create persistent notification for " + "GHSA-g39v-cvjh-8fpf migration: [%s] %s", + type(err).__name__, + err, + ) + + handle_list_files = _build_list_files_handler(hass) + handle_read_file = _build_read_file_handler(hass) + handle_write_file = _build_write_file_handler(hass) + handle_delete_file = _build_delete_file_handler(hass) + handle_edit_yaml_config = _build_edit_yaml_config_handler(hass) + handle_get_caller_token = _build_get_caller_token_handler(hass) + handle_get_allowed_paths = _build_get_allowed_paths_handler(hass) + handle_set_allowed_paths = _build_set_allowed_paths_handler(hass) + handle_list_legacy_backups = _build_list_legacy_backups_handler(hass) + handle_read_legacy_backup = _build_read_legacy_backup_handler(hass) + # Register all services with response support hass.services.async_register( DOMAIN, @@ -2684,12 +3221,47 @@ async def _async_setup_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> b # @require_admin gates each command (see websocket_api.py). async_register_commands(hass) - _LOGGER.info("HA MCP Tools initialized with file management services") + # Entry-level finalization: pick up the #1853 rename on existing installs and + # give the tools entry a device. Both are cosmetic to the integration's core + # value; the one fallible step (the manifest version read below) degrades to + # the compiled-in fallback rather than blocking setup. + # Retitle an entry still carrying the pre-rename default; a user-customized + # title is left untouched (only the exact old default migrates). The tools + # entry registers no update listener, so this async_update_entry cannot + # trigger a reload loop — and the guard is idempotent regardless (the title + # no longer matches on the next setup). + if entry.title == TOOLS_ENTRY_LEGACY_TITLE: + hass.config_entries.async_update_entry(entry, title=TOOLS_ENTRY_TITLE) + + # Register a device (parity with the server entry, which gets one via + # update.py's DeviceInfo). Tied to the config entry, so HA removes it with + # the entry — no unload cleanup needed. The component version comes from the + # manifest like the options-form version hint, degrading to the compiled-in + # COMPONENT_VERSION if that read fails so a manifest hiccup never breaks setup. + component_version = COMPONENT_VERSION + try: + integration = await async_get_integration(hass, DOMAIN) + component_version = str(integration.version) + except Exception as err: + _LOGGER.debug( + "Could not read the component version for the tools device: %s", err + ) + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.entry_id)}, + name=TOOLS_ENTRY_TITLE, + manufacturer="homeassistant-ai", + model="File & YAML editing services", + sw_version=component_version, + configuration_url="https://github.com/homeassistant-ai/ha-mcp", + ) + + _LOGGER.info("HA-MCP File & YAML Tools initialized with file management services") return True async def _async_unload_tools_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload the HA MCP Tools services config entry.""" + """Unload the File & YAML services (tools entry) config entry.""" # Remove all services hass.services.async_remove(DOMAIN, SERVICE_EDIT_YAML_CONFIG) hass.services.async_remove(DOMAIN, SERVICE_LIST_FILES) diff --git a/custom_components/ha_mcp_tools/__pycache__/__init__.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/__init__.cpython-314.pyc index 5f1cb66..9c151fa 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/__init__.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/config_flow.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/config_flow.cpython-314.pyc index f82fe60..a7fd3a2 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/config_flow.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/const.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/const.cpython-314.pyc index bfb6653..0c2eba4 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/const.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/coordinator.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/coordinator.cpython-314.pyc index 870c83e..76eee24 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/coordinator.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/coordinator.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/embedded_entry.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/embedded_entry.cpython-314.pyc index 042d905..a4f5478 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/embedded_entry.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/embedded_entry.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/embedded_server.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/embedded_server.cpython-314.pyc index 7bd52ea..f58000e 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/embedded_server.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/embedded_server.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/embedded_setup.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/embedded_setup.cpython-314.pyc index 9c2f1c2..5b88826 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/embedded_setup.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/embedded_setup.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/hacs_nudge.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/hacs_nudge.cpython-314.pyc new file mode 100644 index 0000000..e75c088 Binary files /dev/null and b/custom_components/ha_mcp_tools/__pycache__/hacs_nudge.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/install_source_check.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/install_source_check.cpython-314.pyc index 68896ad..1a022c7 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/install_source_check.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/install_source_check.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/llm_api.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/llm_api.cpython-314.pyc index 0120e4e..84954dc 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/llm_api.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/llm_api.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/mcp_webhook.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/mcp_webhook.cpython-314.pyc index 905b76d..26a0bd4 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/mcp_webhook.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/mcp_webhook.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/oauth_autoapprove.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/oauth_autoapprove.cpython-314.pyc new file mode 100644 index 0000000..6256b0a Binary files /dev/null and b/custom_components/ha_mcp_tools/__pycache__/oauth_autoapprove.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/oauth_legacy.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/oauth_legacy.cpython-314.pyc new file mode 100644 index 0000000..93f9d80 Binary files /dev/null and b/custom_components/ha_mcp_tools/__pycache__/oauth_legacy.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/ui_panel.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/ui_panel.cpython-314.pyc index f38864b..23db671 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/ui_panel.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/ui_panel.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/update.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/update.cpython-314.pyc index 722476e..e8ef2fe 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/update.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/update.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/websocket_api.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/websocket_api.cpython-314.pyc index 0570960..a04cebe 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/websocket_api.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/websocket_api.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/__pycache__/yaml_rt.cpython-314.pyc b/custom_components/ha_mcp_tools/__pycache__/yaml_rt.cpython-314.pyc index a98f632..eec3a5f 100644 Binary files a/custom_components/ha_mcp_tools/__pycache__/yaml_rt.cpython-314.pyc and b/custom_components/ha_mcp_tools/__pycache__/yaml_rt.cpython-314.pyc differ diff --git a/custom_components/ha_mcp_tools/config_flow.py b/custom_components/ha_mcp_tools/config_flow.py index 07a49c7..70f2205 100644 --- a/custom_components/ha_mcp_tools/config_flow.py +++ b/custom_components/ha_mcp_tools/config_flow.py @@ -12,8 +12,9 @@ menu on the first step: channel / port / bind host / webhook auth / pip spec / server URL. The two entry types are discriminated by ``entry.data[CONF_ENTRY_TYPE]``; the -options-flow dispatcher branches on it so only the server entry gets a -configurable options flow (the tools entry aborts with ``no_options``). +options-flow dispatcher branches on it — the server entry gets the configurable +options flow, and the tools entry gets a light informational options flow +(nothing to configure yet). """ from __future__ import annotations @@ -29,7 +30,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.const import __version__ as HA_VERSION -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -45,6 +46,9 @@ from .const import ( CHANNEL_DEV, CHANNEL_STABLE, CONF_ENTRY_TYPE, + DATA_OAUTH_CLIENT_ID, + DATA_OAUTH_CLIENT_SECRET, + DATA_OAUTH_SIGNING_KEY, DATA_SECRET_PATH, DATA_WEBHOOK_ID, DEFAULT_AUTO_UPDATE, @@ -74,6 +78,9 @@ from .const import ( OPT_ENABLE_WEBHOOK, OPT_EXTERNAL_URL, OPT_LLM_API_EXPOSURE, + OPT_OAUTH_CLIENT_ID, + OPT_OAUTH_CLIENT_SECRET, + OPT_OAUTH_REGENERATE, OPT_PIP_SPEC, OPT_REGENERATE_SECRETS, OPT_SECRET_PATH_OVERRIDE, @@ -81,12 +88,14 @@ from .const import ( OPT_SERVER_URL, OPT_WEBHOOK_AUTH, OPT_WEBHOOK_ID_OVERRIDE, + TOOLS_ENTRY_TITLE, WEBHOOK_AUTH_HA, + WEBHOOK_AUTH_LEGACY, WEBHOOK_AUTH_NONE, ) -# Titles shown for each entry in the integration tile's entry list. -_TOOLS_ENTRY_TITLE = "HA MCP Tools" +# Title shown for the server entry in the integration tile's entry list; the +# tools entry's title lives in const.py (setup migration in __init__ needs it). _SERVER_ENTRY_TITLE = "HA-MCP Server" # The single-instance server entry's unique id — distinct from the tools entry's @@ -97,6 +106,29 @@ _SERVER_UNIQUE_ID = f"{DOMAIN}-server" _LOGGER = logging.getLogger(__name__) +def _legacy_credentials_active( + hass: HomeAssistant, client_id: str, client_secret: str, signing_key: str +) -> bool: + """Deferred-import seam for :func:`oauth_legacy.legacy_credentials_active`. + + oauth_legacy pulls in the aiohttp/HTTP view layer at import time; the + config-flow module must stay importable without it (Home Assistant imports + config flows early, and the flow unit tests stub only the config-entries + surface) — same pattern as ``oauth_legacy._live_auth_mode``. + """ + from .oauth_legacy import legacy_credentials_active + + return legacy_credentials_active(hass, client_id, client_secret, signing_key) + + +def _legacy_restart_pending(hass: HomeAssistant) -> bool: + """Deferred-import seam for :func:`oauth_legacy.legacy_restart_pending` + (see :func:`_legacy_credentials_active` for why the import is deferred).""" + from .oauth_legacy import legacy_restart_pending + + return legacy_restart_pending(hass) + + def _installed_server_version() -> str | None: """Return the installed ha-mcp server version, or None if not installed. @@ -124,13 +156,14 @@ class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg] def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Return the options flow for this entry type. - Only the in-process server entry has options (channel / port / bind / - auth / pip spec / URL). The tools services entry has none, so it returns - a flow that aborts with an explanatory message. + The in-process server entry gets the configurable options flow (channel / + port / bind / auth / pip spec / URL). The tools services entry has + nothing to configure yet, so it gets a light informational options flow + instead of aborting. """ if config_entry.data.get(CONF_ENTRY_TYPE) == ENTRY_TYPE_SERVER: return HaMcpServerOptionsFlow() - return _NoOptionsFlow() + return HaMcpToolsInfoOptionsFlow() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -164,7 +197,7 @@ class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg] def _create_tools_entry(self) -> ConfigFlowResult: """Create the services (tools) config entry.""" return self.async_create_entry( - title=_TOOLS_ENTRY_TITLE, + title=TOOLS_ENTRY_TITLE, data={CONF_ENTRY_TYPE: ENTRY_TYPE_TOOLS}, ) @@ -206,14 +239,31 @@ class HaMcpToolsConfigFlow(ConfigFlow, domain=DOMAIN): # type: ignore[call-arg] return self.async_show_form(step_id="server") -class _NoOptionsFlow(OptionsFlow): - """Options flow for the tools entry: it has no configurable options.""" +class HaMcpToolsInfoOptionsFlow(OptionsFlow): + """Options flow for the tools entry: a light informational form. + + The tools services entry has nothing to configure yet, but aborting the + Configure dialog reads as an error. Show an empty-schema form that explains + what the entry provides instead; submitting persists an empty options + payload. + + The form uses the ``tools_info`` step id, NOT ``init``: the server options + flow already owns ``options.step.init`` in strings.json, so a shared step id + would collide. ``async_step_init`` is the required entry point (it renders + the form); HA routes the form's submit to ``async_step_tools_info``. + """ async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Abort immediately — the services entry exposes no options.""" - return self.async_abort(reason="no_options") + """Render the informational form under the ``tools_info`` step id.""" + return self.async_show_form(step_id="tools_info", data_schema=vol.Schema({})) + + async def async_step_tools_info( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Persist an empty options payload once the info form is submitted.""" + return self.async_create_entry(title="", data={}) class HaMcpServerOptionsFlow(OptionsFlow): @@ -229,6 +279,24 @@ class HaMcpServerOptionsFlow(OptionsFlow): opts = self.config_entry.options schema = vol.Schema( { + # Authentication mode first, directly under the connect URLs + # shown in the step description (#1875): it is the setting users + # most need to find, and legacy mode is what unblocks OAuth-only + # clients such as Google Gemini Spark and Copilot CLI. + vol.Required( + OPT_WEBHOOK_AUTH, + default=opts.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE), + ): SelectSelector( + SelectSelectorConfig( + options=[ + WEBHOOK_AUTH_NONE, + WEBHOOK_AUTH_HA, + WEBHOOK_AUTH_LEGACY, + ], + translation_key="server_webhook_auth", + mode=SelectSelectorMode.DROPDOWN, + ) + ), vol.Required( OPT_CHANNEL, default=opts.get(OPT_CHANNEL, DEFAULT_CHANNEL), @@ -268,16 +336,6 @@ class HaMcpServerOptionsFlow(OptionsFlow): mode=SelectSelectorMode.DROPDOWN, ) ), - vol.Required( - OPT_WEBHOOK_AUTH, - default=opts.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE), - ): SelectSelector( - SelectSelectorConfig( - options=[WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA], - translation_key="server_webhook_auth", - mode=SelectSelectorMode.DROPDOWN, - ) - ), vol.Optional( OPT_PIP_SPEC, # Pre-fill via suggested_value, NOT a schema default: a @@ -296,11 +354,13 @@ class HaMcpServerOptionsFlow(OptionsFlow): ): str, vol.Optional( OPT_SERVER_URL, - description={ - "suggested_value": opts.get( - OPT_SERVER_URL, DEFAULT_LOOPBACK_URL - ) - }, + # Only a genuinely saved override is suggested (same + # pattern as OPT_PIP_SPEC above). Pre-filling + # DEFAULT_LOOPBACK_URL made every options save store the + # constant as an explicit override, which would pin the + # scheme/port even after issue #1890's SSL/port-aware + # loopback derivation. + description={"suggested_value": opts.get(OPT_SERVER_URL, "")}, ): str, vol.Required( OPT_ENABLE_WEBHOOK, @@ -352,6 +412,23 @@ class HaMcpServerOptionsFlow(OptionsFlow): OPT_REGENERATE_SECRETS, default=False, ): bool, + # Legacy OAuth mode (Google Gemini Spark) credential overrides — + # same shape as the webhook id/secret-path overrides above. + # Empty = auto-generate/keep the current value. + vol.Optional( + OPT_OAUTH_CLIENT_ID, + description={"suggested_value": opts.get(OPT_OAUTH_CLIENT_ID, "")}, + ): str, + vol.Optional( + OPT_OAUTH_CLIENT_SECRET, + description={ + "suggested_value": opts.get(OPT_OAUTH_CLIENT_SECRET, "") + }, + ): str, + vol.Optional( + OPT_OAUTH_REGENERATE, + default=False, + ): bool, } ) # The sidebar-panel sentence in the description is only truthful while @@ -370,7 +447,8 @@ class HaMcpServerOptionsFlow(OptionsFlow): data_schema=schema, description_placeholders={ "versions": await self._versions_hint(), - "connect_url": self._connect_url_hint(), + "connect_url": await self._connect_url_hint(), + "oauth_creds": self._oauth_creds_hint(), "llm_api_docs_url": LLM_API_DOCS_URL, "panel_hint": panel_hint, }, @@ -396,15 +474,21 @@ class HaMcpServerOptionsFlow(OptionsFlow): OPT_EXTERNAL_URL, OPT_WEBHOOK_ID_OVERRIDE, OPT_SECRET_PATH_OVERRIDE, + OPT_OAUTH_CLIENT_ID, + OPT_OAUTH_CLIENT_SECRET, ): cleaned[key] = str(cleaned.get(key, "") or "").strip() cleaned[OPT_EXTERNAL_URL] = cleaned[OPT_EXTERNAL_URL].rstrip("/") # server_url gets no _normalize-forced empty like the fields above; strip # it and drop it entirely when blank so a whitespace-only value can't be # stored verbatim (it would bypass the consumer's empty -> loopback - # fallback and break the HA connection). + # fallback and break the HA connection). A value equal to + # DEFAULT_LOOPBACK_URL is likewise dropped: older forms pre-filled it, + # so it reaches here from users who never chose an override, and + # storing it would pin the scheme/port the #1890 loopback derivation + # exists to resolve. server_url = str(cleaned.get(OPT_SERVER_URL, "") or "").strip().rstrip("/") - if server_url: + if server_url and server_url != DEFAULT_LOOPBACK_URL: cleaned[OPT_SERVER_URL] = server_url else: cleaned.pop(OPT_SERVER_URL, None) @@ -451,7 +535,7 @@ class HaMcpServerOptionsFlow(OptionsFlow): f"Server ha-mcp {server_version} ({channel} channel)" ) - def _connect_url_hint(self) -> str: + async def _connect_url_hint(self) -> str: """Return the connect URLs for the options form. The Configure screen is admin-only, so it shows the real resolved @@ -471,10 +555,13 @@ class HaMcpServerOptionsFlow(OptionsFlow): hass = getattr(self, "hass", None) if hass is not None: try: - from .embedded_setup import build_connect_urls + from .embedded_setup import async_get_lan_hosts, build_connect_urls urls = build_connect_urls( - hass, self.config_entry, webhook_enabled=webhook_enabled + hass, + self.config_entry, + webhook_enabled=webhook_enabled, + extra_hosts=await async_get_lan_hosts(hass), ) if urls: return "Connect URL(s):\n" + "\n".join(f"- {u}" for u in urls) @@ -507,3 +594,50 @@ class HaMcpServerOptionsFlow(OptionsFlow): f"http://:{port}{secret_path}" ) return hint + + def _oauth_creds_hint(self) -> str: + """Return the resolved legacy OAuth Client ID + Secret for the options + form, or a note pointing at the mode selector when legacy mode isn't + the CONFIGURED mode. Admin-only screen (like ``_connect_url_hint``), + so showing the secret in cleartext here is acceptable — and this is + the surface the startup log points at while a rotation is pending + (``_surface_connect_urls`` withholds pending credentials from the + log, where a still-valid old-identity token could read them; an HA + admin here is trusted). A pending rotation gets a caveat so the admin + doesn't paste values that only start working after the restart. + """ + configured_mode = str(self.config_entry.options.get(OPT_WEBHOOK_AUTH) or "") + if configured_mode != WEBHOOK_AUTH_LEGACY: + return ( + "Set Authentication mode to legacy OAuth above and save to " + "generate a Client ID and Client Secret." + ) + client_id = self.config_entry.data.get(DATA_OAUTH_CLIENT_ID) + client_secret = self.config_entry.data.get(DATA_OAUTH_CLIENT_SECRET) + if not client_id or not client_secret: + # Not minted yet — the entry hasn't finished a bring-up cycle + # since legacy mode was selected (e.g. this save just turned it + # on). They appear after the next reload. + return ( + "The Client ID and Client Secret appear here once the server " + "has started." + ) + creds = f"Client ID: {client_id}\nClient Secret: {client_secret}" + signing_key = str(self.config_entry.data.get(DATA_OAUTH_SIGNING_KEY) or "") + active = _legacy_credentials_active( + self.hass, str(client_id), str(client_secret), signing_key + ) + if active and not _legacy_restart_pending(self.hass): + return creds # bound and live + # Not serving these credentials yet: a mid-session first enable + # (bound but not live until the restart), a pending rotation (the old + # identity still bound), the webhook disabled, or another integration + # owning the routes. State-agnostic wording — the previous "the + # previous Client ID and Client Secret remain active" was false at a + # first enable and when nothing of ours is bound. Matches the startup + # log's first-enable caveat and the oauth_regenerate help text. + return ( + f"{creds}\n" + "Legacy OAuth is not serving these yet — restart Home Assistant " + "when it asks you to, to activate them." + ) diff --git a/custom_components/ha_mcp_tools/const.py b/custom_components/ha_mcp_tools/const.py index 03a69f5..762a5b0 100644 --- a/custom_components/ha_mcp_tools/const.py +++ b/custom_components/ha_mcp_tools/const.py @@ -24,7 +24,7 @@ DOMAIN = "ha_mcp_tools" # manifest bump that forgets this constant (or vice-versa) fails in CI. The # capability negotiation — not this version — gates each WS command (see # ``websocket_api.CAPABILITIES``). -COMPONENT_VERSION = "1.1.0" +COMPONENT_VERSION = "1.2.2" # Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value # means "tools" so the pre-existing services entry keeps working across the @@ -32,6 +32,12 @@ COMPONENT_VERSION = "1.1.0" CONF_ENTRY_TYPE = "entry_type" ENTRY_TYPE_TOOLS = "tools" ENTRY_TYPE_SERVER = "server" + +# Titles shown for each entry in the integration tile's entry list. Public so +# __init__'s setup migration can retitle pre-#1853 tools entries still +# carrying the legacy default (a user-customized title is left alone). +TOOLS_ENTRY_TITLE = "HA-MCP File & YAML Tools" +TOOLS_ENTRY_LEGACY_TITLE = "HA MCP Tools" MIN_EMBEDDED_HOME_ASSISTANT_VERSION = "2026.6.0" # Allowed directories for file operations (relative to config dir) @@ -290,6 +296,16 @@ DEFAULT_AUTO_UPDATE = True OPT_SERVER_PORT = "server_port" OPT_BIND_HOST = "bind_host" OPT_WEBHOOK_AUTH = "webhook_auth" +# Legacy OAuth mode (self-hosted authorization server, static client_id/secret +# for Google Gemini Spark) credential management — mirrors the +# OPT_WEBHOOK_ID_OVERRIDE / OPT_REGENERATE_SECRETS shape below. Empty override +# fields mean "keep the current value"; OPT_OAUTH_REGENERATE is one-shot. +# _override suffix distinguishes these OPTIONS keys from the DATA_OAUTH_* +# entry.data keys (which store the resolved values under the un-suffixed +# names) — mirrors OPT_WEBHOOK_ID_OVERRIDE vs DATA_WEBHOOK_ID. +OPT_OAUTH_CLIENT_ID = "oauth_client_id_override" +OPT_OAUTH_CLIENT_SECRET = "oauth_client_secret_override" +OPT_OAUTH_REGENERATE = "oauth_regenerate" OPT_PIP_SPEC = "pip_spec" OPT_SERVER_URL = "server_url" # Connect-URL surface + secret management (owner request, parity with the @@ -331,6 +347,22 @@ OPT_ENABLE_SIDEBAR_PANEL = "enable_sidebar_panel" # entry.data keys (persisted ids + secrets; entry.data is fine for secrets). DATA_WEBHOOK_ID = "webhook_id" DATA_SECRET_PATH = "secret_path" +# Legacy OAuth mode credentials, minted by embedded_entry._ensure_secrets and +# consumed by oauth_legacy.LegacyOAuthProvider. DATA_OAUTH_SIGNING_KEY is a hex +# string (entry.data must be JSON-serializable, so raw bytes aren't stored +# directly) — the provider converts it with bytes.fromhex(). The signed token +# payload carries the client_id (not the secret), so rotating the client_id +# revokes every outstanding token at the restart that rebinds the views (see +# LegacyOAuthProvider._validate_token). +# Because validation never involves the client_secret, a secret-only override +# change instead rotates the signing key, evicting outstanding tokens at the +# restart that activates the new credentials (see +# embedded_entry._ensure_legacy_oauth_secrets). Until that restart the bound +# views keep serving the OLD identity, so the startup log withholds rotated +# credentials (embedded_setup._surface_connect_urls). +DATA_OAUTH_CLIENT_ID = "oauth_client_id" +DATA_OAUTH_CLIENT_SECRET = "oauth_client_secret" +DATA_OAUTH_SIGNING_KEY = "oauth_signing_key" DATA_SERVER_USER_ID = "server_user_id" DATA_REFRESH_TOKEN_ID = "refresh_token_id" DATA_ACCESS_TOKEN = "access_token" @@ -375,6 +407,12 @@ DATA_LLM_API_UNSUB = "llm_api_unsub" # Webhook auth modes (mirrors the webhook-proxy add-on's default posture). WEBHOOK_AUTH_NONE = "none" # secret webhook URL is the shared secret (default) WEBHOOK_AUTH_HA = "ha_auth" # HA-native bearer (HA core is the OAuth AS) +# Self-hosted OAuth 2.1 authorization server with a static client_id/secret, +# ported from the webhook-proxy add-on's "legacy" mode. Needed because HA +# core's /auth/authorize does not yet fetch Client ID Metadata Documents for +# cross-origin redirect_uris (home-assistant/core#176282), which is what +# Google Gemini Spark's custom connected apps require. +WEBHOOK_AUTH_LEGACY = "legacy" # Default bind host + port. 9584 (not the add-on's 9583) so this in-process # server and an add-on install can coexist on the same box. @@ -407,14 +445,34 @@ SERVER_USER_NAME = "HA-MCP Server" # namespace (mirrors the webhook-proxy add-on's /api/mcp_proxy/oauth base). OAUTH_BASE = "/api/ha_mcp_tools/oauth" -# HACS "add repository" deep link for the custom component. Shared learn_more_url -# for every repair issue that ends with "install/reinstall the component via -# HACS" (the component-outdated issue and the legacy-HACS-source issue below). +# HACS repository full_names (``owner/repo``, the key HACS's repository registry +# uses) this component may be tracked under: the dedicated integration mirror is +# the current install path; the main ha-mcp server repo is the legacy pre-mirror +# path (see install_source_check). Shared by the legacy-source check and the +# HACS refresh nudge (hacs_nudge) so a repository rename lands in one place. +HACS_MIRROR_REPO_FULL_NAME = "homeassistant-ai/ha-mcp-integration" +HACS_LEGACY_REPO_FULL_NAME = "homeassistant-ai/ha-mcp" + +# HACS "add repository" deep link for the custom component. learn_more_url for +# the legacy-HACS-source repair (install_source_check) only, whose fix really is +# re-adding the repository. The update-held and component-outdated issues point +# at UPDATE_HOLD_DOCS_URL instead — for an already-installed component this deep +# link just opens a blank "add repository" dialog. HACS_COMPONENT_URL = ( "https://my.home-assistant.io/redirect/hacs_repository/" "?owner=homeassistant-ai&repository=ha-mcp-integration&category=integration" ) +# Docs section explaining the automatic-update hold, linked as learn_more_url +# from the update-held and component-outdated repair issues (both resolve by +# updating an already-installed component, not by re-adding a repository). The +# anchor is the GitHub slug of the "Held server updates" heading in +# docs/in-process-server.md; hassfest forbids literal URLs inside strings.json. +UPDATE_HOLD_DOCS_URL = ( + "https://github.com/homeassistant-ai/ha-mcp/blob/master/docs/" + "in-process-server.md#held-server-updates" +) + # Usage guide for the conversation-agent LLM API option (#1745). Injected into # the options form as a description placeholder — hassfest forbids literal # URLs inside strings.json. @@ -449,3 +507,10 @@ ISSUE_UPDATE_HELD = "server_update_held" # mechanism, so this only self-resolves if the user re-adds the dedicated # mirror (homeassistant-ai/ha-mcp-integration). ISSUE_LEGACY_HACS_SOURCE = "legacy_hacs_source" +# Repair issue surfaced when the legacy OAuth mode's root /authorize + /token +# views are out of sync with the CONFIGURED webhook_auth mode — either just +# enabled (views not yet bound with the current credentials) or just disabled +# (views still bound and serving the old identity). aiohttp can neither bind +# nor unbind an HTTP view without a full Home Assistant restart, so both +# transitions need one; see oauth_legacy.bind_legacy_views. +ISSUE_LEGACY_OAUTH_RESTART = "legacy_oauth_restart" diff --git a/custom_components/ha_mcp_tools/embedded_entry.py b/custom_components/ha_mcp_tools/embedded_entry.py index 5da9df4..a087353 100644 --- a/custom_components/ha_mcp_tools/embedded_entry.py +++ b/custom_components/ha_mcp_tools/embedded_entry.py @@ -26,14 +26,23 @@ from homeassistant.core import HomeAssistant, callback from .const import ( DATA_BRINGUP_TASK, DATA_LAST_OPTIONS, + DATA_OAUTH_CLIENT_ID, + DATA_OAUTH_CLIENT_SECRET, + DATA_OAUTH_SIGNING_KEY, DATA_SECRET_PATH, DATA_UPDATE_COORDINATOR, DATA_WEBHOOK_ID, DOMAIN, OPT_ENABLE_SIDEBAR_PANEL, + OPT_ENABLE_WEBHOOK, + OPT_OAUTH_CLIENT_ID, + OPT_OAUTH_CLIENT_SECRET, + OPT_OAUTH_REGENERATE, OPT_REGENERATE_SECRETS, OPT_SECRET_PATH_OVERRIDE, + OPT_WEBHOOK_AUTH, OPT_WEBHOOK_ID_OVERRIDE, + WEBHOOK_AUTH_LEGACY, ) # NOTE: embedded_setup / coordinator (and their embedded_server / mcp_webhook @@ -65,6 +74,9 @@ async def async_setup_server_entry(hass: HomeAssistant, entry: ConfigEntry) -> b from .ui_panel import async_register_ui_panel _ensure_secrets(hass, entry) + # Bind the legacy OAuth root views synchronously here — before the slow + # background bring-up below — so they are live at boot (see the helper). + _prebind_legacy_oauth_views(hass, entry) # Admin-only "Open Web UI" sidebar panel + proxy. Registered while the entry # exists (its proxy returns 503 until the server is actually running), so the @@ -178,6 +190,50 @@ async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> Non await hass.config_entries.async_reload(entry.entry_id) +def _prebind_legacy_oauth_views(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Register the legacy OAuth root ``/authorize`` + ``/token`` views during + entry setup, before the background bring-up's (slow) package install. + + An aiohttp route is only ever live if it is registered before Home Assistant + freezes its HTTP app at the end of startup. Binding these views from the + background bring-up task races HA reaching RUNNING: on a slow-install boot + the routes would register AFTER the freeze — never live until a restart — + and ``bind_legacy_views`` would see ``hass.is_running`` True and file a + restart repair that the restart cannot clear. Binding here, while the entry + is still setting up (``hass.is_running`` is False at boot), mirrors the + webhook-proxy add-on, which binds in its own ``async_setup_entry``. The + bring-up's ``async_register_webhook`` then reuses this already-bound provider. + + Only relevant when legacy is the configured mode and the webhook endpoint is + enabled (legacy OAuth guards that endpoint; with no webhook there is nothing + to protect). A route-ownership conflict with the webhook-proxy add-on is + swallowed here — the bring-up re-encounters it and files the user-facing + start-failed repair. + """ + if str(entry.options.get(OPT_WEBHOOK_AUTH, "")) != WEBHOOK_AUTH_LEGACY: + return + if not bool(entry.options.get(OPT_ENABLE_WEBHOOK, True)): + return + client_id = entry.data.get(DATA_OAUTH_CLIENT_ID) + client_secret = entry.data.get(DATA_OAUTH_CLIENT_SECRET) + signing_key = entry.data.get(DATA_OAUTH_SIGNING_KEY) + if not (client_id and client_secret and signing_key): + # _ensure_secrets mints these whenever legacy mode is configured; a gap + # means a partial config — let the bring-up path surface it. + return + from .mcp_webhook import _register_metadata_views + from .oauth_legacy import LegacyOAuthRouteConflict, bind_legacy_views + + with suppress(LegacyOAuthRouteConflict): + # Register the RFC 8414/9728 discovery views alongside the root + # /authorize + /token views, both at setup time, so the discovery + # doc's resource_metadata URL resolves at boot for RFC-compliant + # clients — not just the root views. Both are idempotent, so the + # bring-up's async_register_webhook reuses them. + _register_metadata_views(hass) + bind_legacy_views(hass, client_id, client_secret, signing_key) + + def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None: """Generate + persist the stable webhook id and secret path on first setup. @@ -192,6 +248,13 @@ def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None: ``secret_path_override`` replaces the stored value (normalized: the secret path gets a leading ``/``). 3. First setup: mint random values for whatever is still missing. + + When the configured webhook auth mode is legacy, the same three-path + lifecycle additionally applies to the legacy OAuth client_id/client_secret + (see :func:`_ensure_legacy_oauth_secrets`) — folded into this same + read-mutate-write cycle so a single ``async_update_entry`` call covers + every field that changed this load, including when BOTH a webhook secret + regenerate and an OAuth credential regenerate are requested together. """ data = dict(entry.data) options = dict(entry.options) @@ -206,20 +269,19 @@ def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None: options[OPT_REGENERATE_SECRETS] = False options[OPT_WEBHOOK_ID_OVERRIDE] = "" options[OPT_SECRET_PATH_OVERRIDE] = "" - hass.config_entries.async_update_entry(entry, data=data, options=options) - return - - webhook_override = str(options.get(OPT_WEBHOOK_ID_OVERRIDE) or "").strip() - if webhook_override and data.get(DATA_WEBHOOK_ID) != webhook_override: - data[DATA_WEBHOOK_ID] = webhook_override changed = True - path_override = str(options.get(OPT_SECRET_PATH_OVERRIDE) or "").strip() - if path_override: - if not path_override.startswith("/"): - path_override = f"/{path_override}" - if data.get(DATA_SECRET_PATH) != path_override: - data[DATA_SECRET_PATH] = path_override + else: + webhook_override = str(options.get(OPT_WEBHOOK_ID_OVERRIDE) or "").strip() + if webhook_override and data.get(DATA_WEBHOOK_ID) != webhook_override: + data[DATA_WEBHOOK_ID] = webhook_override changed = True + path_override = str(options.get(OPT_SECRET_PATH_OVERRIDE) or "").strip() + if path_override: + if not path_override.startswith("/"): + path_override = f"/{path_override}" + if data.get(DATA_SECRET_PATH) != path_override: + data[DATA_SECRET_PATH] = path_override + changed = True if not data.get(DATA_WEBHOOK_ID): data[DATA_WEBHOOK_ID] = f"mcp_{secrets.token_hex(16)}" @@ -227,5 +289,117 @@ def _ensure_secrets(hass: HomeAssistant, entry: ConfigEntry) -> None: if not data.get(DATA_SECRET_PATH): data[DATA_SECRET_PATH] = f"/private_{secrets.token_urlsafe(16)}" changed = True + + if options.get(OPT_WEBHOOK_AUTH) == WEBHOOK_AUTH_LEGACY: + changed = _ensure_legacy_oauth_secrets(data, options) or changed + if changed: - hass.config_entries.async_update_entry(entry, data=data) + hass.config_entries.async_update_entry(entry, data=data, options=options) + + +def _ensure_legacy_oauth_secrets(data: dict, options: dict) -> bool: + """Mint/persist/rotate the legacy OAuth mode's credentials into ``data`` + (mutated in place, along with ``options`` for the one-shot regenerate + flag). Only called while the configured webhook auth mode is legacy — + switching away leaves whatever was last minted in place, so switching + back reuses it rather than silently rotating. + + Mirrors the ``OPT_REGENERATE_SECRETS`` shape above: ``OPT_OAUTH_REGENERATE`` + is one-shot, minting a fresh client_id/client_secret and clearing itself + plus the two override fields. + + ANY credential change — regenerate, a client_id override, or a + client_secret override — also rotates ``signing_key``, making every + rotation a hard revocation of outstanding tokens (they take effect at the + restart that rebinds the views). Rotating the key on a secret change is + load-bearing (token validation never involves the secret, so the cid + claim alone would not evict anything). Rotating it on a client_id change + is defence in depth: the cid claim already evicts on a normal rotation, + but without a fresh key an ``A → B → A`` client_id sequence would + resurrect id-A's still-unexpired tokens, since they re-match the cid + claim under the unchanged-key HMAC. Rotating the key kills that echo at + zero cost. Rotation does NOT shorten the pre-restart window — the bound + views keep serving the old identity until the restart (see + ``oauth_legacy.bind_legacy_views``) — which is why the startup log also + withholds rotated credentials until they are active + (``embedded_setup._surface_connect_urls`` via + ``oauth_legacy.legacy_credentials_active``; review findings on #1880). + + Returns True if ``data``/``options`` were mutated. + """ + changed = False + if options.get(OPT_OAUTH_REGENERATE): + data[DATA_OAUTH_CLIENT_ID] = f"hamcp-{secrets.token_hex(16)}" + data[DATA_OAUTH_CLIENT_SECRET] = secrets.token_urlsafe(32) + # Every credential change rotates the key — see docstring (kills the + # A->B->A client_id resurrection; regenerate mints random ids so it + # can't recur to a former id, but the key rotation is kept uniform). + data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32) + options[OPT_OAUTH_REGENERATE] = False + options[OPT_OAUTH_CLIENT_ID] = "" + options[OPT_OAUTH_CLIENT_SECRET] = "" + changed = True + else: + client_id_override = str(options.get(OPT_OAUTH_CLIENT_ID) or "").strip() + if client_id_override and data.get(DATA_OAUTH_CLIENT_ID) != client_id_override: + data[DATA_OAUTH_CLIENT_ID] = client_id_override + # Rotate the key so a re-used former client_id can't resurrect its + # old tokens (see docstring). + data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32) + changed = True + client_secret_override = str(options.get(OPT_OAUTH_CLIENT_SECRET) or "").strip() + if ( + client_secret_override + and data.get(DATA_OAUTH_CLIENT_SECRET) != client_secret_override + ): + data[DATA_OAUTH_CLIENT_SECRET] = client_secret_override + # Evict outstanding tokens along with the old secret — see the + # docstring for why a secret-only rotation must not leave them + # valid for the rest of their TTL. + data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32) + changed = True + # Consume the OAuth override fields once applied. entry.options is + # readable through the server's own tools + # (ha_get_integration(include_options=True) rebuilds it from the + # options-form suggested_values), so a rotated client_secret left here + # in cleartext would let a pre-restart old-identity token holder read + # the NEW secret that way — the exact party the rotation evicts, and + # the same leak the startup log withholds. The resolved values live in + # entry.data and on the admin-only Configure screen + # (config_flow._oauth_creds_hint), so nothing is lost. Cleared even + # when the override matched the current value: the cleartext must not + # linger regardless of whether it changed data. + # + # DELIBERATE divergence from the webhook_id / secret_path overrides + # above, which persist. Three reasons the OAuth secret is different: + # (1) A client_secret override IS the OAuth rotation path and gates a + # per-connection revocable bearer, so a lingering copy defeats the + # very revocation the rotation performs. secret_path's own rotation + # (OPT_REGENERATE_SECRETS) already clears its override, so that + # path is not self-defeating; a standalone secret_path override is + # configuration, not rotation. + # (2) A connected legacy client learns the OAuth secret only via this + # options leak (entry.data is not tool-exposed; the webhook is + # OAuth-gated). secret_path gates the direct LAN port, and per + # SECURITY.md the local network is the trusted zone and the client + # is a trusted principal — LAN-peer access to standard-mode + # endpoints is explicitly out of scope. + # (3) Persisting the webhook_id/secret_path override is deliberate UX + # (the admin sees their configured value in the form). + if options.get(OPT_OAUTH_CLIENT_ID): + options[OPT_OAUTH_CLIENT_ID] = "" + changed = True + if options.get(OPT_OAUTH_CLIENT_SECRET): + options[OPT_OAUTH_CLIENT_SECRET] = "" + changed = True + + if not data.get(DATA_OAUTH_CLIENT_ID): + data[DATA_OAUTH_CLIENT_ID] = f"hamcp-{secrets.token_hex(16)}" + changed = True + if not data.get(DATA_OAUTH_CLIENT_SECRET): + data[DATA_OAUTH_CLIENT_SECRET] = secrets.token_urlsafe(32) + changed = True + if not data.get(DATA_OAUTH_SIGNING_KEY): + data[DATA_OAUTH_SIGNING_KEY] = secrets.token_hex(32) + changed = True + return changed diff --git a/custom_components/ha_mcp_tools/embedded_server.py b/custom_components/ha_mcp_tools/embedded_server.py index 3e94f19..c2e3158 100644 --- a/custom_components/ha_mcp_tools/embedded_server.py +++ b/custom_components/ha_mcp_tools/embedded_server.py @@ -50,6 +50,8 @@ from homeassistant.requirements import ( pip_kwargs, ) from homeassistant.util.package import install_package +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name from packaging.version import InvalidVersion, Version from .const import ( @@ -83,6 +85,8 @@ from .const import ( ) if TYPE_CHECKING: + from collections.abc import Callable + from homeassistant.config_entries import ConfigEntry _LOGGER = logging.getLogger(__name__) @@ -92,13 +96,20 @@ _LOGGER = logging.getLogger(__name__) # from the same refresh token on every start regardless. _ACCESS_TOKEN_TTL = timedelta(days=3650) -# Readiness probe: how long to wait for the server thread to accept a loopback -# TCP connection before declaring the start failed. Generous on purpose: a -# cold import of the fastmcp tree takes 1-3s on real hardware but has been -# observed to exceed 30s on QEMU-emulated HAOS (the e2e lane), and a single -# readiness timeout fails the bring-up outright — there is no retry. Real -# deployments only pay this budget on the failure path. -_READY_TIMEOUT_SECONDS = 90.0 +# Readiness probe: fail the bring-up only when there is no observable startup +# progress (no new modules landing in sys.modules, no phase advance) for this +# long. The previous flat 90s deadline assumed real hardware imports the +# server tree in seconds — issue #1904 (HA Green) showed a cold import alone +# can take minutes there, and killing the still-importing worker at the +# deadline is what created the orphaned-thread/port-collision cascade: the +# worker cannot be joined mid-import, lingered as a zombie, and later bound +# the port out from under the retry. The module count is process-wide (see +# _progress_signature), so a wedged worker trips the stall budget on a quiet +# instance and the absolute cap below at the latest. +_READY_STALL_TIMEOUT_SECONDS = 90.0 +# Absolute ceiling on one bring-up regardless of apparent progress, matching +# the HAOS e2e lane's own 600s readiness deadline. +_READY_TOTAL_CAP_SECONDS = 600.0 _READY_POLL_INTERVAL_SECONDS = 0.5 # How long to wait for the worker thread to exit on stop before giving up and @@ -113,12 +124,66 @@ _PIP_INSTALL_TIMEOUT_SECONDS = 300 # subprocess can never tie up an executor thread indefinitely. _PIP_UNINSTALL_TIMEOUT_SECONDS = 120 +# How long a bring-up waits for an install job orphaned by a CANCELLED +# previous bring-up before giving up: asyncio cancellation detaches the +# awaiter but an executor pip job runs to completion regardless. Sized to +# the same absolute budget as the readiness cap: pip's own timeout (300s) +# is per-download, so a healthy cold install pulling the whole dependency +# tree on slow hardware can legitimately run for minutes — a tighter bound +# would misreport it as stuck. On expiry the bring-up fails with a clear +# message and the next reload retries. +_PENDING_INSTALL_WAIT_SECONDS = 600.0 + # The in-process connection API was added with the embedded server in 7.10.0. # Older distributions can be left behind by an unsupported Core version's # constraints and must never enter the worker thread. MIN_EMBEDDED_SERVER_VERSION = "7.10.0" +def _derive_loopback_url(hass: HomeAssistant) -> tuple[str, bool | None]: + """Resolve the loopback base URL for HA core from the http integration. + + Returns ``(url, verify_ssl)`` where ``verify_ssl`` is ``False`` when the + URL is ``https`` (HA's certificate is issued for its hostname, never for + 127.0.0.1, so verification on the loopback hop can only fail) and ``None`` + when no override of the server's default is needed. + + The hardcoded ``http://127.0.0.1:8123`` default this replaces broke every + instance with ``http.ssl_certificate`` configured (issue #1890): port 8123 + speaks TLS there, so the server's plaintext REST/WS calls died with + "Server disconnected without sending a response" / "did not receive a + valid HTTP response" on every tool call — while the MCP handshake and + tools/list (no HA round-trip) kept working. A custom ``server_port`` + similarly broke the hardcoded port. Both live in ``hass.config.api``, + set by the ``http`` integration this component depends on; the constant + remains the fallback if it is ever absent. + """ + api = getattr(hass.config, "api", None) + if api is None: + # Leave a trail: if this ever fires on a real instance, the resulting + # failure looks exactly like issue #1890 (TLS loopback broken, MCP + # handshake fine) and took a live reproduction to diagnose last time. + _LOGGER.debug( + "hass.config.api unavailable; using hardcoded loopback default %s", + DEFAULT_LOOPBACK_URL, + ) + return DEFAULT_LOOPBACK_URL, None + # Strict type checks (not coercion / truthiness): a malformed api object + # must resolve to the plaintext default on port 8123, never to a surprise + # port or https flip. bool is excluded because it is an int subclass. + port_raw = getattr(api, "port", None) + port = ( + port_raw + if isinstance(port_raw, int) + and not isinstance(port_raw, bool) + and 0 < port_raw <= 65535 + else 8123 + ) + if getattr(api, "use_ssl", False) is True: + return f"https://127.0.0.1:{port}", False + return f"http://127.0.0.1:{port}", None + + class EmbeddedServerError(Exception): """Raised when the in-process ha-mcp server could not be installed or started. @@ -146,9 +211,17 @@ class EmbeddedServerManager: options = entry.options self._port: int = int(options.get(OPT_SERVER_PORT, DEFAULT_SERVER_PORT)) self._bind_host: str = str(options.get(OPT_BIND_HOST, DEFAULT_BIND_HOST)) - self._server_url: str = str( - options.get(OPT_SERVER_URL) or DEFAULT_LOOPBACK_URL - ).rstrip("/") + # An explicit server_url override wins verbatim (the operator manages + # scheme/verification themselves via the settings UI). A stored value + # equal to DEFAULT_LOOPBACK_URL is treated as no-override: the options + # form used to pre-fill that constant as suggested_value, so existing + # entries carry it without the user ever having chosen it. + _url_override = str(options.get(OPT_SERVER_URL) or "").rstrip("/") + self._loopback_verify_ssl: bool | None = None + if _url_override and _url_override != DEFAULT_LOOPBACK_URL: + self._server_url: str = _url_override + else: + self._server_url, self._loopback_verify_ssl = _derive_loopback_url(hass) self._channel: str = str(options.get(OPT_CHANNEL) or DEFAULT_CHANNEL) # An explicit pip-spec override (the pre-release test channel) wins over # the channel selector. DEFAULT_PIP_SPEC in the field means "no override, @@ -189,6 +262,10 @@ class EmbeddedServerManager: # Compared against the installed distribution after start to detect a # stale-code worker (see _purge_ha_mcp_modules). self._running_version: str | None = None + # Startup phase marker (plain attribute writes: init markers from the + # main thread, _note_startup_phase transitions from the worker). Read + # by the readiness poll for progress detection and error messages. + self._startup_phase: str = "not started" @property def port(self) -> int: @@ -233,45 +310,39 @@ class EmbeddedServerManager: kind="package", ) - await self._async_ensure_package() + # Read the importer registry BEFORE the package step too: replacing + # the distribution's files on disk under a live importer corrupts it + # exactly like the sys.modules purge does (review finding), so a + # mutating install is deferred while any registered worker is alive. + ready_version = await self._async_ensure_package( + defer_mutations=_prune_and_check_importing_workers() + ) access_token = await self._async_provision_token() await self._hass.async_add_executor_job(self._prepare_config_dir) - # Drop cached ha_mcp modules so the worker imports the code that is on - # disk NOW. Without this, a reload after a pip install keeps serving - # the OLD code forever: all workers are threads of the one HA core - # process, and Python resolves ``import ha_mcp`` from sys.modules — - # installs only took effect after a full HA core restart (issue - # observed live: options saves reinstalled the package, the web UI - # footer showed the new on-disk version, yet the serving worker kept - # reporting the version it was first imported with). - # - # SKIPPED while an orphaned worker may still be importing: ripping - # entries out of sys.modules under a live importer corrupts its - # import in progress (seen on QEMU-slow HAOS, where a cold import - # can outlive both the readiness timeout and the stop-join budget). - # The post-start staleness check below surfaces the consequence - # (old code possibly serving) instead. - orphan = self._orphaned_thread - if orphan is not None and not orphan.is_alive(): - self._orphaned_thread = orphan = None - if orphan is None: - _purge_ha_mcp_modules() - else: - _LOGGER.warning( - "Skipping the ha_mcp module purge: a previous worker thread " - "is still shutting down. The new worker may serve the " - "previously imported code until Home Assistant restarts." - ) + self._maybe_purge_stale_modules(ready_version) self._thread_exc = None + self._startup_phase = "waiting for the worker thread" self._thread = threading.Thread( target=self._thread_main, args=(access_token,), name="ha-mcp-server", daemon=True, ) - self._thread.start() + # Registered by THIS (main) thread before start(): every bring-up + # runs its gate → register → start section synchronously on the one + # event loop, so another bring-up's purge gate can never interleave + # with a worker that exists but has not yet registered itself + # (review finding). The worker itself only ever deregisters. + with _IMPORTING_WORKERS_LOCK: + _IMPORTING_WORKERS.add(self._thread) + try: + self._thread.start() + except BaseException: + with _IMPORTING_WORKERS_LOCK: + _IMPORTING_WORKERS.discard(self._thread) + raise await self._async_wait_until_ready() @@ -402,9 +473,171 @@ class EmbeddedServerManager: return None return DIST_NAME_STABLE if self._channel == CHANNEL_DEV else DIST_NAME_DEV - async def _async_ensure_package(self) -> None: + def _maybe_purge_stale_modules(self, ready_version: str | None) -> None: + """Purge cached ha_mcp modules unless doing so is unsafe or pointless. + + The purge makes the next worker import the code that is on disk NOW. + Without it, a reload after a pip install keeps serving the OLD code + forever: all workers are threads of the one HA core process, and + Python resolves ``import ha_mcp`` from sys.modules — installs only + took effect after a full HA core restart (observed live: options + saves reinstalled the package, the web UI footer showed the new + on-disk version, yet the serving worker kept reporting the version + it was first imported with). + + SKIPPED while any previous worker may still be importing — ripping + entries out of sys.modules under a live importer corrupts its import + in progress. Two guards cover that: the per-manager orphan (a worker + this manager's stop could not join), and the process-global + ``_IMPORTING_WORKERS`` registry, because every bring-up constructs a + FRESH manager (async_bring_up_server) and an entry reload during a + slow cold import otherwise hands the purge to a manager that has + never heard of the still-importing worker — which then crashed + mid-import with KeyError: 'ha_mcp.config' (issue #1904, live on + 1.1.1-dev.107). The post-start staleness check surfaces the + consequence (old code possibly serving) instead. + + Also skipped when the cached modules already ARE the generation on + disk: purging on every attempt made each retry pay the full cold + import again, so slow hardware that missed the readiness window once + could never recover (#1904). Never skipped under a pip-spec override + — the one workflow where a reinstall can change the code without + changing the version string (re-pointed tarball/pin), which a + version-keyed skip would serve stale; channel installs mint a + distinct version per build. + """ + orphan = self._orphaned_thread + if orphan is not None and not orphan.is_alive(): + self._orphaned_thread = orphan = None + importing_busy = _prune_and_check_importing_workers() + if orphan is not None: + _LOGGER.warning( + "Skipping the ha_mcp module purge: a previous worker thread " + "is still shutting down. The new worker may serve the " + "previously imported code until Home Assistant restarts." + ) + elif importing_busy: + # Distinct from the orphan message: that worker is still STARTING + # (mid cold-import, the #1904 incident shape), not shutting down — + # naming the actual state matters when reading logs during one. + _LOGGER.warning( + "Skipping the ha_mcp module purge: a previous bring-up's " + "worker thread is still importing. The new worker may serve " + "the previously imported code until Home Assistant restarts." + ) + elif ( + not self._pip_spec_override + and ready_version is not None + and _CACHED_IMPORT_VERSION is not None + and ready_version == _CACHED_IMPORT_VERSION + ): + _LOGGER.debug( + "Cached ha_mcp modules already match installed version %s; " + "skipping the module purge (warm start)", + ready_version, + ) + else: + _purge_ha_mcp_modules() + + async def _async_run_tracked_install_job( + self, func: Callable[[], object] + ) -> object: + """Run a package-mutating executor job, tracked process-wide. + + Registration happens BEFORE dispatch and completion is signalled on + the executor thread in a finally, so a bring-up cancelled mid-job + (install or uninstall) leaves behind a waitable job instead of an + invisible one. + """ + global _PENDING_INSTALL_DONE + done = threading.Event() + with _PENDING_INSTALL_LOCK: + _PENDING_INSTALL_DONE = done + + def _run() -> object: + global _PENDING_INSTALL_DONE + try: + return func() + finally: + done.set() + with _PENDING_INSTALL_LOCK: + # A newer job may already have replaced the slot. + if _PENDING_INSTALL_DONE is done: + _PENDING_INSTALL_DONE = None + + try: + # Shielded: cancelling the awaiter must NOT cancel the executor + # job. Unshielded, a cancel landing while the job is still + # QUEUED removes it from the pool — _run never starts, nothing + # ever sets the event, and the next bring-up waits the full + # budget on a job that does not exist (review finding). With the + # shield, _run always runs and its finally always fires; the + # awaiter still detaches immediately on cancel. + return await asyncio.shield(self._hass.async_add_executor_job(_run)) + except asyncio.CancelledError: + # The job is queued or running and its finally will clear the + # slot; leaving it registered is the whole point — the next + # bring-up must wait it out. + raise + except BaseException: + # A DISPATCH failure (e.g. executor already shut down) means + # _run never ran and nothing will ever set the event — clear our + # own registration or the next bring-up waits the full budget on + # a job that does not exist. The identity check makes this a + # no-op if _run did run and already cleaned up. + with _PENDING_INSTALL_LOCK: + if _PENDING_INSTALL_DONE is done: + _PENDING_INSTALL_DONE = None + raise + + async def _async_wait_for_pending_install(self) -> None: + """Wait out an install job orphaned by a cancelled previous bring-up. + + Raises :class:`EmbeddedServerError` if it is still running after the + bounded wait — mutating the package (or importing from it) underneath + a live pip job is the same corruption class as purging sys.modules + under a live importer. + + The wait occupies one pooled executor thread (bounded): the setter + runs on an executor thread with no handle to this loop, so a + loop-side wakeup would need cross-thread plumbing this rare recovery + path does not justify. + """ + with _PENDING_INSTALL_LOCK: + pending = _PENDING_INSTALL_DONE + if pending is None or pending.is_set(): + return + _LOGGER.warning( + "A previous bring-up's install job is still running on the " + "executor (the bring-up was cancelled but pip cannot be); " + "waiting for it to finish before touching the ha-mcp package." + ) + finished = await self._hass.async_add_executor_job( + pending.wait, _PENDING_INSTALL_WAIT_SECONDS + ) + if not finished: + raise EmbeddedServerError( + f"A previous install job was still running after " + f"{_PENDING_INSTALL_WAIT_SECONDS:.0f}s; refusing to modify " + "the ha-mcp package underneath it. Reload the integration " + "to retry.", + kind="package", + ) + + async def _async_ensure_package( + self, *, defer_mutations: bool = False + ) -> str | None: """Ensure ``ha-mcp`` is importable, installing the pip spec if needed. + Returns the installed version that the worker is about to run, for the + caller's warm-cache purge decision. + + ``defer_mutations=True`` (a previous bring-up's worker is still + importing) downgrades any would-be uninstall/force-install to the + non-mutating fast path: replacing the distribution's files on disk + under a live importer corrupts it the same way a sys.modules purge + does. The deferred update applies on the next reload or HA restart. + With auto-update on (the default) both channels install their distribution UNPINNED, so every entry reload / HA restart must pick up the newest build. Such a spec ALWAYS takes the force-install path @@ -419,9 +652,12 @@ class EmbeddedServerManager: When that spec matches the one last installed and the package imports, delegate the "already satisfied?" decision to Home Assistant's requirements manager; a pinned spec does not move, so there is nothing to - upgrade to. A CHANGED spec (a new override, a toggled auto-update, a - channel switch) still falls through to the force-install path below so - the change actually takes effect. + upgrade to. A CHANGED spec (a new override, a cleared override, a + toggled auto-update, a channel switch) falls through to the + force-install path below — and additionally uninstalls the replaced + distribution first (:meth:`_async_remove_replaced_source`), because + ``upgrade=True`` alone decides by version and a changed SOURCE can keep + the version string (issue #1914). On a channel switch the other channel's distribution is uninstalled first (:meth:`_async_remove_conflicting_dist`): ``ha-mcp`` and ``ha-mcp-dev`` @@ -437,14 +673,14 @@ class EmbeddedServerManager: Never imports ``ha_mcp`` in this (main) process — that happens only inside the worker thread. """ + await self._async_wait_for_pending_install() + stored_spec = self._entry.data.get(DATA_LAST_PIP_SPEC) installed_version = await self._hass.async_add_executor_job( _installed_ha_mcp_version ) - pending_version = str( - self._entry.data.get(DATA_PENDING_INSTALL_VERSION) or "" - ).strip() + pending_version = self._pending_install_version(defer_mutations) target_dist = dist_for_channel(self._channel) if not self._pip_spec_override and pending_version: # Pin to the requested version. Its own value differs from @@ -498,13 +734,19 @@ class EmbeddedServerManager: and installed_version is not None and _is_compatible_embedded_version(installed_version) ) + deferred = False if fast_path_ok: await self._async_process_requirements_fast() + elif defer_mutations: + deferred = True + await self._async_defer_package_mutations(installed_version) else: await self._async_remove_conflicting_dist() await self._async_remove_legacy_target(target_dist, installed_version) + await self._async_remove_replaced_source(stored_spec, installed_version) await self._async_force_install() + version: str | None if not self._pip_spec_override and self._channel == CHANNEL_DEV: version = await self._hass.async_add_executor_job( _installed_ha_mcp_version, target_dist @@ -527,8 +769,13 @@ class EmbeddedServerManager: kind="package", ) _LOGGER.info("HA-MCP in-process server package ready (version %s)", version) - if stored_spec != self._pip_spec: + # A DEFERRED spec change must not be recorded as installed: with the + # stored spec advanced, the next reload would see "unchanged", take the + # fast path (for a stable spec) and skip the replaced-source uninstall, + # so the deferred change would silently never apply. + if not deferred and stored_spec != self._pip_spec: self._store_installed_spec() + return version async def _async_remove_legacy_target( self, target_dist: str, installed_version: str | None @@ -555,6 +802,165 @@ class EmbeddedServerManager: ) await self._async_remove_distribution(target_dist) + def _pending_install_version(self, defer_mutations: bool) -> str: + """Return the update entity's pending-install version, or ``""``. + + Always empty while mutations are deferred, leaving the marker in + ``entry.data`` untouched: the deferred branch runs no install, and + consuming the marker without an attempt would lose the user's Install + click entirely (with auto-update off, the next reload re-pins to the + OLD installed version). One-shot means one real ATTEMPT — a deferred + bring-up never attempts, so the marker survives to the next + undeferred reload (review finding on #1923). + """ + if defer_mutations: + return "" + return str(self._entry.data.get(DATA_PENDING_INSTALL_VERSION) or "").strip() + + async def _async_defer_package_mutations( + self, installed_version: str | None + ) -> None: + """Handle the defer-mutations branch of :meth:`_async_ensure_package`. + + A previous bring-up's worker is still importing, so the package files + must not be replaced under it. With an importable build on disk + (``installed_version`` non-None — importability only, compatibility is + checked by the caller afterwards) nothing is touched at all — not even + the requirements manager, which installs any unsatisfied spec and + would mutate exactly like the deferred force install. When nothing + imports there are no distribution files to replace under the live + importer, and without an install this bring-up cannot produce a + server at all, so the requirements manager still runs. + """ + _LOGGER.warning( + "Deferring the ha-mcp install/upgrade: a previous bring-up's " + "worker thread is still importing, and replacing the package " + "files under it could corrupt that import. The currently " + "installed build will be used; reload the integration (or " + "restart Home Assistant) to apply the update." + ) + if installed_version is None: + await self._async_process_requirements_fast() + + def _replaced_dist_name(self) -> str | None: + """Return the distribution whose presence could no-op the new spec. + + This is the distribution the effective spec installs *by name* — the + channel's distribution for a channel spec, or the named distribution + of an override that parses as a requirement (a pin like + ``ha-mcp==X``, matched against the two known channel dists). It is + deliberately NOT the channel's dist for every override: a repo + tarball installs as ``ha-mcp`` regardless of the selected channel, so + keying on the channel would miss the dev-channel + override case. + + Returns None for an override that names an unknown distribution or + does not parse as a requirement at all (a direct URL): the installer + re-fetches and rebuilds URL requirements under ``upgrade=True`` + regardless of the installed version, so a URL install is already + real and nothing needs removing. + """ + if not self._pip_spec_override: + return dist_for_channel(self._channel) + try: + name = canonicalize_name(Requirement(self._pip_spec_override).name) + except InvalidRequirement: + return None + for dist_name in (DIST_NAME_STABLE, DIST_NAME_DEV): + if name == canonicalize_name(dist_name): + return dist_name + return None + + async def _async_remove_replaced_source( + self, stored_spec: str | None, installed_version: str | None + ) -> None: + """Uninstall the replaced distribution when the requested source changed. + + The forced install that follows relies on ``upgrade=True``, and the + installer decides "already satisfied" by VERSION alone — but a source + change can keep the version string. A PR branch's committed + ``project.version`` equals the release it branched from (only release + automation bumps it), so its tarball installs with that same version + string; clearing the override then resolves the channel spec to the + exact version already on disk and the install swaps nothing, leaving + the PR code running while the entry reports a clean channel install + (issue #1914). The same version-blindness bites a manual spec edit + that pins the version already installed. The installer cannot see the + difference, so when the spec that produced the current install + differs from the one about to be installed, the distribution the new + spec resolves to by name (:meth:`_replaced_dist_name`) is removed + first — the install that follows is then unconditionally real. + + Skipped when nothing is installed, when the last-installed spec is + unknown (nothing to compare: first install, or entry data predating + the spec tracking), when the spec is unchanged (the routine + reload/restart path, where ``upgrade=True`` alone is correct and an + uninstall would churn — and briefly break — a healthy install on + every restart), when the new spec is a direct URL (always installs + for real), when the named distribution is not installed (e.g. a + cross-channel switch already removed it), when the stored spec is + an index requirement on the SAME distribution (a repin — e.g. + toggling auto-update rewrites bare ``ha-mcp`` to ``ha-mcp==X`` — + draws from the same index either way, so version resolution is + faithful and uninstalling a healthy install on a preference toggle + would only add an offline-breakage window), or when the new spec is + an exact pin on a version provably different from the installed one + (the install cannot no-op, so the working build stays in place as + the fallback if it fails). + + Unlike the other pre-install uninstalls this one is NOT best-effort: + if the distribution survives a failed uninstall, the forced install + would no-op as "already satisfied", the new spec would be persisted, + and the next reload would see it as unchanged — reproducing #1914 and + then permanently masking it. Raising instead keeps the stored spec on + the old value, so the next reload retries the whole source change. + """ + if installed_version is None or stored_spec is None: + return + if stored_spec == self._pip_spec: + return + replaced_dist = self._replaced_dist_name() + if replaced_dist is None: + return + if _spec_is_index_requirement_on(stored_spec, replaced_dist): + # Same distribution, same index — only the pin changed. The old + # code on disk came from the index too, so "already satisfied by + # version" is the truth, not the #1914 lie. + return + pinned = _exact_pinned_version(self._pip_spec) + if pinned is not None: + try: + version_moves = Version(pinned) != Version(installed_version) + except InvalidVersion: + version_moves = False # unprovable — keep the uninstall + if version_moves: + # The new pin cannot be satisfied by the installed version, so + # the forced install is guaranteed to be real without any + # uninstall — and keeping the working build in place preserves + # it as the fallback if that install fails (e.g. offline). + return + if not await self._hass.async_add_executor_job(_dist_installed, replaced_dist): + return + _LOGGER.info( + "The requested server source changed (%r -> %r); removing the " + "installed %r first so the reinstall cannot be skipped as " + "already satisfied", + stored_spec, + self._pip_spec, + replaced_dist, + ) + removed = await self._async_remove_distribution(replaced_dist) + if not removed and await self._hass.async_add_executor_job( + _dist_installed, replaced_dist + ): + raise EmbeddedServerError( + f"Could not remove the installed {replaced_dist!r} (from " + f"{stored_spec!r}) before installing {self._pip_spec!r}: the " + "installer would report the new source as already satisfied " + "and keep the old code running. Uninstall details are logged " + "above; reload the integration to retry the source change.", + kind="package", + ) + async def _async_process_requirements_fast(self) -> None: """Fast path: let HA's requirements manager satisfy the override spec.""" try: @@ -583,7 +989,7 @@ class EmbeddedServerManager: kwargs["timeout"] = max( int(kwargs.get("timeout") or 0), _PIP_INSTALL_TIMEOUT_SECONDS ) - installed = await self._hass.async_add_executor_job( + installed = await self._async_run_tracked_install_job( partial(install_package, self._pip_spec, upgrade=True, **kwargs) ) if not installed: @@ -624,15 +1030,25 @@ class EmbeddedServerManager: ) await self._async_remove_distribution(other) - async def _async_remove_distribution(self, dist_name: str) -> None: - """Remove a distribution from the same target used for installation.""" + async def _async_remove_distribution(self, dist_name: str) -> bool: + """Remove a distribution from the same target used for installation. + + Tracked like the install: an uninstall mutates the same package files. + Returns whether the uninstall subprocess reported success; callers + decide whether a failure is best-effort (channel-conflict / legacy + cleanup) or fatal (the replaced-source removal, whose failure would + silently void the reinstall — see ``_async_remove_replaced_source``). + """ target = pip_kwargs(self._hass.config.config_dir).get("target") if target is None: - await self._hass.async_add_executor_job(_uninstall_distribution, dist_name) + result = await self._async_run_tracked_install_job( + partial(_uninstall_distribution, dist_name) + ) else: - await self._hass.async_add_executor_job( + result = await self._async_run_tracked_install_job( partial(_uninstall_distribution, dist_name, target=target) ) + return bool(result) def _store_installed_spec(self) -> None: """Persist the pip spec just installed so a restart skips the reinstall.""" @@ -724,6 +1140,15 @@ class EmbeddedServerManager: # -- worker thread ----------------------------------------------------- + def _note_startup_phase(self, phase: str) -> None: + """Publish the worker's startup phase (a plain attribute write). + + Read by the readiness poll: a phase advance counts as progress, and the + failure message names the phase the worker was last seen in. + """ + self._startup_phase = phase + _LOGGER.debug("HA-MCP in-process server startup: %s", phase) + def _thread_main(self, access_token: str) -> None: """Thread entry point: stage non-secret env, then run the server. @@ -744,12 +1169,38 @@ class EmbeddedServerManager: stop_event = asyncio.Event() self._loop = loop self._stop_event = stop_event + # Registration in _IMPORTING_WORKERS happened on the MAIN thread, + # before start() — see async_start. This thread only deregisters: + # in _serve once the import section completes, and in the finally + # below on exit as the backstop. try: loop.run_until_complete(self._serve(access_token, stop_event)) + except SystemExit as err: + # uvicorn signals a startup failure (e.g. the port is already in + # use) with SystemExit(STARTUP_FAILURE), which ``except Exception`` + # misses — live issue #1904 saw the real bind error surface only + # in HA's generic task-exception log while the component reported + # a bare readiness timeout. Unwrap the original error so the + # repair issue names the actual cause; a bare SystemExit (no + # chained exception) is reported by repr so an empty/zero exit + # code still reads as what it is. The phase names where in + # _serve the exit happened instead of hardcoding a bind failure. + cause = err.__context__ or err.__cause__ + detail = str(cause) if cause is not None else repr(err) + self._thread_exc = EmbeddedServerError( + f"the server exited during startup ({self._startup_phase}): {detail}" + ) + _LOGGER.error( + "HA-MCP in-process server exited during startup (%s): %s", + self._startup_phase, + detail, + ) except Exception as err: self._thread_exc = err _LOGGER.exception("HA-MCP in-process server thread crashed") finally: + with _IMPORTING_WORKERS_LOCK: + _IMPORTING_WORKERS.discard(threading.current_thread()) # Teardown is best-effort but never SILENT (review finding): a # raise here must not mask the primary outcome, yet a recurring # cleanup failure (leaking executor threads across reloads) has @@ -779,6 +1230,7 @@ class EmbeddedServerManager: # Hand ha-mcp the loopback URL + provisioned admin token in memory, before # the server (and its settings singleton) is built. Keeping the token out # of os.environ is the whole point of the in-process channel. + self._note_startup_phase("importing the server package") import ha_mcp.config as _hamcp_config # Record which code generation this worker imported. Prefer the @@ -786,6 +1238,11 @@ class EmbeddedServerManager: # ha_mcp.__version__ itself checks stable first and stale stable # metadata can otherwise make a fresh dev worker look outdated. self._running_version = _running_ha_mcp_version(self._channel) + # The cache in sys.modules now holds this generation — remembered + # process-wide so the next start can skip the purge when the install + # has not changed (issue #1904). + global _CACHED_IMPORT_VERSION + _CACHED_IMPORT_VERSION = self._running_version # Drop any settings singleton cached by a PREVIOUS start in this same # Python process: an entry reload must re-read the override files @@ -805,12 +1262,33 @@ class EmbeddedServerManager: "entry may serve stale override values until HA restarts" ) - _hamcp_config.set_embedded_connection(self._server_url, access_token) + if self._loopback_verify_ssl is None: + _hamcp_config.set_embedded_connection(self._server_url, access_token) + else: + try: + _hamcp_config.set_embedded_connection( + self._server_url, + access_token, + verify_ssl=self._loopback_verify_ssl, + ) + except TypeError: + # Server predates the verify_ssl parameter (< the release + # carrying issue #1890's fix). Register url+token the old way; + # on an SSL-enabled instance the wss loopback will fail + # certificate verification until the server package updates — + # no worse than the plaintext failure it replaces. + _LOGGER.warning( + "Installed ha-mcp server does not accept verify_ssl for " + "the embedded connection; loopback TLS verification stays " + "enabled until the server package updates" + ) + _hamcp_config.set_embedded_connection(self._server_url, access_token) # Imported here, in the worker thread, after the connection is registered. from ha_mcp.server import HomeAssistantSmartMCPServer from ha_mcp.settings_ui import register_settings_routes + self._note_startup_phase("building the server") server = HomeAssistantSmartMCPServer() # Startup observability (no secrets): confirm the in-memory connection @@ -849,6 +1327,7 @@ class EmbeddedServerManager: # Parity with the CLI HTTP runner: serve the web settings UI under the # same secret path as the MCP endpoint. + self._note_startup_phase("registering web routes") register_settings_routes(server.mcp, server, secret_path=self._secret_path) # Parity with the CLI HTTP runner: answer a browser GET on the MCP path @@ -891,6 +1370,16 @@ class EmbeddedServerManager: else: ensure_host_origin_guard_default_off() + # The cold import — the multi-minute window that crashed in #1904 — + # is complete: every explicit ha_mcp import in _serve precedes this + # line. Leave the registry so a long-running healthy server never + # blocks later bring-ups' purges (removal from sys.modules cannot + # unload already-bound modules; only in-flight imports are + # corruptible, and any later lazy import is outside the window this + # registry protects). + with _IMPORTING_WORKERS_LOCK: + _IMPORTING_WORKERS.discard(threading.current_thread()) + app = server.mcp.http_app(path=self._secret_path, stateless_http=True) config = uvicorn.Config( app, @@ -905,6 +1394,7 @@ class EmbeddedServerManager: ) uv_server = uvicorn.Server(config) + self._note_startup_phase("starting the HTTP listener") stop_task = asyncio.create_task(stop_event.wait()) async with server.mcp._lifespan_manager(): serve_task = asyncio.create_task(uv_server.serve()) @@ -924,15 +1414,37 @@ class EmbeddedServerManager: # Surface a server that exited on its own (bind failure, etc.). serve_task.result() + def _progress_signature(self) -> tuple[int, str]: + """Snapshot the observable startup progress of the worker thread. + + ``len(sys.modules)`` moves continuously while the worker grinds + through a cold import (the single longest startup step — minutes on + slow hardware, issue #1904), and the published phase moves between + steps. Any change in the pair counts as progress. The module count is + PROCESS-wide — an approximation: nothing finer-grained is observable + from outside a thread stuck inside one ``import`` statement, and any + other HA thread importing concurrently also refreshes the stall + budget. Erring toward patience is the point; the absolute cap bounds + the wait regardless. + """ + return (len(sys.modules), self._startup_phase) + async def _async_wait_until_ready(self) -> None: """Poll a loopback TCP connect until the server accepts, or fail. - On failure (timeout or an early thread crash) stops the thread and raises + Patience is progress-based: the wait only gives up when there is no + observable progress for ``_READY_STALL_TIMEOUT_SECONDS`` (or the + absolute ``_READY_TOTAL_CAP_SECONDS`` ceiling is hit). A slow cold + import keeps the wait alive; a wedged worker is caught by the stall + budget on a quiet instance, by the cap at the latest (the progress + signal is process-wide). On failure stops the thread and raises :class:`EmbeddedServerError` so the caller leaves the webhook unregistered and files a repair issue. """ - deadline = self._hass.loop.time() + _READY_TIMEOUT_SECONDS - while self._hass.loop.time() < deadline: + start = self._hass.loop.time() + last_progress = start + last_signature = self._progress_signature() + while True: if self._thread_exc is not None: raise EmbeddedServerError( f"HA-MCP in-process server failed to start: {self._thread_exc}" @@ -948,15 +1460,32 @@ class EmbeddedServerManager: self._port, ) return + now = self._hass.loop.time() + signature = self._progress_signature() + if signature != last_signature: + last_signature = signature + last_progress = now + if now - start >= _READY_TOTAL_CAP_SECONDS: + failure = ( + f"HA-MCP in-process server did not become reachable on " + f"port {self._port} within {_READY_TOTAL_CAP_SECONDS:.0f}s " + f"(last startup phase: {self._startup_phase})." + ) + break + if now - last_progress >= _READY_STALL_TIMEOUT_SECONDS: + failure = ( + f"HA-MCP in-process server did not become reachable on " + f"port {self._port}: no startup progress observed for " + f"{_READY_STALL_TIMEOUT_SECONDS:.0f}s (last phase: " + f"{self._startup_phase}; {now - start:.0f}s since start)." + ) + break await asyncio.sleep(_READY_POLL_INTERVAL_SECONDS) - # Timed out — tear the thread down so we never leave a half-started + # Gave up — tear the thread down so we never leave a half-started # server behind an unregistered webhook. await self.async_stop() - raise EmbeddedServerError( - f"HA-MCP in-process server did not become reachable on port " - f"{self._port} within {_READY_TIMEOUT_SECONDS:.0f}s." - ) + raise EmbeddedServerError(failure) async def _async_probe_port(self) -> bool: """Return True if a loopback TCP connection to the server port succeeds. @@ -977,6 +1506,68 @@ class EmbeddedServerManager: return True +# Worker threads that may still be executing their ha_mcp imports. Purging +# sys.modules while any of them is alive corrupts the in-flight import +# (KeyError from frozen importlib — issue #1904). Process-global because a +# manager is recreated on every bring-up, so per-manager orphan tracking +# cannot see a previous manager's abandoned worker. The spawning (main) +# thread registers the worker BEFORE start() — see async_start — and the +# worker deregisters once its import section completes (or on thread exit, +# whichever comes first). All access +# goes through the lock: CPython's GIL would make the individual set ops +# atomic, but the purge gate's prune-then-check is a composite read and the +# lock keeps its correctness independent of GIL scheduling arguments. +# Deliberate tradeoff: a worker wedged forever inside its import stays +# registered and blocks every later purge until an HA core restart — evicting +# a live importer on a timer would reintroduce the very corruption this +# registry prevents, and the skip warning plus the post-start staleness check +# surface the condition. +_IMPORTING_WORKERS_LOCK = threading.Lock() +_IMPORTING_WORKERS: set[threading.Thread] = set() + + +def _prune_and_check_importing_workers() -> bool: + """Drop dead workers from the registry; return True if any live one remains.""" + with _IMPORTING_WORKERS_LOCK: + _IMPORTING_WORKERS.difference_update( + [t for t in _IMPORTING_WORKERS if not t.is_alive()] + ) + return bool(_IMPORTING_WORKERS) + + +# Completion event of the package-mutating install/uninstall job currently on +# the executor, if any. asyncio cancellation of a bring-up detaches the +# awaiter, but the executor job keeps running to completion — untracked, an +# orphaned pip could swap the distribution's files under the NEXT bring-up's +# install or its worker's cold import (found in review of PR #1911, the +# #1904 fixes; pre-existing). The dispatching coroutine registers the event BEFORE handing +# the job to the executor, and the executor fn sets it in a finally that +# survives cancellation; the next bring-up waits on it before mutating +# anything. Process-global for the same reason as _IMPORTING_WORKERS: a +# manager is recreated on every bring-up. +# +# Single slot BY DESIGN: at most one tracked job can exist at a time — the +# server entry is single-instance, an entry reload cancels-and-awaits the +# previous bring-up before setting up, and every dispatch site sits behind +# _async_wait_for_pending_install. A second concurrent dispatcher would +# overwrite the slot and silently lose the older live job — keep any new +# package-mutating call site behind the wait gate. The slot tracking wraps +# the DIRECT-pip sites (force install, uninstalls); the fast path goes +# through HA's requirements manager, which is behind the gate but untracked — +# it only dispatches pip when the package is missing outright, which cannot +# co-occur with a live orphaned job worth waiting on. +_PENDING_INSTALL_LOCK = threading.Lock() +_PENDING_INSTALL_DONE: threading.Event | None = None + + +# Version of the ha_mcp generation currently cached in sys.modules — set by +# the worker right after its first import lands, cleared by the purge. Process-wide +# (the module cache it describes is process-wide too). Lets a retry with an +# unchanged install keep the warm cache instead of paying the full cold import +# again (issue #1904). +_CACHED_IMPORT_VERSION: str | None = None + + def _purge_ha_mcp_modules() -> None: """Drop every cached ``ha_mcp`` module so the next import loads fresh code. @@ -984,11 +1575,15 @@ def _purge_ha_mcp_modules() -> None: Python resolves imports from the process-wide ``sys.modules`` cache — so after a pip install the next worker would silently reuse the OLD code unless the cache is purged first. Safe here because ``ha_mcp`` is pure - Python and is only ever imported inside the (currently stopped) worker - thread; third-party dependencies are deliberately NOT purged (they are - shared with the rest of Home Assistant), so a dependency-version change - still needs an HA core restart. + Python and is only ever imported inside worker threads, and the caller's + gate guarantees no registered worker is mid-import when this runs (a + worker past its imports keeps its already-bound modules regardless); + third-party dependencies are deliberately NOT purged (they are shared + with the rest of Home Assistant), so a dependency-version change still + needs an HA core restart. """ + global _CACHED_IMPORT_VERSION + _CACHED_IMPORT_VERSION = None # Snapshot the keys: sys.modules can be mutated by concurrent imports on # other threads mid-iteration (HA core is heavily threaded). purged = [ @@ -1120,6 +1715,46 @@ def _uninstall_distribution(dist_name: str, *, target: str | None = None) -> boo return True +def _exact_pinned_version(spec: str) -> str | None: + """Return the version of an exact ``==``/``===`` single-clause pin, or None. + + Anything else — URL specs, bare names, ranges, multi-clause specifiers — + returns None: only an exact pin lets the caller prove, without asking the + resolver, whether the installed version could satisfy the spec. A + wildcard pin (``==7.13.*``) is returned as-is; the caller's ``Version`` + parse rejects it, which conservatively keeps the uninstall. + """ + try: + req = Requirement(spec) + except InvalidRequirement: + return None + if req.url: + return None + clauses = list(req.specifier) + if len(clauses) != 1 or clauses[0].operator not in ("==", "==="): + return None + return clauses[0].version + + +def _spec_is_index_requirement_on(spec: str, dist_name: str) -> bool: + """Return whether ``spec`` is a plain index requirement on ``dist_name``. + + True only for a PEP 508 requirement with no direct-URL part whose + canonical name matches — i.e. a spec that installs ``dist_name`` from the + package index (bare name or version pin). A direct URL (whether a plain + URL string, which does not parse as a requirement, or a ``name @ url`` + form) returns False: its origin is not the index, so it is a genuine + source change for the replaced-source check. + """ + try: + req = Requirement(spec) + except InvalidRequirement: + return False + if req.url: + return False + return canonicalize_name(req.name) == canonicalize_name(dist_name) + + def _is_compatible_embedded_version(version: str) -> bool: """Return whether a server distribution provides the embedded API.""" try: diff --git a/custom_components/ha_mcp_tools/embedded_setup.py b/custom_components/ha_mcp_tools/embedded_setup.py index a7ef501..987288a 100644 --- a/custom_components/ha_mcp_tools/embedded_setup.py +++ b/custom_components/ha_mcp_tools/embedded_setup.py @@ -33,6 +33,9 @@ from .const import ( COMPONENT_MANIFEST_AT_TAG_URL, DATA_BRINGUP_TASK, DATA_MANAGER, + DATA_OAUTH_CLIENT_ID, + DATA_OAUTH_CLIENT_SECRET, + DATA_OAUTH_SIGNING_KEY, DATA_PENDING_UPDATE_NOTIFY, DATA_SECRET_PATH, DATA_UPDATE_COORDINATOR, @@ -43,8 +46,8 @@ from .const import ( DEFAULT_PIP_SPEC, DEFAULT_SERVER_PORT, DOMAIN, - HACS_COMPONENT_URL, ISSUE_COMPONENT_OUTDATED, + ISSUE_LEGACY_OAUTH_RESTART, ISSUE_PACKAGE_FAILED, ISSUE_START_FAILED, ISSUE_UPDATE_HELD, @@ -58,12 +61,16 @@ from .const import ( OPT_PIP_SPEC, OPT_SERVER_PORT, OPT_WEBHOOK_AUTH, + UPDATE_HOLD_DOCS_URL, + WEBHOOK_AUTH_LEGACY, WEBHOOK_AUTH_NONE, channel_for_dist, ) from .embedded_server import EmbeddedServerError, EmbeddedServerManager +from .hacs_nudge import async_schedule_hacs_nudge from .llm_api import async_register_llm_api, async_unregister_llm_api from .mcp_webhook import async_register_webhook, async_unregister_webhook +from .oauth_legacy import legacy_credentials_active if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -111,23 +118,51 @@ async def async_bring_up_server(hass: HomeAssistant, entry: ConfigEntry) -> None auth_mode = str(entry.options.get(OPT_WEBHOOK_AUTH, WEBHOOK_AUTH_NONE)) secret_path = str(entry.data[DATA_SECRET_PATH]) webhook_enabled = bool(entry.options.get(OPT_ENABLE_WEBHOOK, True)) + oauth_client_id = entry.data.get(DATA_OAUTH_CLIENT_ID) + oauth_client_secret = entry.data.get(DATA_OAUTH_CLIENT_SECRET) # Always set up the loopback forwarding config — the sidebar settings # panel proxies through it (#1803); the option gates only the public - # webhook endpoint. - await async_register_webhook( + # webhook endpoint. oauth_* args are ignored unless auth_mode is legacy. + oauth_restart_needed = await async_register_webhook( hass, entry, port=manager.port, secret_path=secret_path, auth_mode=auth_mode, register_endpoint=webhook_enabled, + oauth_client_id=oauth_client_id, + oauth_client_secret=oauth_client_secret, + oauth_signing_key=entry.data.get(DATA_OAUTH_SIGNING_KEY), ) + _async_update_legacy_oauth_issue(hass, oauth_restart_needed) if not webhook_enabled: _LOGGER.info( "Webhook access disabled by option - the server is local-only " "(direct port + sidebar panel)" ) - _surface_connect_urls(hass, entry, auth_mode, webhook_enabled=webhook_enabled) + # Only surface cleartext credentials once the bound provider actually + # serves them: while a rotation is pending restart, an old-identity + # token still validates and can read this log (see + # legacy_credentials_active). + oauth_creds_active = True + if webhook_enabled and auth_mode == WEBHOOK_AUTH_LEGACY: + oauth_creds_active = legacy_credentials_active( + hass, + str(oauth_client_id or ""), + str(oauth_client_secret or ""), + str(entry.data.get(DATA_OAUTH_SIGNING_KEY) or ""), + ) + _surface_connect_urls( + hass, + entry, + auth_mode, + webhook_enabled=webhook_enabled, + extra_hosts=await async_get_lan_hosts(hass), + oauth_client_id=oauth_client_id, + oauth_client_secret=oauth_client_secret, + oauth_creds_active=oauth_creds_active, + oauth_restart_pending=oauth_restart_needed, + ) # Conversation-agent LLM API (#1745), gated on its option (default on). # Advisory: registration failures are contained inside (logged, feature # absent) — the running server must never be taken down by them. @@ -189,6 +224,87 @@ async def async_revoke_credentials_on_remove( await EmbeddedServerManager(hass, entry).async_revoke_credentials() _clear_issues(hass) ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED) + # Clear the legacy-OAuth restart repair too: it is filed only from bring-up, + # which never runs again for a removed entry, so a restart that was still + # pending at removal would otherwise leave a dangling warning for a server + # that no longer exists. (Re-enabling legacy on a fresh entry re-files it.) + ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_OAUTH_RESTART) + + +async def async_get_lan_hosts(hass: HomeAssistant) -> list[str]: + """Every IPv4 address on an enabled network adapter, in adapter order (#1862). + + Lets the connect-URL surfaces list one entry per interface on a + multi-interface / multi-VLAN host, instead of only the single address + ``get_url`` resolves. IPv4 only: ``async_get_adapters`` returns a bare + IPv6 ``address`` with a separate ``scope_id`` int, so a link-local adapter + address is not a usable URL host without rejoining that zone id (and every + IPv6 host additionally needs bracket-wrapping), so building those correctly + is out of scope; the reported setups are IPv4. Best-effort: any failure + yields an empty list so URL + surfacing degrades to the single ``get_url`` host rather than taking down + the caller (bring-up would otherwise file a repair issue for a display-only + lookup). + """ + try: + from homeassistant.components import network + + adapters = await network.async_get_adapters(hass) + # The whole extraction is inside the try (not just the fetch): a + # malformed adapter entry must degrade to the single get_url host too, + # never escape into async_bring_up_server's handler, which would tear + # the running server down and file a start-failure for a display-only + # lookup. + return [ + ipv4["address"] + for adapter in adapters + if adapter["enabled"] + for ipv4 in adapter["ipv4"] + ] + except Exception: # display-only enumeration; must never fail the caller + _LOGGER.warning( + "Adapter enumeration failed; using the single resolved host", + exc_info=True, + ) + return [] + + +def _swap_url_host(base: str, host: str) -> str: + """Return ``base`` with its host replaced by ``host`` (scheme/port/path kept).""" + parsed = urlparse(base) + netloc = host if parsed.port is None else f"{host}:{parsed.port}" + return parsed._replace(netloc=netloc).geturl() + + +def _dedup_hosts(primary: str | None, extra: list[str] | None) -> list[str]: + """Ordered unique hosts, ``primary`` (the canonical get_url host) first.""" + ordered: list[str] = [] + for host in (primary, *(extra or [])): + if host and host not in ordered: + ordered.append(host) + return ordered + + +def _resolve_local_url(hass: HomeAssistant) -> tuple[str | None, str | None]: + """The get_url internal base URL and its host, or ``(None, None)``.""" + from homeassistant.helpers.network import NoURLAvailableError, get_url + + try: + base = get_url(hass, allow_external=False, prefer_external=False) + except NoURLAvailableError: + return None, None # No internal/local URL configured - hint form instead. + return base, urlparse(base).hostname + + +def _local_webhook_urls( + local_base: str, local_host: str | None, lan_hosts: list[str], webhook_id: str +) -> list[str]: + """One local webhook URL per LAN host (canonical ``local_base`` verbatim).""" + return [ + f"{local_base if host == local_host else _swap_url_host(local_base, host)}" + f"/api/webhook/{webhook_id}" + for host in lan_hosts + ] def build_connect_urls( @@ -196,6 +312,7 @@ def build_connect_urls( entry: ConfigEntry, *, webhook_enabled: bool = True, + extra_hosts: list[str] | None = None, ) -> list[str]: """Resolve the entry's connect URLs (webhook forms first, then direct). @@ -203,9 +320,13 @@ def build_connect_urls( log on start-up and the entry's Configure screen (the notification deliberately carries none - it is visible to every signed-in user). Each source is best-effort: a URL that cannot be resolved is omitted. - """ - from homeassistant.helpers.network import NoURLAvailableError, get_url + ``extra_hosts`` (from :func:`async_get_lan_hosts`) adds one webhook and one + direct-access URL per additional LAN address, so a multi-interface / + multi-VLAN host surfaces every reachable interface rather than only the + single host ``get_url`` picks (#1862). The ``get_url`` host stays canonical + and first; any repeat of it in ``extra_hosts`` is deduped away. + """ webhook_id = entry.data.get(DATA_WEBHOOK_ID) urls: list[str] = [] external = str(entry.options.get(OPT_EXTERNAL_URL) or "").rstrip("/") @@ -234,14 +355,15 @@ def build_connect_urls( except ImportError: pass # Cloud integration not installed (e.g. HA Core) - local URL only. - local_host: str | None = None - try: - local_base = get_url(hass, allow_external=False, prefer_external=False) - local_host = urlparse(local_base).hostname - if webhook_id: - urls.append(f"{local_base}/api/webhook/{webhook_id}") - except NoURLAvailableError: - pass # No internal/local URL configured - fall through to the hint form. + local_base, local_host = _resolve_local_url(hass) + + # One entry per enabled LAN address so a multi-interface / multi-VLAN host + # surfaces every reachable interface rather than get_url's single pick + # (#1862). The get_url host stays canonical and first. + lan_hosts = _dedup_hosts(local_host, extra_hosts) + + if local_base and webhook_id: + urls.extend(_local_webhook_urls(local_base, local_host, lan_hosts, webhook_id)) if not urls and webhook_id: urls.append(f"/api/webhook/{webhook_id} (prefix with your Home Assistant URL)") @@ -253,9 +375,9 @@ def build_connect_urls( # Direct-access URL: admin-gated surfaces only (log + Configure screen). # Guarded on the secret path so a missing one omits the line instead of # rendering a valid-looking URL without its credential segment. - urls.append( - f"http://{local_host or ''}:{port}{secret_path}" - " (direct access)" + urls.extend( + f"http://{host}:{port}{secret_path} (direct access)" + for host in lan_hosts or [""] ) return urls @@ -266,23 +388,83 @@ def _surface_connect_urls( auth_mode: str, *, webhook_enabled: bool = True, + extra_hosts: list[str] | None = None, + oauth_client_id: str | None = None, + oauth_client_secret: str | None = None, + oauth_creds_active: bool = True, + oauth_restart_pending: bool = False, ) -> None: """Log the connect URLs and (re)create a persistent notification.""" - urls = build_connect_urls(hass, entry, webhook_enabled=webhook_enabled) - auth_note = ( - "Webhook access is disabled (local-only mode)." - if not webhook_enabled - else "The webhook URL is the shared secret (no bearer required)." - if auth_mode == WEBHOOK_AUTH_NONE - else "Clients authenticate with your Home Assistant account (ha_auth)." + urls = build_connect_urls( + hass, entry, webhook_enabled=webhook_enabled, extra_hosts=extra_hosts ) + if not webhook_enabled: + auth_note = "Webhook access is disabled (local-only mode)." + elif auth_mode == WEBHOOK_AUTH_NONE: + auth_note = "The webhook URL is the shared secret (no bearer required)." + elif auth_mode == WEBHOOK_AUTH_LEGACY: + # Kept secret-free (unlike the log line below) — see the SECURITY note + # on the persistent notification further down, which reuses this text. + creds_where = ( + "the Home Assistant log or the entry's Configure screen" + if oauth_creds_active + else "the entry's Configure screen" + ) + auth_note = ( + "OAuth (Beta) is ENABLED for this URL (legacy mode) - see " + f"{creds_where} for the Client ID and Client Secret to paste " + "into your MCP client." + ) + else: + auth_note = "Clients authenticate with your Home Assistant account (ha_auth)." url_lines = "\n".join(f"- {url}" for url in urls) - _LOGGER.info( - "HA-MCP in-process server is running. Connect URL(s):\n%s\n%s", - url_lines, - auth_note, + log_message = ( + "HA-MCP in-process server is running. " + f"Connect URL(s):\n{url_lines}\n{auth_note}" ) + if webhook_enabled and auth_mode == WEBHOOK_AUTH_LEGACY: + if oauth_creds_active: + # Admin-only log (mirrors the webhook-proxy add-on's own startup + # log, start.py). Cleartext credentials — deliberately NOT in the + # persistent notification below, which every signed-in user can + # see. + log_message += ( + f"\n OAuth Client ID: {oauth_client_id}" + f"\n OAuth Client Secret: {oauth_client_secret}" + ) + if oauth_restart_pending: + # First-enable mid-session late-binds the root views, so + # /authorize is not live until the restart the repair asks + # for. The credentials ARE the ones that will be served + # (oauth_creds_active is True), but pasting them now gets a + # connection that fails until the restart — same caveat the + # rotation branch, the options hint, and the oauth_regenerate + # help text carry. + log_message += ( + "\n Legacy OAuth is not live until the restart Home " + "Assistant is asking for; these credentials work once " + "you restart." + ) + log_message += ( + "\n Paste both into your MCP client's OAuth connector setup " + "(e.g. Google Gemini Spark: Advanced settings)." + ) + else: + # SECURITY (review finding on #1880): while a credential rotation + # is pending the restart, the bound root views still serve the OLD + # identity, so a token issued under it stays valid — and could + # read this log through the server's own log tools. Logging the + # NEW credentials here would hand them to exactly the party the + # rotation is meant to evict, so they are withheld until the + # restart makes them active (which also kills every old token). + log_message += ( + "\n The OAuth credentials were rotated and take effect after " + "the restart Home Assistant is asking for; until then the " + "previous credentials remain active. The new Client ID and " + "Client Secret are on the entry's Configure screen." + ) + _LOGGER.info(log_message) if not bool(entry.options.get(OPT_ENABLE_STARTUP_NOTIFICATION, True)): # Notification suppressed by option: clear any notification created # before the toggle was turned off, then skip creating a fresh one. The @@ -322,6 +504,29 @@ def _surface_connect_urls( ) +def _async_update_legacy_oauth_issue(hass: HomeAssistant, restart_needed: bool) -> None: + """File/clear the legacy-OAuth restart repair per ``async_register_webhook``'s + return value. + + Raised on BOTH transitions (see that function's docstring): enabling + legacy mode (the root views just bound, or bound with different + credentials than before) and disabling it (the views are still bound from + a prior legacy registration). aiohttp can neither bind nor release an HTTP + view without a full Home Assistant restart either way. + """ + if restart_needed: + ir.async_create_issue( + hass, + DOMAIN, + ISSUE_LEGACY_OAUTH_RESTART, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_LEGACY_OAUTH_RESTART, + ) + else: + ir.async_delete_issue(hass, DOMAIN, ISSUE_LEGACY_OAUTH_RESTART) + + _ISSUE_BY_KIND = { "package": ISSUE_PACKAGE_FAILED, "start": ISSUE_START_FAILED, @@ -450,8 +655,13 @@ async def async_maybe_auto_update( "shipped": shipped, "running": running, }, - learn_more_url=HACS_COMPONENT_URL, + learn_more_url=UPDATE_HOLD_DOCS_URL, ) + # A newer component exists but HACS may not surface it for ~48h; ask + # HACS to refresh this repository now so the update becomes visible + # promptly. Fire-and-forget + throttled per shipped version; fully + # advisory (see hacs_nudge). + async_schedule_hacs_nudge(hass, shipped) return ir.async_delete_issue(hass, DOMAIN, ISSUE_UPDATE_HELD) @@ -707,7 +917,12 @@ async def _async_check_component_compat( severity=ir.IssueSeverity.WARNING, translation_key=ISSUE_COMPONENT_OUTDATED, translation_placeholders={"required": required, "installed": own}, - learn_more_url=HACS_COMPONENT_URL, + learn_more_url=UPDATE_HOLD_DOCS_URL, ) + # The server needs a newer component than HACS has surfaced; ask HACS + # to refresh this repository now so the required update becomes visible + # promptly. Fire-and-forget + throttled per required version; fully + # advisory (see hacs_nudge). + async_schedule_hacs_nudge(hass, required) else: ir.async_delete_issue(hass, DOMAIN, ISSUE_COMPONENT_OUTDATED) diff --git a/custom_components/ha_mcp_tools/hacs_nudge.py b/custom_components/ha_mcp_tools/hacs_nudge.py new file mode 100644 index 0000000..2d4bce3 --- /dev/null +++ b/custom_components/ha_mcp_tools/hacs_nudge.py @@ -0,0 +1,174 @@ +"""Nudge HACS to refresh this component's repository info when a newer +component is detected (#1783/#1785 follow-up). + +The component notices a newer custom component quickly — it checks PyPI every +6 hours (and on every reload/restart) and the auto-update gate reads the +component version shipped at each server release's tag. HACS, by contrast, +refreshes a *custom* repository's release data only about every 48 hours, so +the component update the hold (or the component-outdated repair) is waiting on +is usually not yet visible in HACS. This module runs the same force-refresh +that HACS's own repository "Update information" menu action performs, so HACS's +update entity flips promptly and Home Assistant advertises the component update +natively instead of the user waiting out HACS's cache. + +HACS registers no service and ``homeassistant.update_entity`` is a no-op on its +entities, so there is no supported API for this: the refresh reaches directly +into HACS internals (``hass.data["hacs"]``). Those internals can change under us +at any HACS release, so EVERY access here is defensive and the whole interaction +is advisory — any failure degrades to a debug log and never touches the caller's +update-check path (which runs on bring-up, gating webhook registration, and on +the version coordinator's listener). The same unsupported ``hass.data["hacs"]`` +reach as install_source_check, but deliberately logged at debug where that +module warns: this path retries on every 6h check, so a persistent HACS shape +change would otherwise warn forever about an advisory nicety. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from .const import ( + DOMAIN, + HACS_LEGACY_REPO_FULL_NAME, + HACS_MIRROR_REPO_FULL_NAME, +) + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +# hass.data[DOMAIN] sub-key holding the SET of component versions HACS was +# already asked to refresh for. The hold check runs every 6h; without this the +# same pending version would force a fresh HACS network refresh on every pass. +# A set, not a single string: the update hold nudges with the shipped version +# while the component-outdated check nudges with the required version, and a +# scalar marker would ping-pong between the two, re-refreshing every pass +# (review finding). Distinct from the other DOMAIN sub-keys (const.py) so both +# entry types can share hass.data[DOMAIN]. +_DATA_HACS_NUDGED_VERSIONS = "hacs_nudged_versions" + +# The repository full_names (owner/repo) HACS may track this component under, in +# lookup order: the dedicated mirror first (the current install path), the +# legacy main-repo path second (pre-mirror installs — see install_source_check). +_CANDIDATE_REPO_FULL_NAMES = ( + HACS_MIRROR_REPO_FULL_NAME, + HACS_LEGACY_REPO_FULL_NAME, +) + + +def async_schedule_hacs_nudge(hass: HomeAssistant, target_version: str) -> None: + """Fire-and-forget the HACS refresh nudge for ``target_version``. + + Scheduled as a background task rather than awaited: ``update_repository`` + makes a GitHub network call, and the callers must not block on it — the + component-compat check is awaited inside the server bring-up *before* + webhook registration (blocking it would delay the connect URLs for a + display-only refresh), and the auto-update hold runs on the version + coordinator's listener. ``async_create_task`` keeps a strong reference so + the task is not garbage-collected mid-flight; every failure is contained + inside :func:`async_nudge_hacs_refresh`. + """ + hass.async_create_task( + async_nudge_hacs_refresh(hass, target_version), + f"{DOMAIN}_hacs_nudge", + ) + + +async def async_nudge_hacs_refresh(hass: HomeAssistant, target_version: str) -> None: + """Ask HACS to re-fetch this component's repository info for ``target_version``. + + Throttled to at most one refresh per detected component version (the marker + lives in ``hass.data[DOMAIN]``): the hold check repeats every 6h, and + hammering HACS's GitHub fetch each pass for the same pending version would + be pointless. Absent / broken HACS and a not-yet-registered repository stay + unthrottled so a later pass (once HACS is ready) still gets its one refresh. + """ + domain_data = hass.data.setdefault(DOMAIN, {}) + nudged_versions: set[str] = domain_data.setdefault( + _DATA_HACS_NUDGED_VERSIONS, set() + ) + if target_version in nudged_versions: + # Already refreshed HACS for this pending component version. + return + + try: + refreshed = await _async_force_hacs_repo_refresh(hass) + except Exception: + # HACS internals are unsupported and may change shape (missing hacs, + # renamed attributes, a network failure inside update_repository); any + # of it must degrade to a debug log, never fault the caller. + _LOGGER.debug( + "HA-MCP: could not nudge HACS to refresh the component repository", + exc_info=True, + ) + return + + if refreshed: + # Throttle only on a completed refresh, so a transient miss (HACS not + # set up yet, repo not registered this pass) is retried next check + # rather than suppressed for this version forever. + nudged_versions.add(target_version) + + +async def _async_force_hacs_repo_refresh(hass: HomeAssistant) -> bool: + """Run HACS's "Update information" force-refresh for this component's repo. + + Returns True when a tracked repository was found and its refresh completed, + False when there is nothing to refresh (no HACS, or no INSTALLED repository + under either candidate name). Reaches into HACS internals — + the top-level lookups are ``getattr``-guarded so a wholly different HACS + shape returns False cleanly; anything deeper that changes shape raises and is + swallowed by :func:`async_nudge_hacs_refresh`. + """ + hacs = hass.data.get("hacs") + if hacs is None: + # No HACS (manual/copy install, or HACS set up later this run) — nothing + # to refresh; the caller leaves the throttle unset so a later pass retries. + return False + + repositories = getattr(hacs, "repositories", None) + get_by_full_name = getattr(repositories, "get_by_full_name", None) + if get_by_full_name is None: + return False + + repository = None + for full_name in _CANDIDATE_REPO_FULL_NAMES: + candidate = get_by_full_name(full_name) + if candidate is None: + continue + # HACS keeps a repository record for every ADDED repo, downloaded or + # not, but only creates an update entity for DOWNLOADED ones — and a + # legacy->mirror migration can leave the mirror added but not yet + # (re)installed while the running component is still tracked under the + # legacy record. Refreshing an uninstalled record lights up nothing, so + # only an installed candidate counts (review finding). + if not getattr(getattr(candidate, "data", None), "installed", False): + continue + repository = candidate + break + if repository is None: + return False + + # The repository's "Update information" menu action: re-fetch its release + # data ignoring cached state, then push the fresh data to HACS's own update + # entity so Home Assistant advertises the component update immediately. + await repository.update_repository(ignore_issues=True, force=True) + # The refresh is complete at this point; the listener push below only + # re-publishes the fresh data to HACS's update entity sooner. Guarded + # separately so a HACS shape change here cannot void the completed + # refresh's throttle and re-run the network fetch every pass (review + # finding). + try: + coordinators = getattr(hacs, "coordinators", None) or {} + category = getattr(getattr(repository, "data", None), "category", None) + coordinator = coordinators.get(category) + if coordinator is not None: + coordinator.async_update_listeners() + except Exception: + _LOGGER.debug( + "HA-MCP: HACS listener push after the repository refresh failed", + exc_info=True, + ) + return True diff --git a/custom_components/ha_mcp_tools/install_source_check.py b/custom_components/ha_mcp_tools/install_source_check.py index cb78003..de50b3d 100644 --- a/custom_components/ha_mcp_tools/install_source_check.py +++ b/custom_components/ha_mcp_tools/install_source_check.py @@ -21,17 +21,18 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, callback from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN, HACS_COMPONENT_URL, ISSUE_LEGACY_HACS_SOURCE +from .const import ( + DOMAIN, + HACS_COMPONENT_URL, + HACS_LEGACY_REPO_FULL_NAME, + ISSUE_LEGACY_HACS_SOURCE, +) if TYPE_CHECKING: from homeassistant.core import Event, HomeAssistant _LOGGER = logging.getLogger(__name__) -# The main server repo's full_name, as HACS's repository registry keys it — -# the legacy (pre-mirror) install path this module detects. -_LEGACY_REPO_FULL_NAME = "homeassistant-ai/ha-mcp" - # Guards the schedule below so multiple config entries (tools + server) on the # same HA run only ever schedule the check once. _DATA_SCHEDULED = "install_source_check_scheduled" @@ -86,7 +87,7 @@ async def _async_check_install_source(hass: HomeAssistant) -> None: hacs = hass.data.get("hacs") installed = False if hacs is not None: - repo = hacs.repositories.get_by_full_name(_LEGACY_REPO_FULL_NAME) + repo = hacs.repositories.get_by_full_name(HACS_LEGACY_REPO_FULL_NAME) installed = repo is not None and bool(repo.data.installed) except Exception: _LOGGER.warning( diff --git a/custom_components/ha_mcp_tools/manifest.json b/custom_components/ha_mcp_tools/manifest.json index 4f12e1c..1b2adee 100644 --- a/custom_components/ha_mcp_tools/manifest.json +++ b/custom_components/ha_mcp_tools/manifest.json @@ -2,9 +2,12 @@ "domain": "ha_mcp_tools", "name": "HA-MCP Custom Component", "after_dependencies": [ - "http", + "backup", "cloud", - "frontend" + "frontend", + "http", + "lovelace", + "network" ], "codeowners": [ "@homeassistant-ai" @@ -19,5 +22,5 @@ "requirements": [ "ruamel.yaml>=0.18.0" ], - "version": "1.1.0" + "version": "1.2.2" } diff --git a/custom_components/ha_mcp_tools/mcp_webhook.py b/custom_components/ha_mcp_tools/mcp_webhook.py index 91f78f2..df4e72f 100644 --- a/custom_components/ha_mcp_tools/mcp_webhook.py +++ b/custom_components/ha_mcp_tools/mcp_webhook.py @@ -5,21 +5,34 @@ Ported from the proven webhook-proxy add-on (``mcp_proxy``): an HA webhook the response back, so the server is reachable through Nabu Casa remote UI (or any reverse proxy) with the webhook id as the shared secret. -Two auth postures, chosen in the options flow: +Three auth postures, chosen in the options flow: * ``none`` — the secret webhook URL *is* the credential (matches the add-on's - default). No bearer is required. + default). No bearer is required and the forwarder always returns 200. It still + serves our own corrected RFC 8414 / RFC 9728 discovery documents plus an + invisible auto-approve authorization server (:mod:`oauth_autoapprove`), so + claude.ai's intermittent OAuth discovery resolves against us — not HA core's + broken origin-root doc — and connects with no HA login (issue #1969). * ``ha_auth`` — Home Assistant core is the OAuth authorization server. This module serves the RFC 8414 / RFC 9728 discovery documents (so claude.ai / ChatGPT can sign in with the user's HA account) and validates inbound bearer tokens via ``hass.auth``. There is no bespoke authorization-server code here — every protocol step is HA core's own ``/auth/*``. +* ``legacy`` — this module (via :mod:`oauth_legacy`) is its own OAuth 2.1 + authorization server with a static client_id/secret, for MCP clients (Google + Gemini Spark) that need a credential to paste rather than an HA sign-in. The forwarding handler mirrors ``mcp_proxy._handle_webhook`` exactly (hop-by-hop header stripping, the SSE streaming branch with anti-buffering headers, the content-type whitelist, ``Mcp-Session-Id`` propagation, and the 502/500 error mapping); the ``ha_auth`` bearer check + discovery documents mirror the add-on's -``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``. +``auth_native.py`` + the ``ha_auth`` subset of ``oauth.py``; the ``legacy`` +provider + its root ``/authorize`` + ``/token`` views live in +:mod:`oauth_legacy`, ported from the ``legacy`` subset of the add-on's +``oauth.py``. The seven RFC 8414 / RFC 9728 discovery views below are shared by +``ha_auth``, ``legacy``, and ``none`` (which serves a distinct auto-approve +authorization-server document pointing at :mod:`oauth_autoapprove`'s endpoints) +— see :func:`active_auth_mode`. """ from __future__ import annotations @@ -41,8 +54,22 @@ from .const import ( DOMAIN, OAUTH_BASE, WEBHOOK_AUTH_HA, + WEBHOOK_AUTH_LEGACY, WEBHOOK_AUTH_NONE, ) +from .oauth_autoapprove import ( + CFG_AUTOAPPROVE_PROVIDER, + AutoApproveProvider, + bind_autoapprove_views, +) +from .oauth_legacy import ( + AUTHORIZE_PATH, + OAUTH_ROUTE_OWNER_KEY, + TOKEN_PATH, + LegacyOAuthProvider, + LegacyOAuthRouteConflict, + bind_legacy_views, +) if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -145,14 +172,6 @@ class ResourceServer: """This install's private webhook id.""" return self._webhook_id - def resource_url(self, base_url: str) -> str: - """Absolute URL of the protected webhook resource under ``base_url``.""" - return f"{base_url}/api/webhook/{self._webhook_id}" - - def authorization_server_url(self, base_url: str) -> str: - """Issuer / authorization-server URL under ``base_url``.""" - return f"{base_url}{OAUTH_BASE}" - async def validate_request(self, request: web.Request) -> bool: """Return True iff the request carries a Bearer token HA core accepts. @@ -195,29 +214,62 @@ class ResourceServer: # --------------------------------------------------------------------------- -# RFC 8414 / RFC 9728 discovery views (ha_auth mode only) +# RFC 8414 / RFC 9728 discovery views (ha_auth + legacy modes) # --------------------------------------------------------------------------- -def _active_resource_server(hass: HomeAssistant) -> ResourceServer | None: - """Return the CURRENT entry's ha_auth resource server, or None. - - The discovery views resolve this per request instead of binding a provider - at registration time: aiohttp can't drop a bound view until HA restarts, so - a remove + re-add of the config entry (which mints a NEW webhook id in the - same HA session) would otherwise leave the views advertising the old id. - Returns None when no entry is live, the webhook auth mode is not ha_auth, - or the public endpoint is disabled (local-only mode constructs no resource - server even under ha_auth) — the views then 404 like an unregistered route. - """ +def _active_webhook_cfg(hass: HomeAssistant) -> dict[str, Any] | None: + """Return the live webhook forwarding cfg dict, or None if not set up.""" domain_data = hass.data.get(DOMAIN) if not isinstance(domain_data, dict): return None cfg = domain_data.get(DATA_WEBHOOK) - if not isinstance(cfg, dict) or cfg.get("auth_mode") != WEBHOOK_AUTH_HA: + return cfg if isinstance(cfg, dict) else None + + +def active_auth_mode(hass: HomeAssistant) -> str | None: + """Return the OAuth-relevant auth mode of the live webhook registration. + + ``WEBHOOK_AUTH_HA``, ``WEBHOOK_AUTH_LEGACY``, or ``WEBHOOK_AUTH_NONE`` (the + none-mode auto-approve surface, issue #1969), or None when no discovery + surface is live. Checked via PROVIDER PRESENCE, not the raw configured + ``auth_mode`` string, so local-only mode (remote webhook disabled by + option — ``register_endpoint=False`` in ``async_register_webhook``) + correctly reports None even when ``webhook_auth`` is set: no provider is + constructed for a webhook that was never registered, so there is nothing to + advertise or authenticate against. Read live from hass.data (not captured at view/provider + construction time) so the SAME registered/bound instances serve whichever + mode is active now — mirrors the add-on's ``_active_oauth_mode``. Used by + the discovery views below AND by ``LegacyOAuthProvider.is_active`` (via + the getter passed into :func:`oauth_legacy.bind_legacy_views`) so the + root ``/authorize``/``/token`` views, which aiohttp can never unbind, 404 + once the operator switches away from legacy (or to local-only mode) + without a restart. The webhook forwarder's own bearer gate + (``_async_handle_webhook``) reads ``cfg["resource_server"]`` / + ``cfg["oauth_provider"]`` directly instead of through this function — it + already has ``cfg`` in hand and needs the provider OBJECT, not just the + mode name. + """ + cfg = _active_webhook_cfg(hass) + if cfg is None: return None - provider = cfg.get("resource_server") - return provider if isinstance(provider, ResourceServer) else None + if cfg.get("resource_server") is not None: + return WEBHOOK_AUTH_HA + if cfg.get("oauth_provider") is not None: + return WEBHOOK_AUTH_LEGACY + if cfg.get(CFG_AUTOAPPROVE_PROVIDER) is not None: + return WEBHOOK_AUTH_NONE + return None + + +def _active_webhook_id(hass: HomeAssistant) -> str | None: + """Webhook id of the live registration, gated the same as the AS document + (None whenever :func:`active_auth_mode` is None) so the protected-resource + document 404s in exactly the same cases.""" + if active_auth_mode(hass) is None: + return None + cfg = _active_webhook_cfg(hass) + return cfg.get("webhook_id") if cfg is not None else None def _json_not_found() -> web.Response: @@ -225,16 +277,63 @@ def _json_not_found() -> web.Response: return web.json_response({"error": "not_found"}, status=404) -def _protected_resource_document(provider: ResourceServer, base: str) -> dict[str, Any]: - """RFC 9728 protected-resource document for ``provider`` under ``base``.""" +def _protected_resource_document(webhook_id: str, base: str) -> dict[str, Any]: + """RFC 9728 protected-resource document for ``webhook_id`` under ``base``. + + Identical shape in both OAuth modes — only the authorization-server + document (below) differs by mode. + """ return { - "resource": provider.resource_url(base), - "authorization_servers": [provider.authorization_server_url(base)], + "resource": f"{base}/api/webhook/{webhook_id}", + "authorization_servers": [f"{base}{OAUTH_BASE}"], "bearer_methods_supported": ["header"], "resource_documentation": "https://github.com/homeassistant-ai/ha-mcp", } +def _legacy_authorization_server_document(base: str) -> dict[str, Any]: + """RFC 8414 authorization-server metadata for legacy mode's own root + ``/authorize`` + ``/token`` views (see :mod:`oauth_legacy`).""" + return { + "issuer": f"{base}{OAUTH_BASE}", + "authorization_endpoint": f"{base}{AUTHORIZE_PATH}", + "token_endpoint": f"{base}{TOKEN_PATH}", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + ], + } + + +def _none_mode_authorization_server_document(base: str) -> dict[str, Any]: + """RFC 8414 authorization-server metadata for none mode's auto-approve server. + + Points at OUR OWN ``OAUTH_BASE`` ``/authorize`` + ``/token`` (the invisible + auto-approve endpoints in :mod:`oauth_autoapprove`), NOT HA core's + ``/auth/*``. Serving this — with ``token_endpoint_auth_methods_supported: + ["none"]`` (public PKCE client) and ``client_id_metadata_document_supported`` + — is the none-mode fix: claude.ai's intermittent discovery resolves against + this corrected document instead of HA core's origin-root + ``/.well-known/oauth-authorization-server``, which omits the ``"none"`` auth + method and has no ``registration_endpoint`` (issue #1969). No refresh grant: + the token is cosmetic (none mode ignores bearers), so only + ``authorization_code`` is advertised. + """ + return { + "issuer": f"{base}{OAUTH_BASE}", + "authorization_endpoint": f"{base}{OAUTH_BASE}/authorize", + "token_endpoint": f"{base}{OAUTH_BASE}/token", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "client_id_metadata_document_supported": True, + } + + class _ProtectedResourceMetadataView(HomeAssistantView): """RFC 9728 Protected Resource Metadata.""" @@ -244,21 +343,36 @@ class _ProtectedResourceMetadataView(HomeAssistantView): name = "ha_mcp_tools:oauth:protected-resource" def __init__(self, hass: HomeAssistant) -> None: - """Bind the view to the HA instance; the provider is resolved per request.""" + """Bind the view to the HA instance; liveness is resolved per request.""" self._hass = hass async def get(self, request: web.Request) -> web.Response: - """Serve the protected-resource document (or 404 when ha_auth is off).""" - provider = _active_resource_server(self._hass) - if provider is None: + """Serve the protected-resource document for the bearer-gated modes only. + + SECURITY (#1976 review): this ANONYMOUS, fixed (guessable) path exposes + ``resource: /api/webhook/``. In none mode the webhook id is the + SOLE credential, so serving it here would leak it to any unauthenticated + GET. Serve only for ``ha_auth``/``legacy`` (where the id is not a secret + and the 401 ``WWW-Authenticate`` pointer legitimately directs a client + here); 404 otherwise. The PATH-SCOPED well-known view still serves in none + mode — its caller must already know the id (it is a route parameter). + """ + if active_auth_mode(self._hass) not in (WEBHOOK_AUTH_HA, WEBHOOK_AUTH_LEGACY): + return _json_not_found() + webhook_id = _active_webhook_id(self._hass) + if webhook_id is None: return _json_not_found() return web.json_response( - _protected_resource_document(provider, _build_base_url(request)) + _protected_resource_document(webhook_id, _build_base_url(request)) ) class _AuthorizationServerMetadataView(HomeAssistantView): - """RFC 8414 Authorization Server Metadata (points at HA core's OAuth).""" + """RFC 8414 Authorization Server Metadata. + + Mode-aware: ha_auth points at HA core's own ``/auth/*``; legacy points at + this module's root ``/authorize``/``/token`` views. + """ requires_auth = False cors_allowed = True @@ -270,10 +384,15 @@ class _AuthorizationServerMetadataView(HomeAssistantView): self._hass = hass async def get(self, request: web.Request) -> web.Response: - """Serve the authorization-server document (or 404 when ha_auth is off).""" - if _active_resource_server(self._hass) is None: + """Serve the AS document (or 404 when no OAuth mode is live).""" + mode = active_auth_mode(self._hass) + if mode is None: return _json_not_found() base = _build_base_url(request) + if mode == WEBHOOK_AUTH_LEGACY: + return web.json_response(_legacy_authorization_server_document(base)) + if mode == WEBHOOK_AUTH_NONE: + return web.json_response(_none_mode_authorization_server_document(base)) return web.json_response(_authorization_server_document(base)) @@ -296,16 +415,16 @@ class _WellKnownProtectedResourceView(HomeAssistantView): url = "/.well-known/oauth-protected-resource/api/webhook/{webhook_id}" def __init__(self, hass: HomeAssistant) -> None: - """Bind the view to the HA instance; the provider is resolved per request.""" + """Bind the view to the HA instance; liveness is resolved per request.""" self._hass = hass async def get(self, request: web.Request, webhook_id: str) -> web.Response: """Serve the document only for the CURRENT entry's webhook id.""" - provider = _active_resource_server(self._hass) - if provider is None or webhook_id != provider.webhook_id: + active_id = _active_webhook_id(self._hass) + if active_id is None or webhook_id != active_id: return _json_not_found() return web.json_response( - _protected_resource_document(provider, _build_base_url(request)) + _protected_resource_document(active_id, _build_base_url(request)) ) @@ -324,7 +443,8 @@ class _WellKnownAuthorizationServerMetadataView(_AuthorizationServerMetadataView def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]: - """Build the seven ha_auth discovery-document views (provider-agnostic).""" + """Build the seven discovery-document views, shared by ha_auth and legacy + (mode-agnostic — each view resolves the active mode per request).""" views: list[HomeAssistantView] = [ _ProtectedResourceMetadataView(hass), _AuthorizationServerMetadataView(hass), @@ -355,13 +475,14 @@ def _metadata_views(hass: HomeAssistant) -> list[HomeAssistantView]: def _register_metadata_views(hass: HomeAssistant) -> None: - """Register the ha_auth discovery views at most once per HA session. + """Register the seven discovery views at most once per HA session. - aiohttp cannot unregister a bound view, so a reload / re-enable / re-add must - reuse the already-bound views — they resolve the ACTIVE provider from - hass.data per request, so a later entry (even with a new webhook id) is - served correctly. The guard flag lives at a top-level hass.data key that - survives config-entry teardown. + aiohttp cannot unregister a bound view, so a reload / re-enable / re-add / + ha_auth<->legacy mode switch must all reuse the already-bound views — they + resolve the ACTIVE mode + provider from hass.data per request (see + ``active_auth_mode``), so a later entry (even with a new webhook id, or a + different auth mode) is served correctly. The guard flag lives at a + top-level hass.data key that survives config-entry teardown. """ if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY): return @@ -370,9 +491,7 @@ def _register_metadata_views(hass: HomeAssistant) -> None: hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True -def _build_unauthorized_response( - request: web.Request, provider: ResourceServer -) -> web.Response: +def _build_unauthorized_response(request: web.Request) -> web.Response: """Build the 401 + ``WWW-Authenticate`` challenge MCP clients use to discover. Per RFC 9728 §5.1 / MCP spec, the ``resource_metadata`` parameter points to @@ -397,6 +516,29 @@ def _build_unauthorized_response( # --------------------------------------------------------------------------- +async def _check_webhook_auth( + request: web.Request, cfg: dict[str, Any] +) -> web.StreamResponse | None: + """Return a 401 challenge response if the request fails the auth gate, else None.""" + # Auth gate. ``none`` = the secret webhook URL is the credential; ``ha_auth`` + # validates the bearer via HA core; ``legacy`` validates it against this + # module's own opaque tokens. Either failure emits the same 401 discovery + # challenge so the client can start the OAuth flow. Gate on the PROVIDER + # (constructed only for the matching mode) rather than a string compare, + # so the coupling "provider present <=> mode" has a single owner and an + # inconsistent cfg cannot fail open. auth_mode makes the two mutually + # exclusive, so at most one of these is ever set. + resource_server: ResourceServer | None = cfg.get("resource_server") + if resource_server is not None and not await resource_server.validate_request( + request + ): + return _build_unauthorized_response(request) + oauth_provider: LegacyOAuthProvider | None = cfg.get("oauth_provider") + if oauth_provider is not None and not oauth_provider.validate_bearer(request): + return _build_unauthorized_response(request) + return None + + async def _async_handle_webhook( hass: HomeAssistant, webhook_id: str, request: web.Request ) -> web.StreamResponse: @@ -406,16 +548,9 @@ async def _async_handle_webhook( if not isinstance(cfg, dict): return web.Response(status=503, text="MCP server is not available") - # Auth gate. ``none`` = the secret webhook URL is the credential; ``ha_auth`` - # = validate the bearer via HA core, and on failure emit the 401 discovery - # challenge so the client can start the OAuth flow. Gate on the PROVIDER - # (constructed only for ha_auth) rather than a string compare, so the - # coupling "provider present <=> ha_auth" has a single owner and an - # inconsistent cfg cannot fail open. - provider = cfg.get("resource_server") - if provider is not None: - if not await provider.validate_request(request): - return _build_unauthorized_response(request, provider) + auth_response = await _check_webhook_auth(request, cfg) + if auth_response is not None: + return auth_response target_url: str = cfg["target_url"] session: aiohttp.ClientSession = cfg["session"] @@ -504,22 +639,34 @@ async def async_register_webhook( secret_path: str, auth_mode: str, register_endpoint: bool = True, -) -> None: - """Register the ingress webhook (and, for ha_auth, the discovery views). + oauth_client_id: str | None = None, + oauth_client_secret: str | None = None, + oauth_signing_key: str | None = None, +) -> bool: + """Register the ingress webhook (and, for ha_auth/legacy, the OAuth surface). Stores the forwarding config in ``hass.data[DOMAIN][DATA_WEBHOOK]`` and opens a long-lived aiohttp session for streaming. Raises on failure with the webhook already unregistered, so the caller never leaves a half-configured endpoint live. ``webhook`` is a manifest dependency, so HA guarantees it is set up - before this runs. + before this runs. ``oauth_client_id``/``oauth_client_secret``/ + ``oauth_signing_key`` are required when ``auth_mode == WEBHOOK_AUTH_LEGACY`` + (ignored otherwise); ``oauth_signing_key`` is the hex string persisted in + ``entry.data`` — see ``oauth_legacy._normalize_signing_key``. With ``register_endpoint=False`` (remote webhook access disabled by option) - no public endpoint or ha_auth surface is created — and any leftover endpoint - from a crashed unload is cleared, so off means off; only the forwarding - config is stored, which same-host consumers — the sidebar settings panel - proxy — need to reach the loopback server (#1803). + no public endpoint or ha_auth/legacy surface is created — and any leftover + endpoint from a crashed unload is cleared, so off means off; only the + forwarding config is stored, which same-host consumers — the sidebar + settings panel proxy — need to reach the loopback server (#1803). + + Returns True when the caller should surface ``ISSUE_LEGACY_OAUTH_RESTART``: + the root ``/authorize``/``/token`` views just bound for the first time this + HA session (or with changed credentials), or they are still bound from a + prior legacy registration that this call has moved away from — either way + aiohttp cannot bind or release a view without a full HA restart. """ - if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA): + if auth_mode not in (WEBHOOK_AUTH_NONE, WEBHOOK_AUTH_HA, WEBHOOK_AUTH_LEGACY): # Fail CLOSED on an unknown mode (corrupt/migrated options): refusing # bring-up files a repair issue, instead of an unrecognized string # silently taking the unauthenticated forward path. @@ -540,8 +687,11 @@ async def async_register_webhook( "session": session, "auth_mode": auth_mode, "resource_server": None, + "oauth_provider": None, + CFG_AUTOAPPROVE_PROVIDER: None, } + oauth_restart_needed = False if register_endpoint: try: async_register( @@ -556,6 +706,38 @@ async def async_register_webhook( provider = ResourceServer(hass, webhook_id) _register_metadata_views(hass) cfg["resource_server"] = provider + elif auth_mode == WEBHOOK_AUTH_LEGACY: + if not (oauth_client_id and oauth_client_secret and oauth_signing_key): + raise ValueError( + "legacy webhook auth mode requires oauth_client_id, " + "oauth_client_secret, and oauth_signing_key" + ) + _register_metadata_views(hass) + try: + oauth_provider, oauth_restart_needed = bind_legacy_views( + hass, oauth_client_id, oauth_client_secret, oauth_signing_key + ) + except LegacyOAuthRouteConflict as err: + raise ValueError( + "The Webhook Proxy add-on (or its dev flavor) already " + f"owns the root /authorize and /token routes ({err}). " + "Stop that add-on and restart Home Assistant, then " + "enable legacy mode again." + ) from err + cfg["oauth_provider"] = oauth_provider + else: + # WEBHOOK_AUTH_NONE (the only remaining mode — unknown modes + # already raised above). The secret webhook URL is the + # credential, but we still serve our own corrected discovery + + # an invisible auto-approve authorization server so claude.ai's + # intermittent OAuth discovery resolves against us instead of HA + # core's broken origin-root document, and completes with no HA + # login (issue #1969). Both view bundles bind at most once per + # HA session; the per-request resolvers gate them on this cfg, + # so a none<->ha_auth switch needs no restart. + _register_metadata_views(hass) + bind_autoapprove_views(hass) + cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider() except Exception: # Never leave a live endpoint (or a leaked session) behind a failed # auth-setup path. suppress: the ORIGINAL error must be what @@ -566,14 +748,27 @@ async def async_register_webhook( await session.close() raise + # A PRIOR registration this HA session may still own the legacy root views + # even though THIS call bound no legacy provider — either the mode is no + # longer legacy, OR legacy is still selected but the webhook endpoint is now + # off (register_endpoint=False skips the bind block above). aiohttp can never + # release a bound view without a restart, so gate on "no provider bound this + # call" (not the mode string) to surface the restart that releases route + # ownership in both cases. + if cfg["oauth_provider"] is None and hass.data.get(OAUTH_ROUTE_OWNER_KEY) == DOMAIN: + oauth_restart_needed = True + hass.data.setdefault(DOMAIN, {})[DATA_WEBHOOK] = cfg + return oauth_restart_needed async def async_unregister_webhook(hass: HomeAssistant) -> None: """Unregister the ingress webhook and close its aiohttp session. - Idempotent. The ha_auth discovery views are intentionally left bound (aiohttp - can't unregister them until HA restarts); they 404 while ha_auth is not live. + Idempotent. The discovery views and the legacy root ``/authorize``/``/token`` + views are intentionally left bound (aiohttp can't unregister them until HA + restarts); they 404 while their mode is not live (see ``active_auth_mode`` + / ``LegacyOAuthProvider.is_active``). """ domain_data = hass.data.get(DOMAIN) if not isinstance(domain_data, dict): diff --git a/custom_components/ha_mcp_tools/oauth_autoapprove.py b/custom_components/ha_mcp_tools/oauth_autoapprove.py new file mode 100644 index 0000000..18b34fd --- /dev/null +++ b/custom_components/ha_mcp_tools/oauth_autoapprove.py @@ -0,0 +1,303 @@ +"""None-mode auto-approve OAuth authorization server (issue #1969). + +In ``none`` webhook auth mode the secret webhook URL *is* the credential, so no +bearer is required and the forwarder always returns 200. But claude.ai's +connector onboarding intermittently front-loads OAuth discovery, and because the +component registers no ``/.well-known`` views in none mode, claude.ai falls +through to Home Assistant *core*'s own origin-root +``/.well-known/oauth-authorization-server`` — which advertises +``client_id_metadata_document_supported`` but omits +``token_endpoint_auth_methods_supported: ["none"]`` and has no +``registration_endpoint``. claude.ai then can neither use CIMD nor do dynamic +client registration and shows "Automatic client registration isn't supported…". + +This module is the none-mode fix's authorization-server half: a pair of +path-scoped ``OAUTH_BASE`` endpoints that complete OAuth *invisibly* — no login, +no consent — so a connector that does run discovery resolves against our own +corrected documents (served by :mod:`mcp_webhook`) instead of HA core's broken +root doc, and connects with zero HA login: + +* ``GET {OAUTH_BASE}/authorize`` issues a PKCE-bound one-time code and + immediately 302-redirects back to the client with ``?code=…&state=…`` — no + page is rendered. +* ``POST {OAUTH_BASE}/token`` exchanges that code (public client, PKCE S256, no + ``client_secret``) for an opaque access token. The token is *cosmetic* — none + mode ignores bearers entirely — but is a real random string so a spec-strict + client is satisfied. + +Both views are gated per request off ``hass.data`` (they 404 unless none mode is +the live webhook auth mode), mirroring the discovery views, so a +``none``\\ ↔\\ ``ha_auth`` switch needs no restart. The PKCE code store and the +redirect-URI floor are reused from :mod:`oauth_legacy` rather than copied. + +**Open-redirect defence.** ``/authorize`` 302-redirects to a caller-supplied +``redirect_uri`` on the Home Assistant origin, so an unvalidated target would be +an open redirector. On top of :func:`oauth_legacy._is_valid_redirect_uri`'s +scheme/host/port floor, the redirect must EXACTLY match a known MCP callback +(:data:`_AUTOAPPROVE_REDIRECT_ALLOWLIST`). Anything else is a hard 400 (no +redirect). A "same origin as the client_id" rule was deliberately NOT used: the +client_id is fully attacker-controlled, so ``client_id == redirect_uri origin`` +still lets an attacker bounce a victim to any site of their choosing (a real +open redirect on a public HA origin). Properly honouring an arbitrary CIMD +client would require fetching the attacker-supplied client_id URL — an SSRF +vector — so the allowlist is both the safe and the simple choice. Add a client's +callback here to support it. +""" + +from __future__ import annotations + +import secrets +from typing import TYPE_CHECKING, Any + +from aiohttp import web +from homeassistant.components.http import HomeAssistantView + +from .const import DATA_WEBHOOK, DOMAIN, OAUTH_BASE +from .oauth_legacy import ( + _PKCE_CHALLENGE_RE, + _TOKEN_RESPONSE_HEADERS, + ACCESS_TOKEN_TTL, + PKCECodeStore, + _is_valid_redirect_uri, +) + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + +# cfg (hass.data[DOMAIN][DATA_WEBHOOK]) key holding the live AutoApproveProvider. +# Present ONLY in none mode with the remote endpoint enabled; its presence is +# how :func:`mcp_webhook.active_auth_mode` recognises the none-autoapprove live +# mode (mirrors the "resource_server"/"oauth_provider" presence keys). +CFG_AUTOAPPROVE_PROVIDER = "autoapprove_provider" + +# TOP-LEVEL hass.data flag recording that the two auto-approve views are bound +# for this HA session. Not under DOMAIN so it survives async_unload_entry's +# teardown — aiohttp cannot unregister a bound view until HA restarts, so the +# views (and this ownership flag) must outlive the config entry (mirrors +# mcp_webhook._OAUTH_VIEWS_REGISTERED_KEY). +_AUTOAPPROVE_VIEWS_REGISTERED_KEY = "ha_mcp_tools_oauth_autoapprove_views_registered" + +# Known MCP OAuth callback URLs always accepted as a redirect target even when +# the client_id is not a same-origin URL — claude.ai's connector onboarding +# posts its authorization code here. Exact-match only (never a prefix test, so +# ``https://claude.ai/api/mcp/auth_callback.evil.example`` cannot slip through). +_AUTOAPPROVE_REDIRECT_ALLOWLIST = frozenset( + { + "https://claude.ai/api/mcp/auth_callback", + } +) + + +def _json_not_found() -> web.Response: + """404 JSON body used when none-autoapprove is not the live mode.""" + return web.json_response({"error": "not_found"}, status=404) + + +def _json_error( + error: str, status: int, description: str | None = None +) -> web.Response: + """OAuth-style JSON error (RFC 6749 §5.2 shape) with no-store headers.""" + body: dict[str, str] = {"error": error} + if description is not None: + body["error_description"] = description + return web.json_response(body, status=status, headers=_TOKEN_RESPONSE_HEADERS) + + +def _is_valid_autoapprove_redirect(redirect_uri: str) -> bool: + """Open-redirect gate for the auto-approve ``/authorize`` view. + + Exact-match allowlist only, on top of + :func:`oauth_legacy._is_valid_redirect_uri`'s scheme/host/port floor. The + ``client_id`` is NOT consulted: it is attacker-controlled, so validating the + redirect against it (even "same origin") does not constrain the redirect + target to a trusted host. See the module docstring. + """ + return ( + _is_valid_redirect_uri(redirect_uri) + and redirect_uri in _AUTOAPPROVE_REDIRECT_ALLOWLIST + ) + + +def _redirect_with(redirect_uri: str, **params: str) -> web.Response: + """302 to ``redirect_uri`` with ``params`` merged into its query string.""" + # yarl ships with aiohttp and handles existing-query merging + encoding + # correctly — safer than hand-rolling (matches oauth_legacy.AuthorizeView). + import yarl + + url = yarl.URL(redirect_uri).update_query(params) + return web.Response(status=302, headers={"Location": str(url)}) + + +class AutoApproveProvider: + """None-mode auto-approve authorization-server state. + + Holds only the PKCE code store shared with :mod:`oauth_legacy`; it owns no + signing key and no client credentials (the token it issues is cosmetic). + Constructed per registration and stored in ``cfg`` — the views resolve it + from ``hass.data`` per request, so a reload minting a fresh provider is + transparent (no bound view captures the old one, unlike legacy mode). + """ + + def __init__(self) -> None: + self._code_store = PKCECodeStore() + + def issue_code(self, redirect_uri: str, code_challenge: str) -> str | None: + """Issue a one-shot PKCE-bound authorization code (see PKCECodeStore).""" + return self._code_store.issue_code(redirect_uri, code_challenge) + + def consume_code(self, code: str, redirect_uri: str, code_verifier: str) -> bool: + """Verify PKCE S256 + one-shot consume a code (see PKCECodeStore).""" + return self._code_store.consume_code(code, redirect_uri, code_verifier) + + @staticmethod + def issue_access_token() -> str: + """Mint an opaque access token. + + None mode ignores bearers (the secret webhook URL is the credential), + so this token grants nothing — but it is a real random string, so a + spec-strict client that stores/echoes it is satisfied. + """ + return secrets.token_urlsafe(32) + + +def _active_autoapprove_provider(hass: HomeAssistant) -> AutoApproveProvider | None: + """The live none-mode auto-approve provider, or None when it is not live. + + Read live from ``hass.data`` (not captured at view construction) so the + bound views serve only while none-autoapprove is the active mode and 404 + otherwise — mirrors ``mcp_webhook._active_webhook_id``'s per-request gating. + """ + domain_data = hass.data.get(DOMAIN) + if not isinstance(domain_data, dict): + return None + cfg = domain_data.get(DATA_WEBHOOK) + if not isinstance(cfg, dict): + return None + provider = cfg.get(CFG_AUTOAPPROVE_PROVIDER) + return provider if isinstance(provider, AutoApproveProvider) else None + + +class AutoApproveAuthorizeView(HomeAssistantView): + """None-mode auto-approve ``/authorize`` — issues a code, 302s, no UI. + + Validates ``response_type=code``, PKCE S256, and the redirect_uri + open-redirect gate, then issues a PKCE-bound one-time code and redirects + straight back to the client. No login page and no consent screen render, so + claude.ai's OAuth flow completes invisibly (issue #1969). + """ + + requires_auth = False + cors_allowed = True + url = f"{OAUTH_BASE}/authorize" + name = "ha_mcp_tools:oauth:autoapprove-authorize" + + def __init__(self, hass: HomeAssistant) -> None: + """Bind the view to the HA instance; liveness is resolved per request.""" + self._hass = hass + + async def get(self, request: web.Request) -> web.Response: + """Auto-approve the authorization request or reject with a 400/404.""" + provider = _active_autoapprove_provider(self._hass) + if provider is None: + return _json_not_found() + + params = request.query + response_type = params.get("response_type", "") + redirect_uri = params.get("redirect_uri", "") + state = params.get("state", "") + code_challenge = params.get("code_challenge", "") + code_challenge_method = params.get("code_challenge_method", "") + + if response_type != "code": + return _json_error("unsupported_response_type", 400) + if code_challenge_method != "S256": + return _json_error( + "invalid_request", 400, "code_challenge_method must be S256" + ) + if not _PKCE_CHALLENGE_RE.match(code_challenge): + return _json_error( + "invalid_request", 400, "invalid code_challenge (43-char base64url)" + ) + # SECURITY: an unvalidated redirect_uri would be an open redirector on + # the HA origin. Reject in-place (never redirect) unless it exactly + # matches a known MCP callback (client_id is attacker-controlled and is + # deliberately not consulted — see module docstring). + if not _is_valid_autoapprove_redirect(redirect_uri): + return _json_error("invalid_request", 400, "invalid redirect_uri") + + code = provider.issue_code(redirect_uri, code_challenge) + if code is None: + # Pending-code store at capacity (abuse guard) — surface per + # RFC 6749 §4.1.2.1 instead of a silent failure. + return _redirect_with( + redirect_uri, error="temporarily_unavailable", state=state + ) + redirect_params = {"code": code} + if state: + redirect_params["state"] = state + return _redirect_with(redirect_uri, **redirect_params) + + +class AutoApproveTokenView(HomeAssistantView): + """None-mode auto-approve ``/token`` — PKCE code → opaque access token. + + Public client (no ``client_secret``): the PKCE code_verifier is the only + proof required. The returned access token is cosmetic (none mode ignores + bearers), but real and opaque. Only the ``authorization_code`` grant is + supported — none mode has no refresh cycle. + """ + + requires_auth = False + cors_allowed = True + url = f"{OAUTH_BASE}/token" + name = "ha_mcp_tools:oauth:autoapprove-token" + + def __init__(self, hass: HomeAssistant) -> None: + """Bind the view to the HA instance; liveness is resolved per request.""" + self._hass = hass + + async def post(self, request: web.Request) -> web.Response: + """Exchange a PKCE authorization code for an opaque access token.""" + provider = _active_autoapprove_provider(self._hass) + if provider is None: + return _json_not_found() + + form: dict[str, Any] = dict(await request.post()) + if form.get("grant_type", "") != "authorization_code": + return _json_error("unsupported_grant_type", 400) + + code = str(form.get("code", "")) + redirect_uri = str(form.get("redirect_uri", "")) + code_verifier = str(form.get("code_verifier", "")) + if not (code and redirect_uri and code_verifier): + return _json_error("invalid_request", 400) + if not provider.consume_code(code, redirect_uri, code_verifier): + return _json_error("invalid_grant", 400) + + return web.json_response( + { + "access_token": provider.issue_access_token(), + "token_type": "Bearer", + "expires_in": ACCESS_TOKEN_TTL, + }, + headers=_TOKEN_RESPONSE_HEADERS, + ) + + +def bind_autoapprove_views(hass: HomeAssistant) -> None: + """Bind the two auto-approve views at most once per HA session. + + aiohttp cannot unregister a bound view, so a reload / re-enable / mode + switch must reuse the already-bound views — they resolve the active + provider from ``hass.data`` per request (see + :func:`_active_autoapprove_provider`), so they serve only while + none-autoapprove is live and 404 otherwise. The guard flag lives at a + top-level ``hass.data`` key that survives config-entry teardown (mirrors + :func:`mcp_webhook._register_metadata_views`). + """ + if hass.data.get(_AUTOAPPROVE_VIEWS_REGISTERED_KEY): + return + hass.http.register_view(AutoApproveAuthorizeView(hass)) + hass.http.register_view(AutoApproveTokenView(hass)) + hass.data[_AUTOAPPROVE_VIEWS_REGISTERED_KEY] = True diff --git a/custom_components/ha_mcp_tools/oauth_legacy.py b/custom_components/ha_mcp_tools/oauth_legacy.py new file mode 100644 index 0000000..80c5c1b --- /dev/null +++ b/custom_components/ha_mcp_tools/oauth_legacy.py @@ -0,0 +1,846 @@ +"""Legacy OAuth 2.1 authorization server for the HA-MCP Server webhook. + +Ported from the webhook-proxy add-on's ``mcp_proxy/oauth.py`` ``OAuthProvider`` ++ ``AuthorizeView`` + ``TokenView`` (the proven ``legacy`` mode). Self-hosted, +single-tenant authorization server with a static client_id/client_secret pair, +for MCP clients that need a credential to paste rather than HA core's native +OAuth (``ha_auth``) — currently just Google Gemini Spark, whose custom +connected apps use the Client ID Metadata Document pattern that HA core's +``/auth/authorize`` does not yet support for cross-origin redirect_uris +(home-assistant/core#176282). + +Unlike the add-on, this module holds no reference to ``hass``, the webhook id, +or a public base URL: the discovery-document layer (RFC 8414 / RFC 9728) and +base-URL resolution already live in ``mcp_webhook.py``, shared with the +``ha_auth`` mode, and are extended there to be mode-aware. This module owns +only the OAuth-specific state (tokens, PKCE codes, client auth) and the two +root views the add-on's ``ha_auth`` mode never needed (``ha_auth`` mode has HA +core serve its own ``/auth/authorize`` + ``/auth/token``). + +Tokens are opaque, HMAC-SHA256 signed (``body.sig``), and carry enough state +(``{kind, iat, exp, jti, cid}``) to validate without a server-side store, so +the integration survives HA restarts. ``cid`` (the client_id at issuance time) +means rotating the client_id revokes every outstanding token at the restart +that rebinds the root views with the new identity — until then the bound +provider keeps the old client_id and old tokens keep validating (see +:func:`bind_legacy_views`). +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import ipaddress +import json +import logging +import re +import secrets +import time +from collections.abc import Callable +from html import escape +from typing import TYPE_CHECKING, TypedDict +from urllib.parse import unquote_plus, urlparse + +from aiohttp import web +from homeassistant.components.http import HomeAssistantView + +from .const import WEBHOOK_AUTH_LEGACY + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + +# Registered at the HA ROOT rather than under a component-namespaced path, to +# match the add-on's legacy mode (see the ownership guard below). This was +# motivated by an MCP client that builds ``/authorize`` from the resource +# host root instead of reading the ``authorization_endpoint`` metadata field — +# observed with Google Gemini Spark, which is what legacy mode exists to serve. +# claude.ai, by contrast, DOES honor the advertised ``authorization_endpoint``: +# issue #1969's none-mode auto-approve server advertises a component-scoped +# ``/authorize`` (see :mod:`oauth_autoapprove`) and claude.ai calls exactly that, +# proven live. So the root registration is for the metadata-ignoring clients, not +# a hard requirement for claude.ai. +AUTHORIZE_PATH = "/authorize" +TOKEN_PATH = "/token" + +ACCESS_TOKEN_TTL = 60 * 60 # 1 hour +REFRESH_TOKEN_TTL = 30 * 24 * 60 * 60 # 30 days +AUTH_CODE_TTL = 5 * 60 # 5 minutes +TOKEN_KIND_ACCESS = "access" +TOKEN_KIND_REFRESH = "refresh" + +# RFC 6749 §5.1: a /token response body carries the access/refresh credentials, +# so it MUST NOT be cached by any intermediary (reverse proxy, Nabu Casa, etc.). +_TOKEN_RESPONSE_HEADERS = {"Cache-Control": "no-store", "Pragma": "no-cache"} + +# RFC 8252 §7.3: native/CLI OAuth clients (e.g. GitHub Copilot CLI) receive the +# authorization code on a loopback redirect, for which the spec explicitly +# permits a plain http scheme. Every non-loopback redirect must still be https. +_LOOPBACK_HOSTNAMES = frozenset({"localhost"}) + +# RFC 7636 §4.1: code_verifier is 43-128 chars from the unreserved URL set. +PKCE_VERIFIER_MIN = 43 +PKCE_VERIFIER_MAX = 128 +# SHA-256 → 32 bytes → 43 base64url chars (no padding). +PKCE_S256_CHALLENGE_LEN = 43 +_PKCE_VERIFIER_RE = re.compile(r"^[A-Za-z0-9._~-]+$") +_PKCE_CHALLENGE_RE = re.compile(r"^[A-Za-z0-9_-]{43}$") + +# Pending-code dict cap. An attacker spamming /authorize with valid params +# could grow the dict between the prune passes that run on each issuance. +# 1000 codes is well past anything legitimate (5-min TTL, single-tenant). +MAX_PENDING_CODES = 1000 + +_RESTART_HINT = ( + "If this persists, fully restart Home Assistant (Settings -> System -> " + "Restart) -- Home Assistant cannot rebind the /authorize and /token " + "endpoints to new credentials without a full restart." +) + +# --------------------------------------------------------------------------- +# Root-route ownership guard +# --------------------------------------------------------------------------- +# +# The webhook-proxy add-on's legacy mode (both the stable and dev flavors) +# ALSO claims the root /authorize + /token routes in the same HA instance — +# aiohttp lets the first-registered path win and silently shadows a later +# duplicate, and HA cannot unregister a bound view until it restarts. These +# two literals are therefore the SAME strings as +# ``mcp_proxy.OAUTH_ROUTE_OWNER_KEY`` / ``mcp_proxy.OAUTH_ROUTE_KEY_FINGERPRINT`` +# (deliberately domain-neutral, not namespaced under this component's DOMAIN) +# so whichever integration binds first can be recognized by the other and the +# second registrant fails loud instead of being silently shadowed. +OAUTH_ROUTE_OWNER_KEY = "webhook_proxy_oauth_route_owner" +OAUTH_ROUTE_KEY_FINGERPRINT = "webhook_proxy_oauth_route_key_fingerprint" + +# TOP-LEVEL hass.data key (NOT under DOMAIN, so it survives +# async_unload_entry's hass.data.pop(DOMAIN)) holding the LegacyOAuthProvider +# instance bound to the root views for this HA session. A reload reuses it +# when the credentials match; see bind_legacy_views. +_LEGACY_PROVIDER_KEY = "ha_mcp_tools_oauth_legacy_provider" + +# TOP-LEVEL hass.data flag recording whether the currently-bound root views were +# registered MID-SESSION (hass already running → the route is not actually live +# until a full HA restart) rather than at boot (live immediately). It cannot be +# cleared without a real process restart, which wipes hass.data — so it stays +# True for the life of a session that late-bound, and is absent/False for a +# session that bound cleanly at boot. Read on every reuse so an unrelated reload +# before that restart does not falsely report "no restart needed" and clear the +# repair (the views are still not live). See bind_legacy_views. +_LEGACY_PENDING_RESTART_KEY = "ha_mcp_tools_oauth_legacy_pending_restart" + +# This component's DOMAIN, duplicated here (rather than imported) to avoid a +# module-level dependency on const.DOMAIN for the ownership-marker value — +# the value written IS "ha_mcp_tools", checked against by name below. +_DOMAIN = "ha_mcp_tools" + + +class LegacyOAuthRouteConflict(RuntimeError): + """Raised when another integration already owns the root OAuth routes.""" + + +def _oauth_route_fingerprint( + client_id: str, client_secret: str, signing_key: bytes +) -> str: + """Stable fingerprint of the OAuth identity bound to the root views.""" + h = hashlib.sha256() + h.update(client_id.encode()) + h.update(b"\0") + h.update(client_secret.encode()) + h.update(b"\0") + h.update(signing_key) + return h.hexdigest() + + +def _normalize_signing_key(signing_key: bytes | str) -> bytes: + """Accept either raw bytes or a hex string (how entry.data stores it, + since entry.data must be JSON-serializable).""" + return bytes.fromhex(signing_key) if isinstance(signing_key, str) else signing_key + + +def bind_legacy_views( + hass: HomeAssistant, + client_id: str, + client_secret: str, + signing_key: bytes | str, +) -> tuple[LegacyOAuthProvider, bool]: + """Bind the root ``/authorize`` + ``/token`` views at most once per HA session. + + Returns ``(provider, restart_needed)``. ``provider`` is the identity now + authoritative for the webhook's bearer gate — on a reload with unchanged + credentials this REUSES the already-bound provider (aiohttp cannot rebind + a view, so a fresh provider object would mint tokens the bound views never + issued and vice versa). ``restart_needed`` is True when: + + * the currently-bound views were registered after HA finished starting (a + route registered while ``hass.is_running`` is not actually live until a + restart — this mirrors the add-on's ``oauth_restart_needed = + hass.is_running``) and that restart has not happened yet — this pending + state persists across config-entry reloads until a real HA restart wipes + ``hass.data``, or + * the credentials changed since the currently-bound views were registered + (the bound views keep serving the OLD identity until a restart rebinds + them with the new one). + + Raises :class:`LegacyOAuthRouteConflict` when the webhook-proxy add-on (or + its dev flavor) already owns the root routes in this HA instance. + """ + key_bytes = _normalize_signing_key(signing_key) + fingerprint = _oauth_route_fingerprint(client_id, client_secret, key_bytes) + + owner = hass.data.get(OAUTH_ROUTE_OWNER_KEY) + if owner is not None and owner != _DOMAIN: + _LOGGER.error( + "HA-MCP: cannot enable legacy OAuth mode -- the Webhook Proxy " + "add-on ('%s') already owns the root /authorize and /token routes " + "in this Home Assistant instance, and Home Assistant cannot " + "release them until it restarts. Stop that add-on and restart " + "Home Assistant, then enable legacy mode again.", + owner, + ) + raise LegacyOAuthRouteConflict(owner) + + bound_provider = hass.data.get(_LEGACY_PROVIDER_KEY) + if owner == _DOMAIN and isinstance(bound_provider, LegacyOAuthProvider): + bound_fingerprint = hass.data.get(OAUTH_ROUTE_KEY_FINGERPRINT) + creds_changed = bound_fingerprint != fingerprint + if creds_changed: + _LOGGER.warning( + "HA-MCP: legacy OAuth credentials changed but the bound root " + "views still use the previous ones -- a Home Assistant " + "restart is required to activate the new credentials." + ) + # Still restart-pending if the views were late-bound this session (flag + # persisted below) OR the credentials just changed. Reading the flag + # rather than recomputing keeps an unrelated reload from clearing a + # still-pending restart repair while the routes remain not-live. + pending = bool(hass.data.get(_LEGACY_PENDING_RESTART_KEY)) + return bound_provider, pending or creds_changed + + # First registration this HA session. + provider = LegacyOAuthProvider( + client_id=client_id, + client_secret=client_secret, + signing_key=key_bytes, + active_mode_getter=lambda: _live_auth_mode(hass), + ) + hass.http.register_view(AuthorizeView(provider)) + hass.http.register_view(TokenView(provider)) + hass.data[OAUTH_ROUTE_OWNER_KEY] = _DOMAIN + hass.data[OAUTH_ROUTE_KEY_FINGERPRINT] = fingerprint + hass.data[_LEGACY_PROVIDER_KEY] = provider + # A first registration happening mid-session isn't live until a full HA + # restart; flag it. At HA boot (hass.is_running is still False while + # integrations are being set up) it binds cleanly. Persist the pending + # state so a later reload before that restart reuses it (see the reuse + # branch above) rather than recomputing "no restart needed". + pending_restart = hass.is_running + hass.data[_LEGACY_PENDING_RESTART_KEY] = pending_restart + return provider, pending_restart + + +def legacy_credentials_active( + hass: HomeAssistant, + client_id: str, + client_secret: str, + signing_key: bytes | str, +) -> bool: + """Whether the bound root views currently serve exactly these credentials. + + False while a credential rotation is pending a restart (the bound provider + keeps the previous identity until then — see :func:`bind_legacy_views`), + when another integration owns the routes, or when legacy OAuth was never + bound this session. Callers use this to withhold rotated credentials from + surfaces a still-valid old-identity token can read — the admin startup log + in particular, which is reachable through the server's own log tools + (review finding on #1880). + """ + if hass.data.get(OAUTH_ROUTE_OWNER_KEY) != _DOMAIN: + return False + bound = hass.data.get(OAUTH_ROUTE_KEY_FINGERPRINT) + if not isinstance(bound, str): + return False + current = _oauth_route_fingerprint( + client_id, client_secret, _normalize_signing_key(signing_key) + ) + return hmac.compare_digest(bound, current) + + +def legacy_restart_pending(hass: HomeAssistant) -> bool: + """Whether the root views were bound mid-session and are not live until a + restart (see :func:`bind_legacy_views`). Distinct from + :func:`legacy_credentials_active`: at a mid-session FIRST enable the bound + views serve exactly the current credentials (active is True) yet + ``/authorize`` is not live until the pending restart — the admin surfaces + (options hint, startup log) use this to caveat credentials that are + correct but not yet serving.""" + return bool(hass.data.get(_LEGACY_PENDING_RESTART_KEY)) + + +def _live_auth_mode(hass: HomeAssistant) -> str | None: + """Read the CURRENTLY configured webhook auth mode from hass.data. + + Deferred import to avoid a module cycle: mcp_webhook imports this module + for the views/provider it registers, so this module cannot import + mcp_webhook at load time. + """ + from .mcp_webhook import active_auth_mode + + return active_auth_mode(hass) + + +# --------------------------------------------------------------------------- +# Small helpers (ported from the add-on's oauth.py) +# --------------------------------------------------------------------------- + + +def _b64url_encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _b64url_decode(s: str) -> bytes: + pad = "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s + pad) + + +def _is_loopback_host(hostname: str) -> bool: + """True for the loopback hosts RFC 8252 §7.3/§8.3 allows over plain http.""" + if hostname in _LOOPBACK_HOSTNAMES: + return True + try: + # Covers all of 127.0.0.0/8 and ::1, not just the literal 127.0.0.1. + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def _is_valid_redirect_uri(redirect_uri: str) -> bool: + """Spec-floor validation for OAuth redirect_uri: an https:// URL — or an + http:// loopback URL (RFC 8252 §7.3, for native/CLI clients) — with a + non-empty host, a valid port, and no fragment. Single-tenant — no per-client + allowlist, but reject the obvious bad shapes that would let an attacker + direct the flow to an empty/malformed URL.""" + if not redirect_uri: + return False + try: + parsed = urlparse(redirect_uri) + # Accessing .port validates it: urlparse defers the range/format check + # until access, so a crafted ':999999' or ':abc' port raises ValueError + # HERE (→ clean 400) instead of later in yarl inside _redirect_with, + # where it would escape as an uncaught 500 on an unauthenticated view. + _ = parsed.port + except ValueError: + return False + if not parsed.hostname: + return False + if parsed.scheme == "http": + # Plain http only for loopback callbacks (native-client flow). + if not _is_loopback_host(parsed.hostname): + return False + elif parsed.scheme != "https": + return False + # Fragments are not allowed in OAuth redirect URIs (RFC 6749 §3.1.2). + return not parsed.fragment + + +def _text_error( + status: int, message: str, *, restart_hint: bool = False +) -> web.Response: + """Plain-text error response. ``restart_hint`` appends ``_RESTART_HINT`` — + set it only for the stale-registration cases a full HA restart actually + unsticks (invalid client_id), not for client-side request mistakes.""" + text = f"{message}. {_RESTART_HINT}" if restart_hint else message + return web.Response(status=status, text=text) + + +def _json_error( + error: str, + status: int, + headers: dict[str, str] | None = None, + *, + restart_hint: bool = False, +) -> web.Response: + """OAuth JSON error response. ``restart_hint`` carries ``_RESTART_HINT`` in + ``error_description`` — set it only for the stale-registration case + (``invalid_client``), not client-side protocol errors.""" + body = {"error": error} + if restart_hint: + body["error_description"] = _RESTART_HINT + return web.json_response(body, status=status, headers=headers) + + +def _json_not_found() -> web.Response: + """404 for a root view whose route is disabled in the active mode, or + whose bound provider is stale (see LegacyOAuthProvider.is_active).""" + return web.json_response({"error": "not_found"}, status=404) + + +class _PendingCode(TypedDict): + """Shape of an entry in PKCECodeStore._codes. TypedDict so a typo on + one of these keys fails type-check rather than silently treating it as + missing.""" + + redirect_uri: str + code_challenge: str + expires: float + + +# --------------------------------------------------------------------------- +# PKCECodeStore (shared PKCE S256 authorization-code lifecycle) +# --------------------------------------------------------------------------- + + +class PKCECodeStore: + """In-memory PKCE (S256) authorization-code store. + + Shared by :class:`LegacyOAuthProvider` and the none-mode auto-approve + server (:mod:`oauth_autoapprove`) so the one-shot code lifecycle — issue at + ``/authorize``, verify + consume at ``/token`` — has a single + implementation instead of two copies. Codes are short-lived + (:data:`AUTH_CODE_TTL`), one-shot, bound to the ``redirect_uri`` + + ``code_challenge`` presented at issuance, and capped + (:data:`MAX_PENDING_CODES`) with an expiry prune on each issue. A restart + wipes the store, which only forces in-flight authorize/token round-trips to + retry. + """ + + def __init__(self) -> None: + self._codes: dict[str, _PendingCode] = {} + + def issue_code(self, redirect_uri: str, code_challenge: str) -> str | None: + """Issue a one-shot authorization code, or None if the pending-code + store is at capacity (signals an abuse attempt — see MAX_PENDING_CODES).""" + now = time.time() + self._codes = {k: v for k, v in self._codes.items() if v["expires"] > now} + if len(self._codes) >= MAX_PENDING_CODES: + _LOGGER.warning( + "HA-MCP OAuth: pending-code store at cap (%d); refusing " + "new issuance until existing codes expire or are consumed.", + MAX_PENDING_CODES, + ) + return None + code = secrets.token_urlsafe(32) + self._codes[code] = { + "redirect_uri": redirect_uri, + "code_challenge": code_challenge, + "expires": now + AUTH_CODE_TTL, + } + return code + + def consume_code(self, code: str, redirect_uri: str, code_verifier: str) -> bool: + """One-shot consume ``code``, verifying its PKCE S256 challenge. + + Returns True only for a live, unexpired code whose stored + ``redirect_uri`` matches and whose ``code_challenge`` equals + ``base64url(SHA-256(code_verifier))``. The code is popped (one-shot) + before any check that can fail, so a failed attempt still burns it. + """ + # Validate the verifier shape per RFC 7636 §4.1 before doing any + # crypto. A confused client passing an empty/short verifier should be + # rejected explicitly rather than silently hashing junk. + if not (PKCE_VERIFIER_MIN <= len(code_verifier) <= PKCE_VERIFIER_MAX): + return False + if not _PKCE_VERIFIER_RE.match(code_verifier): + return False + entry = self._codes.pop(code, None) + if entry is None: + return False + if entry["expires"] < time.time(): + return False + if entry["redirect_uri"] != redirect_uri: + return False + # PKCE S256 verification: SHA-256(verifier) base64url(no pad) == challenge + derived = _b64url_encode(hashlib.sha256(code_verifier.encode()).digest()) + return hmac.compare_digest( + derived.encode("ascii"), entry["code_challenge"].encode("ascii") + ) + + +# --------------------------------------------------------------------------- +# LegacyOAuthProvider +# --------------------------------------------------------------------------- + + +class LegacyOAuthProvider: + """Holds legacy-OAuth state: token issue/validate, PKCE codes, client auth. + + Constructed once per bind (see :func:`bind_legacy_views`), not once per + config-entry reload — see that function's docstring for why. Holds no + reference to ``hass``; ``active_mode_getter`` is how the bound root views + learn whether legacy is STILL the live mode on each request (a reload that + switches away leaves this same instance bound but inactive — see + :meth:`is_active`). + """ + + def __init__( + self, + client_id: str, + client_secret: str, + signing_key: bytes | str, + active_mode_getter: Callable[[], str | None], + ) -> None: + if not client_id: + raise ValueError("client_id must be a non-empty string") + if not client_secret: + raise ValueError("client_secret must be a non-empty string") + key_bytes = _normalize_signing_key(signing_key) + if len(key_bytes) < 32: + raise ValueError("signing_key must be at least 32 bytes") + self._client_id = client_id + self._client_secret = client_secret + self._signing_key = key_bytes + self._active_mode_getter = active_mode_getter + # PKCE authorization codes live in the shared store (also used by the + # none-mode auto-approve server) — see :class:`PKCECodeStore`. + self._code_store = PKCECodeStore() + + @property + def client_id(self) -> str: + return self._client_id + + def is_active(self) -> bool: + """True iff legacy is the CURRENTLY configured + live webhook auth mode.""" + return self._active_mode_getter() == WEBHOOK_AUTH_LEGACY + + # ----------------------------------------------------------------- + # Token issuance / validation + # ----------------------------------------------------------------- + + def _issue_token(self, kind: str, ttl: int) -> str: + now = int(time.time()) + payload = { + "kind": kind, + "iat": now, + "exp": now + ttl, + "jti": secrets.token_urlsafe(12), + "cid": self._client_id, + } + body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) + sig = hmac.new(self._signing_key, body.encode("ascii"), hashlib.sha256).digest() + return f"{body}.{_b64url_encode(sig)}" + + def _validate_token(self, token: str, expected_kind: str) -> bool: + try: + body, sig_part = token.rsplit(".", 1) + except ValueError: + return False + try: + actual_sig = _b64url_decode(sig_part) + # body.encode("ascii") is inside the try: a bearer whose + # pre-signature segment carries a non-ASCII char raises + # UnicodeEncodeError, which must be caught here (return False) + # rather than escaping the webhook gate. + expected_sig = hmac.new( + self._signing_key, body.encode("ascii"), hashlib.sha256 + ).digest() + except (ValueError, binascii.Error, UnicodeEncodeError): + return False + if not hmac.compare_digest(actual_sig, expected_sig): + return False + try: + payload = json.loads(_b64url_decode(body)) + except (ValueError, json.JSONDecodeError): + return False + if not isinstance(payload, dict): + return False + if payload.get("kind") != expected_kind: + return False + if payload.get("cid") != self._client_id: + # Token was issued for a previous client_id config — reject so a + # client_id rotation revokes outstanding tokens once the restart + # binds a provider carrying the new identity. Pre-restart the + # bound provider still holds the OLD client_id, so old tokens + # keep validating until then (see bind_legacy_views). + return False + # Valid up to but not including `exp` (RFC 7519 §4.1.4 convention). + return bool(payload.get("exp", 0) > int(time.time())) + + def issue_access_token(self) -> str: + return self._issue_token(TOKEN_KIND_ACCESS, ACCESS_TOKEN_TTL) + + def issue_refresh_token(self) -> str: + return self._issue_token(TOKEN_KIND_REFRESH, REFRESH_TOKEN_TTL) + + def validate_access_token(self, token: str) -> bool: + return self._validate_token(token, TOKEN_KIND_ACCESS) + + def validate_refresh_token(self, token: str) -> bool: + return self._validate_token(token, TOKEN_KIND_REFRESH) + + def validate_bearer(self, request: web.Request) -> bool: + header = request.headers.get("Authorization", "") + if not header.lower().startswith("bearer "): + return False + token = header[7:].strip() + return self.validate_access_token(token) + + # ----------------------------------------------------------------- + # Authorization codes (PKCE) + # ----------------------------------------------------------------- + + def issue_code(self, redirect_uri: str, code_challenge: str) -> str | None: + """Issue a one-shot PKCE-bound authorization code (see PKCECodeStore).""" + return self._code_store.issue_code(redirect_uri, code_challenge) + + def consume_code(self, code: str, redirect_uri: str, code_verifier: str) -> bool: + """Verify PKCE S256 + one-shot consume a code (see PKCECodeStore).""" + return self._code_store.consume_code(code, redirect_uri, code_verifier) + + # ----------------------------------------------------------------- + # Client authentication + # ----------------------------------------------------------------- + + def authenticate_client( + self, client_id: str | None, client_secret: str | None + ) -> bool: + if not client_id or not client_secret: + return False + return hmac.compare_digest( + client_id.encode(), self._client_id.encode() + ) and hmac.compare_digest(client_secret.encode(), self._client_secret.encode()) + + +# --------------------------------------------------------------------------- +# Views (root /authorize + /token) +# --------------------------------------------------------------------------- + + +class AuthorizeView(HomeAssistantView): + """OAuth /authorize endpoint with a minimal consent page.""" + + requires_auth = False + url = AUTHORIZE_PATH + name = "ha_mcp_tools:oauth:authorize" + + def __init__(self, provider: LegacyOAuthProvider) -> None: + self._provider = provider + + @staticmethod + def _redirect_with(redirect_uri: str, **params: str) -> web.Response: + # yarl ships with aiohttp and handles existing-query-string merging + # plus parameter encoding correctly — safer than hand-rolling. + import yarl + + url = yarl.URL(redirect_uri).update_query(params) + return web.Response(status=302, headers={"Location": str(url)}) + + async def get(self, request: web.Request) -> web.Response: + if not self._provider.is_active(): + # Serve ONLY while legacy is the live mode. Both ha_auth/none (HA + # core or the secret URL is the authority) and a not-yet-live + # entry mean this root view must not serve — HA can't rebind or + # drop it without a restart, so a mode switch away leaves it + # bound. Refuse it. + return _text_error(404, "not found") + params = request.query + client_id = params.get("client_id", "") + redirect_uri = params.get("redirect_uri", "") + state = params.get("state", "") + code_challenge = params.get("code_challenge", "") + code_challenge_method = params.get("code_challenge_method", "") + response_type = params.get("response_type", "") + + err = self._validate_authorize_params( + response_type=response_type, + client_id=client_id, + redirect_uri=redirect_uri, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ) + if err is not None: + return err + + # Render minimal consent page. Showing the redirect_uri lets the user + # verify the flow goes back to a domain they recognize. + html = f""" + + + + Authorize MCP Connector + + + +

Authorize MCP Connector

+

An MCP client is requesting access to your Home Assistant MCP server.

+

It will redirect to:
{escape(redirect_uri)}

+

Only allow this if you started this connection yourself.

+
+ + + + + + +
+ +""" + return web.Response(text=html, content_type="text/html") + + async def post(self, request: web.Request) -> web.Response: + if not self._provider.is_active(): + return _text_error(404, "not found") + data = await request.post() + action = str(data.get("action", "")) + client_id = str(data.get("client_id", "")) + redirect_uri = str(data.get("redirect_uri", "")) + state = str(data.get("state", "")) + code_challenge = str(data.get("code_challenge", "")) + + # Re-validate everything from the form — never trust hidden fields. + # response_type/method aren't carried on the POST so we hard-code the + # spec values here; the validator still applies all the same rules to + # the user-influenceable fields. + err = self._validate_authorize_params( + response_type="code", + client_id=client_id, + redirect_uri=redirect_uri, + code_challenge=code_challenge, + code_challenge_method="S256", + ) + if err is not None: + return err + + if action == "deny": + return self._redirect_with(redirect_uri, error="access_denied", state=state) + if action != "approve": + return _text_error(400, "invalid action") + + code = self._provider.issue_code(redirect_uri, code_challenge) + if code is None: + # Pending-code store at cap → signal back per RFC 6749 §4.1.2.1 + # instead of silently failing. + return self._redirect_with( + redirect_uri, error="temporarily_unavailable", state=state + ) + return self._redirect_with(redirect_uri, code=code, state=state) + + def _validate_authorize_params( + self, + *, + response_type: str, + client_id: str, + redirect_uri: str, + code_challenge: str, + code_challenge_method: str, + ) -> web.Response | None: + """Return a 400 web.Response if any /authorize param is invalid, or + None if all checks pass. Centralized so GET and POST share identical + validation — the POST path explicitly re-validates the hidden form + fields rather than trusting them.""" + if response_type != "code": + return _text_error(400, "unsupported_response_type") + if code_challenge_method != "S256": + return _text_error(400, "invalid code_challenge_method (S256 required)") + if not _PKCE_CHALLENGE_RE.match(code_challenge): + return _text_error( + 400, "invalid code_challenge (must be 43-char base64url)" + ) + if client_id != self._provider.client_id: + return _text_error(400, "invalid client_id", restart_hint=True) + if not _is_valid_redirect_uri(redirect_uri): + return _text_error( + 400, + "redirect_uri must be an https:// URL (or an http:// loopback " + "URL) with a valid host and port", + ) + return None + + +class TokenView(HomeAssistantView): + """OAuth /token endpoint: authorization_code + refresh_token grants.""" + + requires_auth = False + cors_allowed = True + url = TOKEN_PATH + name = "ha_mcp_tools:oauth:token" + + def __init__(self, provider: LegacyOAuthProvider) -> None: + self._provider = provider + + @staticmethod + def _extract_client_creds( + request: web.Request, form: dict + ) -> tuple[str | None, str | None]: + """Pull client_id/secret from Basic auth header OR form body.""" + header = request.headers.get("Authorization", "") + if header.lower().startswith("basic "): + try: + decoded = base64.b64decode(header[6:].strip(), validate=True).decode( + "utf-8" + ) + except (ValueError, UnicodeDecodeError, binascii.Error): + return None, None + if ":" in decoded: + cid, _, sec = decoded.partition(":") + # RFC 6749 §2.3.1: client_secret_basic values are + # application/x-www-form-urlencoded before base64, so decode + # them back with unquote_PLUS ("+" means space in that + # encoding — plain unquote would leave it literal). A no-op for + # the generated credentials (URL-safe alphabets, nothing to + # decode) but required for custom overrides containing reserved + # characters — matches the form-body path below, which aiohttp + # also form-decodes ("+" → space). + return unquote_plus(cid), unquote_plus(sec) + return None, None + return form.get("client_id"), form.get("client_secret") + + async def post(self, request: web.Request) -> web.Response: + if not self._provider.is_active(): + return _json_not_found() + form = dict(await request.post()) + client_id, client_secret = self._extract_client_creds(request, form) + if not self._provider.authenticate_client(client_id, client_secret): + return _json_error( + "invalid_client", + 401, + headers={"WWW-Authenticate": 'Basic realm="HA-MCP OAuth"'}, + restart_hint=True, + ) + + grant_type = form.get("grant_type", "") + if grant_type == "authorization_code": + return await self._handle_authorization_code(form) + if grant_type == "refresh_token": + return await self._handle_refresh(form) + return _json_error("unsupported_grant_type", 400) + + async def _handle_authorization_code(self, form: dict) -> web.Response: + code = str(form.get("code", "")) + redirect_uri = str(form.get("redirect_uri", "")) + code_verifier = str(form.get("code_verifier", "")) + if not (code and redirect_uri and code_verifier): + return _json_error("invalid_request", 400) + if not self._provider.consume_code(code, redirect_uri, code_verifier): + return _json_error("invalid_grant", 400) + return web.json_response( + { + "access_token": self._provider.issue_access_token(), + "token_type": "Bearer", + "expires_in": ACCESS_TOKEN_TTL, + "refresh_token": self._provider.issue_refresh_token(), + }, + headers=_TOKEN_RESPONSE_HEADERS, + ) + + async def _handle_refresh(self, form: dict) -> web.Response: + refresh = str(form.get("refresh_token", "")) + if not refresh or not self._provider.validate_refresh_token(refresh): + return _json_error("invalid_grant", 400) + return web.json_response( + { + "access_token": self._provider.issue_access_token(), + "token_type": "Bearer", + "expires_in": ACCESS_TOKEN_TTL, + "refresh_token": self._provider.issue_refresh_token(), + }, + headers=_TOKEN_RESPONSE_HEADERS, + ) diff --git a/custom_components/ha_mcp_tools/services.yaml b/custom_components/ha_mcp_tools/services.yaml index cd89287..552d4fe 100644 --- a/custom_components/ha_mcp_tools/services.yaml +++ b/custom_components/ha_mcp_tools/services.yaml @@ -96,6 +96,26 @@ read_file: min: 1 max: 10000 mode: box + yaml_path: + name: YAML Path + description: >- + Dotted key path. When set, the response also carries the round-trip + text of that YAML subtree under "subtree". Comments and HA tags + (!secret, !include) are preserved as written. + required: false + example: "alert2" + selector: + text: + include_parsed: + name: Include Parsed + description: >- + With yaml_path, also return the subtree as structured data under + "parsed". HA tags are rendered to their source form (!secret api_key) + and never resolved, so no secret value is exposed. + required: false + default: false + selector: + boolean: write_file: name: Write File diff --git a/custom_components/ha_mcp_tools/strings.json b/custom_components/ha_mcp_tools/strings.json index 8c600a8..dd934a8 100644 --- a/custom_components/ha_mcp_tools/strings.json +++ b/custom_components/ha_mcp_tools/strings.json @@ -3,40 +3,44 @@ "step": { "user": { "title": "HA-MCP Custom Component", - "description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. **HA MCP Tools** adds the privileged file and YAML editing services, which are only needed if you turn on ha-mcp's opt-in file/YAML tools; you can add it later at any time.", + "description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. It is a standalone server and a complete replacement for every other install method (add-on, Docker, uvx/PyPI, stdio); if you run it, do not also run one of those. **HA-MCP File & YAML Tools** adds the privileged file and YAML editing services, needed only if you turn on ha-mcp's opt-in file/YAML tools. If your ha-mcp server already runs elsewhere (add-on, Docker, uvx), you do not need the server entry - add only this File & YAML entry, and only if you use those tools. It works with every server type and can be added later at any time.", "menu_options": { "server": "HA-MCP Server (recommended)", - "tools": "HA MCP Tools (optional file & YAML services)" + "tools": "HA-MCP File & YAML Tools (optional)" } }, "tools": { - "title": "HA MCP Tools", + "title": "HA-MCP File & YAML Tools", "description": "Sets up the privileged file and YAML configuration services. Only needed if you enable ha-mcp's opt-in file/YAML editing tools (feature flags, off by default) - this applies to every server type, including the in-process HA-MCP Server. You can add or remove this entry at any time." }, "server": { "title": "HA-MCP Server", - "description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options." + "description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). It is a standalone server and a complete replacement for the add-on, Docker, uvx/PyPI, and stdio install methods - do not run it alongside another ha-mcp server. Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options." } }, "abort": { "already_configured": "This entry is already set up.", - "unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA MCP Tools entry with an external ha-mcp server running as an add-on or Docker container." + "unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA-MCP File & YAML Tools entry with an external ha-mcp server running as an add-on or Docker container." } }, "options": { - "abort": { - "no_options": "The HA MCP Tools services entry has no options to configure." - }, "step": { + "tools_info": { + "title": "HA-MCP File & YAML Tools", + "description": "This entry provides the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. There is nothing to configure here yet. Which directories the file tools may access is managed from the ha-mcp server's own settings (its Settings UI / allowed-paths), not here. Per-entry options may appear on this screen in a future release." + }, "init": { "title": "HA-MCP Server", - "description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}", + "description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}", "data": { "channel": "Release channel", "auto_update": "Automatic server updates", "server_port": "MCP server listening port", "bind_host": "Network access", "webhook_auth": "Authentication mode", + "oauth_client_id_override": "Legacy OAuth: custom Client ID (optional)", + "oauth_client_secret_override": "Legacy OAuth: custom Client Secret (optional)", + "oauth_regenerate": "Legacy OAuth: regenerate Client ID/Secret now", "pip_spec": "Developer: ha-mcp package override", "server_url": "Home Assistant URL (advanced)", "external_url": "External URL (optional)", @@ -54,9 +58,12 @@ "auto_update": "When on, the newest release of the selected channel is installed automatically - on a reload or restart, and via a periodic check. When off, the server stays on the version currently installed until you turn this back on. This controls the ha-mcp server package only; updates to the HA-MCP Custom Component itself still come through HACS.", "server_port": "The port this server listens on. The ha-mcp add-on uses 9583, so this defaults to 9584 to let both run side by side - if you don't run the add-on, any free port works.", "bind_host": "Who can connect to the MCP server port directly. The default matches the add-on: reachable on your local network, with the secret path as the credential. Choose loopback to allow only connections from the Home Assistant machine itself - the webhook URL and the sidebar panel work either way.", - "webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth).", + "webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth). With legacy OAuth, this integration issues its own Client ID and Secret to paste into clients that require one, such as Google Gemini Spark - it needs a Home Assistant restart to turn on or off.", + "oauth_client_id_override": "Replaces the auto-generated legacy OAuth Client ID. Leave empty to keep the current one. Only used while Authentication mode is set to legacy OAuth.", + "oauth_client_secret_override": "Replaces the auto-generated legacy OAuth Client Secret. Leave empty to keep the current one. Only used while Authentication mode is set to legacy OAuth.", + "oauth_regenerate": "One-time action: mints a fresh Client ID and Secret for legacy OAuth mode. It takes effect only after the Home Assistant restart the repair prompt asks for - until you restart, the previous Client ID and Secret keep working and the new ones do not. Also clears the two override fields above.", "pip_spec": "Leave empty. Only for testing a specific ha-mcp build (for example a pre-release pin); overrides the release channel and disables automatic updates until cleared.", - "server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave the default unless you know you need a different route.", + "server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave empty to derive it from this instance's port and SSL settings; set a value only when the server needs a different route.", "external_url": "Shown as the primary connect URL - use this when Home Assistant sits behind your own domain or reverse proxy. Enter the full base address including the scheme. It must point directly at Home Assistant - opening it in a browser should reach your HA login page - and must not contain a port such as :8123 (or any other port), or remote MCP clients won't be able to reach it. Leave empty to use Nabu Casa / the local address automatically.", "webhook_id_override": "Replaces the random webhook secret in the connect URL (/api/webhook/...). The URL is the credential - use a long, hard-to-guess value. Leave empty to keep the current one.", "secret_path_override": "Replaces the random path used for direct access on the server port. Same rule: the path is the credential. Leave empty to keep the current one.", @@ -81,7 +88,7 @@ }, "component_outdated": { "title": "Update the HA-MCP Custom Component via HACS", - "description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated." + "description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS (open the HA-MCP Custom Component entry and use 'Update information' if no update is shown yet) and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated." }, "server_update_held": { "title": "HA-MCP server update waiting for a component update", @@ -90,6 +97,10 @@ "legacy_hacs_source": { "title": "Component installed from the legacy repository", "description": "HACS is tracking the main ha-mcp server repository for this component, so HACS shows the server's version numbers (7.x) and the server's release notes here instead of the component's own (1.x). Updates keep working, but stay mislabeled this way. To fix: remove this repository from HACS (your integration settings and config entries are kept), add homeassistant-ai/ha-mcp-integration as a custom repository, reinstall the component from it, and restart Home Assistant." + }, + "legacy_oauth_restart": { + "title": "Restart Home Assistant to apply the legacy OAuth change", + "description": "The legacy OAuth authentication mode registers its own /authorize and /token web endpoints, which Home Assistant can only bind or release when it fully restarts - whether you just turned this mode on, turned it off, or changed its Client ID/Secret. Restart Home Assistant (Settings - System - Restart) to apply the change; until then the previous behavior stays in effect." } }, "selector": { @@ -102,7 +113,8 @@ "server_webhook_auth": { "options": { "none": "Secret webhook URL (default)", - "ha_auth": "Sign in with Home Assistant (OAuth)" + "ha_auth": "Sign in with Home Assistant (OAuth)", + "legacy": "Legacy OAuth (Client ID/Secret, for Google Gemini Spark)" } }, "llm_api_exposure": { diff --git a/custom_components/ha_mcp_tools/translations/de.json b/custom_components/ha_mcp_tools/translations/de.json new file mode 100644 index 0000000..55feee3 --- /dev/null +++ b/custom_components/ha_mcp_tools/translations/de.json @@ -0,0 +1,135 @@ +{ + "config": { + "step": { + "user": { + "title": "HA-MCP Custom Component", + "description": "Wähle aus, was du hinzufügen möchtest. **HA-MCP Server** führt den vollständigen ha-mcp-Server in Home Assistant aus und stellt ihn über einen Home Assistant Webhook bereit – das ist die Installation, die die meisten Nutzer brauchen. Er ist ein eigenständiger Server und ein vollständiger Ersatz für alle anderen Installationsmethoden (Add-on, Docker, uvx/PyPI, stdio); wenn du ihn nutzt, solltest du keine dieser anderen Methoden gleichzeitig verwenden. **HA-MCP File & YAML Tools** fügt die privilegierten Datei- und YAML-Bearbeitungsdienste hinzu, die nur benötigt werden, wenn du die opt-in Datei/YAML-Tools von ha-mcp aktivierst. Wenn dein ha-mcp-Server bereits woanders läuft (Add-on, Docker, uvx), benötigst du den Server-Eintrag nicht – füge nur diesen File & YAML-Eintrag hinzu, und auch nur, wenn du diese Tools verwendest. Er funktioniert mit jedem Server-Typ und kann jederzeit später hinzugefügt werden.", + "menu_options": { + "server": "HA-MCP Server (empfohlen)", + "tools": "HA-MCP File & YAML Tools (optional)" + } + }, + "tools": { + "title": "HA-MCP File & YAML Tools", + "description": "Richtet die privilegierten Datei- und YAML-Konfigurationsdienste ein. Nur erforderlich, wenn du die opt-in Datei/YAML-Bearbeitungstools von ha-mcp aktivierst (Feature-Flags, standardmäßig deaktiviert) – dies gilt für jeden Server-Typ, einschließlich des In-Process HA-MCP Servers. Du kannst diesen Eintrag jederzeit hinzufügen oder entfernen." + }, + "server": { + "title": "HA-MCP Server", + "description": "Dies führt den vollständigen ha-mcp-Server in Home Assistant aus und stellt ihn remote über einen Home Assistant Webhook bereit (erreichbar über Nabu Casa oder jeden Reverse Proxy). Er ist ein eigenständiger Server und ein vollständiger Ersatz für die Add-on-, Docker-, uvx/PyPI- und stdio-Installationsmethoden – führe ihn nicht neben einem anderen ha-mcp-Server aus. Wähle **Absenden**, um ihn zu starten; du kannst Port, Binding und Authentifizierung danach in den Integrationsoptionen ändern." + } + }, + "abort": { + "already_configured": "Dieser Eintrag ist bereits eingerichtet.", + "unsupported_home_assistant": "Der In-Process HA-MCP Server benötigt Home Assistant {required} oder neuer, aber diese Instanz läuft mit {installed}. Du kannst weiterhin den HA-MCP File & YAML Tools-Eintrag dieses Components mit einem externen ha-mcp-Server verwenden, der als Add-on oder Docker-Container läuft." + } + }, + "options": { + "step": { + "tools_info": { + "title": "HA-MCP File & YAML Tools", + "description": "Dieser Eintrag stellt die privilegierten Datei- und YAML-Bearbeitungsdienste bereit, die von den opt-in Datei/YAML-Tools von ha-mcp verwendet werden. Hier gibt es noch nichts zu konfigurieren. Welche Verzeichnisse die Datei-Tools nutzen dürfen, wird in den eigenen Einstellungen des ha-mcp-Servers (Settings UI / allowed-paths) verwaltet, nicht hier. Pro-Eintrag-Optionen könnten in einem zukünftigen Release auf diesem Bildschirm erscheinen." + }, + "init": { + "title": "HA-MCP Server", + "description": "Konfiguriere den HA-MCP Server. {panel_hint}Änderungen hier werden beim Speichern angewendet.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}", + "data": { + "channel": "Release-Kanal", + "auto_update": "Automatische Server-Updates", + "server_port": "MCP-Server Listening-Port", + "bind_host": "Netzwerkzugriff", + "webhook_auth": "Authentifizierungsmodus", + "oauth_client_id_override": "Legacy OAuth: benutzerdefinierte Client ID (optional)", + "oauth_client_secret_override": "Legacy OAuth: benutzerdefiniertes Client Secret (optional)", + "oauth_regenerate": "Legacy OAuth: Client ID/Secret jetzt neu generieren", + "pip_spec": "Entwickler: ha-mcp Package Override", + "server_url": "Home Assistant URL (erweitert)", + "external_url": "Externe URL (optional)", + "webhook_id_override": "Benutzerdefiniertes Webhook-Secret (optional)", + "secret_path_override": "Benutzerdefinierter Direktzugriffs-Pfad (optional)", + "regenerate_secrets": "Verbindungs-Secrets jetzt neu generieren", + "enable_webhook": "Remote-Zugriff über Webhook", + "enable_llm_api": "Conversation-Agent LLM API", + "llm_api_exposure": "Conversation-Agent Tool-Bereitstellung", + "enable_startup_notification": "Start-Benachrichtigung", + "enable_sidebar_panel": "Sidebar-Einstellungen-Panel" + }, + "data_description": { + "channel": "Stable installiert das neueste stabile Release; Development installiert den neuesten Development-Build. Wenn automatische Updates an sind, installieren ein Reload oder Restart sowie eine regelmäßige Prüfung den neuesten Build des ausgewählten Kanals. Ein Developer Package Override unten hat Vorrang und deaktiviert automatische Updates.", + "auto_update": "Wenn an, wird das neueste Release des ausgewählten Kanals automatisch installiert – bei einem Reload oder Restart und über eine regelmäßige Prüfung. Wenn aus, bleibt der Server auf der aktuell installierten Version, bis du dies wieder einschaltest. Dies steuert nur das ha-mcp Server-Package; Updates für das HA-MCP Custom Component selbst kommen weiterhin über HACS.", + "server_port": "Der Port, auf dem dieser Server lauscht. Das ha-mcp Add-on nutzt 9583, daher ist hier standardmäßig 9584 eingestellt, damit beide parallel laufen können – wenn du das Add-on nicht nutzt, funktioniert jeder freie Port.", + "bind_host": "Wer sich direkt mit dem MCP-Server-Port verbinden kann. Der Standard entspricht dem Add-on: erreichbar in deinem lokalen Netzwerk, mit dem Secret-Pfad als Credential. Wähle Loopback, um nur Verbindungen von der Home Assistant Maschine selbst zuzulassen – die Webhook-URL und das Sidebar-Panel funktionieren in beiden Fällen.", + "webhook_auth": "Wie sich MCP-Clients an der Webhook-URL authentifizieren. Mit der Secret-URL ist der Link selbst das Credential. Mit Home Assistant Sign-in melden sich Clients wie claude.ai mit einem Home Assistant Administrator-Konto an (OAuth). Mit Legacy OAuth stellt diese Integration eigene Client ID und Secret aus, die in Clients eingefügt werden, die eines benötigen, wie Google Gemini Spark – ein Home Assistant Restart ist zum Ein- oder Ausschalten erforderlich.", + "oauth_client_id_override": "Ersetzt die automatisch generierte Legacy OAuth Client ID. Leer lassen, um die aktuelle zu behalten. Wird nur verwendet, während der Authentifizierungsmodus auf Legacy OAuth gesetzt ist.", + "oauth_client_secret_override": "Ersetzt das automatisch generierte Legacy OAuth Client Secret. Leer lassen, um das aktuelle zu behalten. Wird nur verwendet, während der Authentifizierungsmodus auf Legacy OAuth gesetzt ist.", + "oauth_regenerate": "Einmalige Aktion: erstellt eine neue Client ID und Secret für den Legacy OAuth Modus. Sie wird erst nach dem Home Assistant Restart wirksam, zu dem die Repair-Meldung auffordert – bis zum Restart funktionieren die vorherigen Client ID und Secret weiter, die neuen noch nicht. Löscht auch die beiden Override-Felder oben.", + "pip_spec": "Leer lassen. Nur zum Testen eines spezifischen ha-mcp Builds (z.B. ein Pre-Release-Pin); überschreibt den Release-Kanal und deaktiviert automatische Updates, bis es geleert wird.", + "server_url": "Die URL, die der In-Process-Server nutzt, um deinen Home Assistant zu erreichen (normalerweise diese Instanz selbst). Leer lassen, um sie aus Port und SSL-Einstellungen dieser Instanz abzuleiten; setze nur einen Wert, wenn der Server eine andere Route benötigt.", + "external_url": "Wird als primäre Verbindungs-URL angezeigt – nutze dies, wenn Home Assistant hinter deiner eigenen Domain oder einem Reverse Proxy liegt. Gib die vollständige Basisadresse inklusive Schema ein. Sie muss direkt auf Home Assistant zeigen – beim Öffnen im Browser sollte deine HA-Login-Seite erscheinen – und darf keinen Port wie :8123 (oder einen anderen Port) enthalten, sonst können remote MCP-Clients sie nicht erreichen. Leer lassen, um automatisch Nabu Casa / die lokale Adresse zu verwenden.", + "webhook_id_override": "Ersetzt das zufällige Webhook-Secret in der Verbindungs-URL (/api/webhook/...). Die URL ist das Credential – nutze einen langen, schwer zu erratenden Wert. Leer lassen, um das aktuelle zu behalten.", + "secret_path_override": "Ersetzt den zufälligen Pfad für den Direktzugriff auf dem Server-Port. Gleiche Regel: Der Pfad ist das Credential. Leer lassen, um den aktuellen zu behalten.", + "regenerate_secrets": "Einmalige Aktion: erstellt ein neues zufälliges Webhook-Secret und einen Direktzugriffs-Pfad, wodurch die alten Verbindungs-URLs sofort ungültig werden. Löscht auch die beiden Override-Felder oben.", + "enable_webhook": "Ausschalten für nur-lokal-Modus: Der Home Assistant Webhook wird gar nicht registriert, sodass nichts – einschließlich Nabu Casa – den Server über Home Assistant erreichen kann. Der direkte Server-Port und das Sidebar-Panel funktionieren weiter.", + "enable_llm_api": "Biete das vollständige Toolset für Home Assistant Conversation-Agenten (OpenAI, Google, Ollama, ...) an: Während aktiviert, können Agenten 'HA-MCP Server' unter Control Home Assistant auswählen und die Tools aus Assist-Chat und Sprache nutzen. Das Aktivieren macht es nur auswählbar – nichts wird bereitgestellt, bis du es bei einem Agenten auswählst. Nutzungsanleitung: {llm_api_docs_url}", + "llm_api_exposure": "Form des Toolsets, das Conversation-Agenten angeboten wird. Tool Search (Standard) hält den Kontext des Agenten klein: eine kompakte API mit angehefteten Tools plus Such/Ausführungs-Meta-Tools. Full Catalog listet jedes bereitgestellte Tool direkt auf – besser für Modelle mit großem Kontext. Both registriert beide nebeneinander, sodass jeder Agent unter Control Home Assistant sein eigenes wählt. Die Tool-Bereitstellung pro Tool wird im HA-MCP Settings Panel verwaltet; Details: {llm_api_docs_url}", + "enable_startup_notification": "Zeige bei jedem Server-Start eine Benachrichtigung, die auf die nur-für-Administratoren-Einstellungsoberflächen verweist. Ausschalten, um still zu starten – die Verbindungs-URLs erscheinen weiterhin im Home Assistant Log.", + "enable_sidebar_panel": "Zeige das HA-MCP Settings Panel in der Sidebar (nur Administratoren). Ausschalten, um den Sidebar-Eintrag zu entfernen – Server-Optionen bleiben auf diesem Bildschirm verfügbar." + } + } + } + }, + "issues": { + "server_start_failed": { + "title": "Der HA-MCP In-Process-Server konnte nicht gestartet werden", + "description": "Der HA-MCP In-Process-Server konnte nicht in Home Assistant gestartet werden:\n\n{detail}\n\nPrüfe die Home Assistant Logs und lade dann die Integration neu (oder behebe das zugrunde liegende Problem und lade neu), um es erneut zu versuchen." + }, + "server_package_install_failed": { + "title": "Das HA-MCP In-Process-Server Package konnte nicht installiert werden", + "description": "Die Installation des ha-mcp Packages für den In-Process-Server ist fehlgeschlagen:\n\n{detail}\n\nBehebe das oben beschriebene Kompatibilitäts- oder Installationsproblem und lade dann die Integration neu, um es erneut zu versuchen." + }, + "component_outdated": { + "title": "Aktualisiere das HA-MCP Custom Component über HACS", + "description": "Der installierte ha-mcp Server benötigt HA-MCP Custom Component {required} oder neuer, aber du hast {installed}. Aktualisiere das Component über HACS (öffne den HA-MCP Custom Component-Eintrag und nutze 'Update information', falls noch kein Update angezeigt wird) und starte Home Assistant neu. Der Server läuft in der Zwischenzeit weiter, aber einige neuere Features funktionieren möglicherweise nicht, bis das Component aktualisiert ist." + }, + "server_update_held": { + "title": "HA-MCP Server-Update wartet auf ein Component-Update", + "description": "ha-mcp Server {latest} ist verfügbar, aber dieses Release hat auch das HA-MCP Custom Component aktualisiert (auf {shipped}; du nutzt {running}). Um zu vermeiden, dass eine Server-Version gestartet wird, die nie mit dem laufenden Component getestet wurde, ist das automatische Server-Update ausgesetzt, bis das Component aktualisiert ist.\n\nAktualisiere das Component über HACS (öffne den HA-MCP Custom Component-Eintrag und nutze 'Update information', falls noch kein Update angezeigt wird), starte dann Home Assistant neu – das Server-Update wird danach automatisch installiert. Um das Server-Update trotzdem zu installieren, drücke Install bei der HA-MCP Server Update-Entität." + }, + "legacy_hacs_source": { + "title": "Component aus dem Legacy-Repository installiert", + "description": "HACS verfolgt das Haupt-ha-mcp-Server-Repository für dieses Component, daher zeigt HACS hier die Versionsnummern des Servers (7.x) und die Release-Notes des Servers statt der eigenen des Components (1.x). Updates funktionieren weiter, bleiben aber so falsch beschriftet. Um zu beheben: Entferne dieses Repository aus HACS (deine Integrationseinstellungen und Config-Einträge bleiben erhalten), füge homeassistant-ai/ha-mcp-integration als Custom Repository hinzu, installiere das Component daraus neu und starte Home Assistant neu." + }, + "legacy_oauth_restart": { + "title": "Starte Home Assistant neu, um die Legacy OAuth-Änderung anzuwenden", + "description": "Der Legacy OAuth-Authentifizierungsmodus registriert eigene /authorize- und /token-Web-Endpunkte, die Home Assistant nur bei einem vollständigen Restart binden oder freigeben kann – egal ob du diesen Modus gerade ein- oder ausgeschaltet oder Client ID/Secret geändert hast. Starte Home Assistant neu (Einstellungen - System - Neustart), um die Änderung anzuwenden; bis dahin bleibt das vorherige Verhalten aktiv." + } + }, + "selector": { + "server_channel": { + "options": { + "stable": "Stable (empfohlen)", + "dev": "Development (neuester Build)" + } + }, + "server_webhook_auth": { + "options": { + "none": "Secret Webhook URL (Standard)", + "ha_auth": "Mit Home Assistant anmelden (OAuth)", + "legacy": "Legacy OAuth (Client ID/Secret, für Google Gemini Spark)" + } + }, + "llm_api_exposure": { + "options": { + "tool_search": "Tool Search (kompakt, Standard)", + "full": "Full Catalog", + "both": "Beides (pro Agent wählen)" + } + } + }, + "entity": { + "update": { + "server_update": { + "name": "Update" + } + } + } +} diff --git a/custom_components/ha_mcp_tools/translations/en.json b/custom_components/ha_mcp_tools/translations/en.json index 8c600a8..dd934a8 100644 --- a/custom_components/ha_mcp_tools/translations/en.json +++ b/custom_components/ha_mcp_tools/translations/en.json @@ -3,40 +3,44 @@ "step": { "user": { "title": "HA-MCP Custom Component", - "description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. **HA MCP Tools** adds the privileged file and YAML editing services, which are only needed if you turn on ha-mcp's opt-in file/YAML tools; you can add it later at any time.", + "description": "Choose what to add. **HA-MCP Server** runs the full ha-mcp server inside Home Assistant and exposes it through a Home Assistant webhook - this is the install most people want. It is a standalone server and a complete replacement for every other install method (add-on, Docker, uvx/PyPI, stdio); if you run it, do not also run one of those. **HA-MCP File & YAML Tools** adds the privileged file and YAML editing services, needed only if you turn on ha-mcp's opt-in file/YAML tools. If your ha-mcp server already runs elsewhere (add-on, Docker, uvx), you do not need the server entry - add only this File & YAML entry, and only if you use those tools. It works with every server type and can be added later at any time.", "menu_options": { "server": "HA-MCP Server (recommended)", - "tools": "HA MCP Tools (optional file & YAML services)" + "tools": "HA-MCP File & YAML Tools (optional)" } }, "tools": { - "title": "HA MCP Tools", + "title": "HA-MCP File & YAML Tools", "description": "Sets up the privileged file and YAML configuration services. Only needed if you enable ha-mcp's opt-in file/YAML editing tools (feature flags, off by default) - this applies to every server type, including the in-process HA-MCP Server. You can add or remove this entry at any time." }, "server": { "title": "HA-MCP Server", - "description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options." + "description": "This runs the full ha-mcp server inside Home Assistant and exposes it remotely through a Home Assistant webhook (reachable via Nabu Casa or any reverse proxy). It is a standalone server and a complete replacement for the add-on, Docker, uvx/PyPI, and stdio install methods - do not run it alongside another ha-mcp server. Select **Submit** to start it; you can change the port, binding, and authentication afterward in the integration options." } }, "abort": { "already_configured": "This entry is already set up.", - "unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA MCP Tools entry with an external ha-mcp server running as an add-on or Docker container." + "unsupported_home_assistant": "The in-process HA-MCP Server requires Home Assistant {required} or newer, but this instance is running {installed}. You can still use this component's HA-MCP File & YAML Tools entry with an external ha-mcp server running as an add-on or Docker container." } }, "options": { - "abort": { - "no_options": "The HA MCP Tools services entry has no options to configure." - }, "step": { + "tools_info": { + "title": "HA-MCP File & YAML Tools", + "description": "This entry provides the privileged file and YAML editing services used by ha-mcp's opt-in file/YAML tools. There is nothing to configure here yet. Which directories the file tools may access is managed from the ha-mcp server's own settings (its Settings UI / allowed-paths), not here. Per-entry options may appear on this screen in a future release." + }, "init": { "title": "HA-MCP Server", - "description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}", + "description": "Configure the HA-MCP server. {panel_hint}Changes here are applied on save.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}", "data": { "channel": "Release channel", "auto_update": "Automatic server updates", "server_port": "MCP server listening port", "bind_host": "Network access", "webhook_auth": "Authentication mode", + "oauth_client_id_override": "Legacy OAuth: custom Client ID (optional)", + "oauth_client_secret_override": "Legacy OAuth: custom Client Secret (optional)", + "oauth_regenerate": "Legacy OAuth: regenerate Client ID/Secret now", "pip_spec": "Developer: ha-mcp package override", "server_url": "Home Assistant URL (advanced)", "external_url": "External URL (optional)", @@ -54,9 +58,12 @@ "auto_update": "When on, the newest release of the selected channel is installed automatically - on a reload or restart, and via a periodic check. When off, the server stays on the version currently installed until you turn this back on. This controls the ha-mcp server package only; updates to the HA-MCP Custom Component itself still come through HACS.", "server_port": "The port this server listens on. The ha-mcp add-on uses 9583, so this defaults to 9584 to let both run side by side - if you don't run the add-on, any free port works.", "bind_host": "Who can connect to the MCP server port directly. The default matches the add-on: reachable on your local network, with the secret path as the credential. Choose loopback to allow only connections from the Home Assistant machine itself - the webhook URL and the sidebar panel work either way.", - "webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth).", + "webhook_auth": "How MCP clients prove themselves at the webhook URL. With the secret URL, the link itself is the credential. With Home Assistant sign-in, clients such as claude.ai log in with a Home Assistant administrator account (OAuth). With legacy OAuth, this integration issues its own Client ID and Secret to paste into clients that require one, such as Google Gemini Spark - it needs a Home Assistant restart to turn on or off.", + "oauth_client_id_override": "Replaces the auto-generated legacy OAuth Client ID. Leave empty to keep the current one. Only used while Authentication mode is set to legacy OAuth.", + "oauth_client_secret_override": "Replaces the auto-generated legacy OAuth Client Secret. Leave empty to keep the current one. Only used while Authentication mode is set to legacy OAuth.", + "oauth_regenerate": "One-time action: mints a fresh Client ID and Secret for legacy OAuth mode. It takes effect only after the Home Assistant restart the repair prompt asks for - until you restart, the previous Client ID and Secret keep working and the new ones do not. Also clears the two override fields above.", "pip_spec": "Leave empty. Only for testing a specific ha-mcp build (for example a pre-release pin); overrides the release channel and disables automatic updates until cleared.", - "server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave the default unless you know you need a different route.", + "server_url": "The URL the in-process server uses to reach your Home Assistant (usually this instance itself). Leave empty to derive it from this instance's port and SSL settings; set a value only when the server needs a different route.", "external_url": "Shown as the primary connect URL - use this when Home Assistant sits behind your own domain or reverse proxy. Enter the full base address including the scheme. It must point directly at Home Assistant - opening it in a browser should reach your HA login page - and must not contain a port such as :8123 (or any other port), or remote MCP clients won't be able to reach it. Leave empty to use Nabu Casa / the local address automatically.", "webhook_id_override": "Replaces the random webhook secret in the connect URL (/api/webhook/...). The URL is the credential - use a long, hard-to-guess value. Leave empty to keep the current one.", "secret_path_override": "Replaces the random path used for direct access on the server port. Same rule: the path is the credential. Leave empty to keep the current one.", @@ -81,7 +88,7 @@ }, "component_outdated": { "title": "Update the HA-MCP Custom Component via HACS", - "description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated." + "description": "The installed ha-mcp server requires HA-MCP Custom Component {required} or newer, but you have {installed}. Update the component via HACS (open the HA-MCP Custom Component entry and use 'Update information' if no update is shown yet) and restart Home Assistant. The server keeps running in the meantime, but some newer features may not work until the component is updated." }, "server_update_held": { "title": "HA-MCP server update waiting for a component update", @@ -90,6 +97,10 @@ "legacy_hacs_source": { "title": "Component installed from the legacy repository", "description": "HACS is tracking the main ha-mcp server repository for this component, so HACS shows the server's version numbers (7.x) and the server's release notes here instead of the component's own (1.x). Updates keep working, but stay mislabeled this way. To fix: remove this repository from HACS (your integration settings and config entries are kept), add homeassistant-ai/ha-mcp-integration as a custom repository, reinstall the component from it, and restart Home Assistant." + }, + "legacy_oauth_restart": { + "title": "Restart Home Assistant to apply the legacy OAuth change", + "description": "The legacy OAuth authentication mode registers its own /authorize and /token web endpoints, which Home Assistant can only bind or release when it fully restarts - whether you just turned this mode on, turned it off, or changed its Client ID/Secret. Restart Home Assistant (Settings - System - Restart) to apply the change; until then the previous behavior stays in effect." } }, "selector": { @@ -102,7 +113,8 @@ "server_webhook_auth": { "options": { "none": "Secret webhook URL (default)", - "ha_auth": "Sign in with Home Assistant (OAuth)" + "ha_auth": "Sign in with Home Assistant (OAuth)", + "legacy": "Legacy OAuth (Client ID/Secret, for Google Gemini Spark)" } }, "llm_api_exposure": { diff --git a/custom_components/ha_mcp_tools/translations/ru.json b/custom_components/ha_mcp_tools/translations/ru.json new file mode 100644 index 0000000..b64135b --- /dev/null +++ b/custom_components/ha_mcp_tools/translations/ru.json @@ -0,0 +1,135 @@ +{ + "config": { + "step": { + "user": { + "title": "Пользовательский компонент HA-MCP", + "description": "Выберите, что добавить. **Сервер HA-MCP** запускает полноценный сервер ha-mcp внутри Home Assistant и предоставляет к нему доступ через вебхук Home Assistant — этот вариант установки подходит большинству пользователей. Это автономный сервер, полностью заменяющий все остальные способы установки (дополнение, Docker, uvx/PyPI, stdio); если он запущен, не запускайте одновременно ни один из них. **Файловые инструменты и инструменты YAML HA-MCP** добавляют привилегированные службы для работы с файлами и редактирования YAML. Они нужны, только если вы включите отключённые по умолчанию файловые инструменты и инструменты YAML в ha-mcp. Если сервер ha-mcp уже работает в другом месте (как дополнение, в Docker или через uvx), добавлять серверную запись не нужно — добавьте только эту запись для файлов и YAML и только в том случае, если используете эти инструменты. Она работает с сервером любого типа, и её можно добавить позднее в любое время.", + "menu_options": { + "server": "Сервер HA-MCP (рекомендуется)", + "tools": "Файловые инструменты и инструменты YAML HA-MCP (необязательно)" + } + }, + "tools": { + "title": "Файловые инструменты и инструменты YAML HA-MCP", + "description": "Настраивает привилегированные службы для работы с файлами и конфигурацией YAML. Требуется, только если вы включаете отключённые по умолчанию файловые инструменты и инструменты редактирования YAML в ha-mcp (флаги функций). Это относится к серверу любого типа, в том числе к внутрипроцессному серверу HA-MCP. Эту запись можно добавить или удалить в любое время." + }, + "server": { + "title": "Сервер HA-MCP", + "description": "Запускает полноценный сервер ha-mcp внутри Home Assistant и предоставляет удалённый доступ к нему через вебхук Home Assistant (доступный через Nabu Casa или любой обратный прокси-сервер). Это автономный сервер, полностью заменяющий способы установки в виде дополнения, Docker, uvx/PyPI и stdio; не запускайте его одновременно с другим сервером ha-mcp. Нажмите **Отправить**, чтобы запустить его. После этого порт, сетевой интерфейс и способ аутентификации можно изменить в параметрах интеграции." + } + }, + "abort": { + "already_configured": "Эта запись уже настроена.", + "unsupported_home_assistant": "Для внутрипроцессного сервера HA-MCP требуется Home Assistant версии {required} или новее, но в этом экземпляре установлена версия {installed}. Вы по-прежнему можете использовать запись «Файловые инструменты и инструменты YAML HA-MCP» этого компонента с внешним сервером ha-mcp, работающим как дополнение или контейнер Docker." + } + }, + "options": { + "step": { + "tools_info": { + "title": "Файловые инструменты и инструменты YAML HA-MCP", + "description": "Эта запись предоставляет привилегированные службы для работы с файлами и редактирования YAML, которые используются отключёнными по умолчанию файловыми инструментами и инструментами YAML в ha-mcp. Пока здесь нечего настраивать. Каталоги, доступные файловым инструментам, задаются в собственных настройках сервера ha-mcp (в его интерфейсе настроек, в разделе разрешённых путей), а не здесь. В будущей версии на этом экране могут появиться параметры для отдельных записей." + }, + "init": { + "title": "Сервер HA-MCP", + "description": "Настройте сервер HA-MCP. {panel_hint}Изменения применяются при сохранении.\n\n{versions}\n\n{connect_url}\n\n{oauth_creds}", + "data": { + "channel": "Канал выпуска", + "auto_update": "Автоматические обновления сервера", + "server_port": "Порт прослушивания MCP-сервера", + "bind_host": "Сетевой доступ", + "webhook_auth": "Режим аутентификации", + "oauth_client_id_override": "Устаревший OAuth: пользовательский Client ID (необязательно)", + "oauth_client_secret_override": "Устаревший OAuth: пользовательский Client Secret (необязательно)", + "oauth_regenerate": "Устаревший OAuth: создать новые Client ID и Secret сейчас", + "pip_spec": "Для разработчиков: переопределение пакета ha-mcp", + "server_url": "URL-адрес Home Assistant (расширенная настройка)", + "external_url": "Внешний URL-адрес (необязательно)", + "webhook_id_override": "Пользовательский секрет вебхука (необязательно)", + "secret_path_override": "Пользовательский путь прямого доступа (необязательно)", + "regenerate_secrets": "Создать новые секреты подключения сейчас", + "enable_webhook": "Удалённый доступ через вебхук", + "enable_llm_api": "LLM API диалогового агента", + "llm_api_exposure": "Набор инструментов для диалогового агента", + "enable_startup_notification": "Уведомление о запуске", + "enable_sidebar_panel": "Панель настроек в боковой панели" + }, + "data_description": { + "channel": "Стабильный канал устанавливает последнюю стабильную версию, а канал разработки — самую новую сборку для разработки. Если автоматические обновления включены, последняя сборка выбранного канала устанавливается при перезагрузке интеграции или перезапуске, а также во время периодической проверки. Указанное ниже переопределение пакета для разработчиков имеет приоритет и отключает автоматические обновления.", + "auto_update": "Если параметр включён, последняя версия выбранного канала устанавливается автоматически при перезагрузке интеграции или перезапуске, а также во время периодической проверки. Если параметр выключен, сервер остаётся на текущей установленной версии, пока вы снова не включите его. Это относится только к пакету сервера ha-mcp; обновления самого пользовательского компонента HA-MCP по-прежнему устанавливаются через HACS.", + "server_port": "Порт, который прослушивает этот сервер. Дополнение ha-mcp использует порт 9583, поэтому по умолчанию выбран порт 9584, чтобы оба сервера могли работать одновременно. Если дополнение не используется, подойдёт любой свободный порт.", + "bind_host": "Определяет, кто может подключаться непосредственно к порту MCP-сервера. Значение по умолчанию соответствует дополнению: сервер доступен в локальной сети, а секретный путь используется как учётные данные. Выберите кольцевой интерфейс, чтобы разрешить подключения только с самого компьютера Home Assistant. URL-адрес вебхука и панель в боковом меню будут работать в любом случае.", + "webhook_auth": "Определяет, как MCP-клиенты подтверждают свою подлинность при обращении к URL-адресу вебхука. При использовании секретного URL-адреса учётными данными служит сама ссылка. При использовании входа через Home Assistant такие клиенты, как claude.ai, входят с учётной записью администратора Home Assistant (OAuth). При использовании устаревшего OAuth интеграция выдаёт собственные Client ID и Secret, которые нужно вставить в клиенты, где они требуются, например Google Gemini Spark. Для включения или выключения этого режима необходимо перезапустить Home Assistant.", + "oauth_client_id_override": "Заменяет автоматически созданный Client ID устаревшего OAuth. Оставьте поле пустым, чтобы сохранить текущее значение. Используется, только когда выбран режим аутентификации «Устаревший OAuth».", + "oauth_client_secret_override": "Заменяет автоматически созданный Client Secret устаревшего OAuth. Оставьте поле пустым, чтобы сохранить текущее значение. Используется, только когда выбран режим аутентификации «Устаревший OAuth».", + "oauth_regenerate": "Одноразовое действие: создаёт новые Client ID и Secret для режима устаревшего OAuth. Они начнут действовать только после перезапуска Home Assistant, который будет предложен в уведомлении о восстановлении. До перезапуска прежние Client ID и Secret продолжат работать, а новые работать не будут. Также очищает два поля переопределения выше.", + "pip_spec": "Оставьте пустым. Предназначено только для тестирования определённой сборки ha-mcp (например, для фиксации предварительной версии). Переопределяет канал выпуска и отключает автоматические обновления, пока поле не будет очищено.", + "server_url": "URL-адрес, по которому внутрипроцессный сервер обращается к Home Assistant (обычно к этому же экземпляру). Оставьте пустым, чтобы сформировать адрес из порта и настроек SSL этого экземпляра. Указывайте значение только в том случае, если сервер должен использовать другой маршрут.", + "external_url": "Отображается как основной URL-адрес подключения. Используйте его, если Home Assistant доступен через собственный домен или обратный прокси-сервер. Введите полный базовый адрес, включая схему. Он должен указывать непосредственно на Home Assistant: при открытии в браузере должна появляться страница входа HA. Адрес не должен содержать порт, например :8123 или любой другой, иначе удалённые MCP-клиенты не смогут подключиться. Оставьте поле пустым, чтобы автоматически использовать Nabu Casa или локальный адрес.", + "webhook_id_override": "Заменяет случайный секрет вебхука в URL-адресе подключения (/api/webhook/...). URL-адрес служит учётными данными, поэтому используйте длинное значение, которое трудно угадать. Оставьте поле пустым, чтобы сохранить текущее значение.", + "secret_path_override": "Заменяет случайный путь для прямого доступа через порт сервера. Действует то же правило: путь служит учётными данными. Оставьте поле пустым, чтобы сохранить текущее значение.", + "regenerate_secrets": "Одноразовое действие: создаёт новый случайный секрет вебхука и путь прямого доступа, немедленно делая прежние URL-адреса подключения недействительными. Также очищает два поля переопределения выше.", + "enable_webhook": "Выключите для работы только в локальном режиме: вебхук Home Assistant вообще не будет зарегистрирован, поэтому ничто, включая Nabu Casa, не сможет обратиться к серверу через Home Assistant. Прямой порт сервера и панель в боковом меню продолжат работать.", + "enable_llm_api": "Предоставляет полный набор инструментов диалоговым агентам Home Assistant (OpenAI, Google, Ollama и другим). Пока параметр включён, агенты могут выбрать «Сервер HA-MCP» в разделе управления Home Assistant и использовать инструменты из чата и голосового интерфейса Assist. Включение лишь делает его доступным для выбора: ничего не предоставляется, пока вы не выберете его в настройках агента. Руководство по использованию: {llm_api_docs_url}", + "llm_api_exposure": "Определяет вид набора инструментов, предоставляемого диалоговым агентам. Поиск инструментов (по умолчанию) уменьшает контекст агента: используется компактный API с закреплёнными инструментами и метаинструментами поиска и выполнения. Полный каталог перечисляет все доступные инструменты напрямую и лучше подходит моделям с большим контекстом. Вариант «Оба» регистрирует оба набора параллельно, чтобы каждый агент мог выбрать свой в разделе управления Home Assistant. Доступность отдельных инструментов настраивается в панели HA-MCP; подробности: {llm_api_docs_url}", + "enable_startup_notification": "Показывает уведомление при каждом запуске сервера со ссылками на доступные только администраторам страницы настроек. Выключите, чтобы запускать сервер без уведомлений. URL-адреса подключения всё равно будут записываться в журнал Home Assistant.", + "enable_sidebar_panel": "Показывает панель настроек HA-MCP в боковом меню (только для администраторов). Выключите, чтобы убрать пункт из бокового меню. Параметры сервера останутся доступны на этом экране." + } + } + } + }, + "issues": { + "server_start_failed": { + "title": "Не удалось запустить внутрипроцессный сервер HA-MCP", + "description": "Не удалось запустить внутрипроцессный сервер HA-MCP внутри Home Assistant:\n\n{detail}\n\nПроверьте журналы Home Assistant, затем перезагрузите интеграцию (либо устраните основную проблему и перезагрузите её), чтобы повторить попытку." + }, + "server_package_install_failed": { + "title": "Не удалось установить пакет внутрипроцессного сервера HA-MCP", + "description": "Не удалось установить пакет ha-mcp для внутрипроцессного сервера:\n\n{detail}\n\nУстраните описанную выше проблему совместимости или установки, затем перезагрузите интеграцию, чтобы повторить попытку." + }, + "component_outdated": { + "title": "Обновите пользовательский компонент HA-MCP через HACS", + "description": "Для установленного сервера ha-mcp требуется пользовательский компонент HA-MCP версии {required} или новее, но у вас установлена версия {installed}. Обновите компонент через HACS (откройте запись пользовательского компонента HA-MCP и выберите «Обновить информацию», если обновление ещё не отображается) и перезапустите Home Assistant. Тем временем сервер продолжит работать, но некоторые новые функции могут не работать до обновления компонента." + }, + "server_update_held": { + "title": "Обновление сервера HA-MCP ожидает обновления компонента", + "description": "Доступен сервер ha-mcp версии {latest}, но в этом выпуске также обновлён пользовательский компонент HA-MCP (до версии {shipped}; у вас работает версия {running}). Чтобы не запускать версию сервера, которая не тестировалась с работающим компонентом, автоматическое обновление сервера приостановлено до обновления компонента.\n\nОбновите компонент через HACS (откройте запись пользовательского компонента HA-MCP и выберите «Обновить информацию», если обновление ещё не отображается), затем перезапустите Home Assistant. После этого обновление сервера установится автоматически. Чтобы всё равно установить обновление сервера, нажмите «Установить» у сущности обновления сервера HA-MCP." + }, + "legacy_hacs_source": { + "title": "Компонент установлен из устаревшего репозитория", + "description": "HACS отслеживает для этого компонента основной репозиторий сервера ha-mcp, поэтому здесь отображаются номера версий сервера (7.x) и примечания к его выпускам вместо собственных версий компонента (1.x). Обновления продолжают работать, но будут и дальше обозначаться неверно. Чтобы исправить это, удалите этот репозиторий из HACS (настройки интеграции и записи конфигурации сохранятся), добавьте homeassistant-ai/ha-mcp-integration как пользовательский репозиторий, переустановите из него компонент и перезапустите Home Assistant." + }, + "legacy_oauth_restart": { + "title": "Перезапустите Home Assistant, чтобы применить изменение устаревшего OAuth", + "description": "Режим аутентификации с устаревшим OAuth регистрирует собственные веб-точки /authorize и /token, которые Home Assistant может привязать или освободить только при полном перезапуске — независимо от того, включили вы этот режим, выключили его или изменили Client ID/Secret. Перезапустите Home Assistant («Настройки» → «Система» → «Перезапустить»), чтобы применить изменение. До этого продолжит действовать прежнее поведение." + } + }, + "selector": { + "server_channel": { + "options": { + "stable": "Стабильный (рекомендуется)", + "dev": "Для разработки (последняя сборка)" + } + }, + "server_webhook_auth": { + "options": { + "none": "Секретный URL-адрес вебхука (по умолчанию)", + "ha_auth": "Вход через Home Assistant (OAuth)", + "legacy": "Устаревший OAuth (Client ID/Secret, для Google Gemini Spark)" + } + }, + "llm_api_exposure": { + "options": { + "tool_search": "Поиск инструментов (компактный, по умолчанию)", + "full": "Полный каталог", + "both": "Оба (выбор для каждого агента)" + } + } + }, + "entity": { + "update": { + "server_update": { + "name": "Обновление" + } + } + } +} diff --git a/custom_components/ha_mcp_tools/ui_panel.py b/custom_components/ha_mcp_tools/ui_panel.py index 3eca71f..0a5b0b8 100644 --- a/custom_components/ha_mcp_tools/ui_panel.py +++ b/custom_components/ha_mcp_tools/ui_panel.py @@ -82,6 +82,31 @@ _PROXY_URL = f"{_UI_BASE}/app/{{path:.*}}" # path-scoped to the proxy so it is never sent to the boot/session endpoints. _COOKIE_NAME = "ha_mcp_tools_ui_session" _COOKIE_PATH = f"{_UI_BASE}/app" +# Keep in sync with ``ha_mcp.settings_ui._i18n.LOCALE_COOKIE`` without +# importing the separately installed server package into the HA component. +_LOCALE_COOKIE_NAME = "ha_mcp_locale" + + +def _is_valid_locale_cookie_value(value: str) -> bool: + """True for BCP-47-like values: ASCII-alphanumeric runs joined by single + ``-``/``_`` separators (no leading/trailing/doubled separators). + + A plain character walk instead of a regex: linear by construction, where + CodeQL's backtracking model flagged every regex shape for this language + as potentially polynomial. + """ + prev_is_sep = True # a separator may not open the value + for ch in value: + if ch in "-_": + if prev_is_sep: + return False + prev_is_sep = True + elif ch.isascii() and ch.isalnum(): + prev_is_sep = False + else: + return False + return bool(value) and not prev_is_sep + # Session lifetime. Short by design; the panel re-mints well within it while open. _SESSION_TTL_SECONDS = 8 * 60 * 60 @@ -94,7 +119,9 @@ _SESSIONS_KEY = "ha_mcp_tools_ui_sessions" # Request headers never forwarded to the loopback server. Hop-by-hop plus the # browser's cookie/authorization (the loopback server has no auth on the secret -# path and must not receive the session cookie or the frontend bearer). +# path and must not receive the session cookie or the frontend bearer). The +# locale cookie is reconstructed separately from the parsed cookie jar so no +# other browser cookie can cross this trust boundary. _STRIPPED_REQUEST_HEADERS = frozenset( { "host", @@ -121,6 +148,24 @@ _STRIPPED_RESPONSE_HEADERS = frozenset( ) +def _forwarded_locale_cookie(request: web.Request) -> str | None: + """Return the single safe locale cookie header allowed upstream. + + The settings app stores a manual language override in ``ha_mcp_locale``. + Forwarding the browser's raw Cookie header would also expose Home + Assistant's authenticated session cookie to the unauthenticated loopback + server, so rebuild a header containing only a short BCP-47-like value. + """ + value = request.cookies.get(_LOCALE_COOKIE_NAME) + if ( + not isinstance(value, str) + or len(value) > 64 + or not _is_valid_locale_cookie_value(value) + ): + return None + return f"{_LOCALE_COOKIE_NAME}={value}" + + # --------------------------------------------------------------------------- # Session store (server-side; no secret ever placed in a URL) # --------------------------------------------------------------------------- @@ -300,6 +345,9 @@ class _ProxyView(HomeAssistantView): for key, value in request.headers.items() if key.lower() not in _STRIPPED_REQUEST_HEADERS } + locale_cookie = _forwarded_locale_cookie(request) + if locale_cookie is not None: + forward_headers["Cookie"] = locale_cookie try: async with session.request( @@ -473,7 +521,7 @@ class _suppress_all: _BOOT_JS = f""" const SESSION_URL = {_SESSION_URL!r}; -const APP_URL = {_APP_PREFIX!r} + "settings"; +const APP_BASE_URL = {_APP_PREFIX!r} + "settings"; // Re-mint at half the cookie lifetime so an open panel never expires mid-use. const REFRESH_MS = {_SESSION_TTL_SECONDS // 2} * 1000; // While the frontend is still booting (a cold start straight into this panel), @@ -495,6 +543,22 @@ let busy = false; let tokenMisses = 0; let authDead = false; +function homeAssistantRoot() {{ + try {{ + if (window.parent === window) return null; + return window.parent.document.querySelector("home-assistant"); + }} catch (err) {{ + return null; + }} +}} + +function appUrl() {{ + const root = homeAssistantRoot(); + const language = root && root.hass && root.hass.language; + if (!language) return APP_BASE_URL; + return APP_BASE_URL + "?ha_lang=" + encodeURIComponent(language); +}} + function showMessage(text, isError) {{ frame.classList.add("hidden"); msg.classList.remove("hidden"); @@ -532,8 +596,7 @@ async function token() {{ // (#1802). A failed refresh means the sign-in itself is dead: mark it // terminal rather than looping. try {{ - if (window.parent === window) return null; - const root = window.parent.document.querySelector("home-assistant"); + const root = homeAssistantRoot(); const auth = root && root.hass && root.hass.auth; if (!auth) return null; if (auth.expired && typeof auth.refreshAccessToken === "function") {{ @@ -615,9 +678,10 @@ async function mint() {{ async function showApp() {{ // Probe the proxy so a not-yet-running server shows a friendly message // instead of a raw 503 page inside the iframe. + const targetUrl = appUrl(); let probe; try {{ - probe = await fetchWithTimeout(APP_URL, {{ credentials: "same-origin" }}); + probe = await fetchWithTimeout(targetUrl, {{ credentials: "same-origin" }}); }} catch (err) {{ transientFailure("Could not reach Home Assistant to load the settings UI."); return; @@ -636,8 +700,8 @@ async function showApp() {{ transientFailure("The settings UI returned HTTP " + probe.status + "."); return; }} - if (frame.getAttribute("src") !== APP_URL) {{ - frame.setAttribute("src", APP_URL); + if (frame.getAttribute("src") !== targetUrl) {{ + frame.setAttribute("src", targetUrl); }} msg.classList.add("hidden"); frame.classList.remove("hidden"); diff --git a/custom_components/ha_mcp_tools/update.py b/custom_components/ha_mcp_tools/update.py index 8b6e916..cb6f0b3 100644 --- a/custom_components/ha_mcp_tools/update.py +++ b/custom_components/ha_mcp_tools/update.py @@ -29,8 +29,9 @@ from .const import ( DOMAIN, OPT_AUTO_UPDATE, ) -from .coordinator import ServerVersionCoordinator +from .coordinator import ServerVersionCoordinator, ServerVersionInfo from .embedded_server import _installed_dist_version +from .embedded_setup import _async_update_held_by_component if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -46,6 +47,19 @@ _RELEASES_URL = ( ) _RELEASE_NOTES_TIMEOUT_SECONDS = 15 +# Prepended to the release notes while the automatic server update is HELD on a +# newer custom component (embedded_setup's auto-update gate). ``ha-alert`` renders +# as a prominent banner in Home Assistant's markdown; ``{shipped}`` is the +# component version the release ships, ``{running}`` the one currently installed. +_COMPONENT_HOLD_WARNING = ( + '\n' + "This release also updates the HA-MCP Custom Component (to {shipped}; you " + "are running {running}). Update the component in HACS first — installing " + "this server update now runs a server build the HACS component has never " + "been tested with.\n" + "" +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -120,17 +134,77 @@ class ServerUpdateEntity(CoordinatorEntity[ServerVersionCoordinator], UpdateEnti return features async def async_release_notes(self) -> str | None: - """Concatenate GitHub release bodies between installed and latest. + """Return the GitHub release notes, with a component-update warning + prepended when the pending update is held on a component update. + + When the newer server release also ships a newer custom component than + the one running, the auto-update gate HOLDS the install (see + embedded_setup._async_update_held_by_component), so the dialog leads + with a prominent warning to update the component in HACS first. That + warning must survive even a failed or empty notes fetch — surfacing it + is the whole point of opening a held update's dialog — so a held update + returns the warning alone rather than None. When not held, behaviour is + unchanged. Advisory-only (same reasoning as embedded_setup's _async_check_component_compat): a GitHub fetch failure, rate limit, or - unexpected payload shape must degrade to None - the UI then falls back - to :attr:`release_url` - rather than break the update dialog. + unexpected payload shape degrades to the :attr:`release_url` fallback + rather than breaking the update dialog. """ data = self.coordinator.data if data is None or data.installed is None or data.latest is None: return None + # Both probes are advisory network calls that contain their own + # failures and timeouts; run them concurrently so the dialog waits for + # the slower of the two, not their sum — a blocked/slow manifest host + # must not stall the ordinary notes fetch (review finding). + warning, notes = await asyncio.gather( + self._async_component_hold_warning(data), + self._async_fetch_release_notes(data), + ) + + if warning is None: + # Not held: exactly the pre-existing behaviour (the notes, or None + # on any failure/empty — the UI then falls back to release_url). + return notes + # Held: the warning must always surface, even when the notes fetch + # failed or returned nothing — it must never vanish with the notes. + if notes is None: + return warning + return f"{warning}\n\n{notes}" + + async def _async_component_hold_warning( + self, data: ServerVersionInfo + ) -> str | None: + """Return the markdown component-hold warning, or None when not held. + + Reuses the auto-update gate's own held-check so this dialog and the + Repairs hold agree on when the component is behind. Fully advisory: any + failure — including an unexpected error escaping the gate — degrades to + None so the hold warning can never break the release-notes dialog. + """ + try: + held = await _async_update_held_by_component(self.hass, data) + except Exception: + # The gate contains all its expected failures internally, so an + # exception escaping it is a bug — logged visibly per the repo's + # convention (review finding), still degrading to plain notes. + _LOGGER.warning( + "HA-MCP release-notes component-hold check failed", exc_info=True + ) + return None + if held is None: + return None + shipped, running = held + return _COMPONENT_HOLD_WARNING.format(shipped=shipped, running=running) + + async def _async_fetch_release_notes(self, data: ServerVersionInfo) -> str | None: + """Concatenate GitHub release bodies between installed and latest. + + Advisory-only: a GitHub fetch failure, rate limit, or unexpected payload + shape degrades to None so the dialog falls back to :attr:`release_url`. + """ try: installed = AwesomeVersion(data.installed) latest = AwesomeVersion(data.latest) diff --git a/custom_components/ha_mcp_tools/websocket_api.py b/custom_components/ha_mcp_tools/websocket_api.py index 49f6bf4..0457c63 100644 --- a/custom_components/ha_mcp_tools/websocket_api.py +++ b/custom_components/ha_mcp_tools/websocket_api.py @@ -2,11 +2,15 @@ This module registers versioned ``ha_mcp_tools/*`` WebSocket commands that the ha-mcp server calls in-process (same HA core, no REST/WS round-trips) behind a -capability gate. v1.1.0 ships four commands (three capabilities): +capability gate. It registers twenty-two commands (twenty-two capabilities — the +``search_visibility`` capability is a flag on the existing ``search`` command, +and ``info`` itself carries no capability entry): * ``ha_mcp_tools/info`` — the handshake: ``schema_version`` + ``capabilities[]`` - + ``component_version`` + advisory ``limits``. One cached probe tells the - server which commands are live (capability negotiation, NOT a version floor). + + ``component_version`` + advisory ``limits`` + the instance ``timezone`` + (an additive field consumers detect by presence — no capability entry). One + cached probe tells the server which commands are live (capability + negotiation, NOT a version floor). * ``ha_mcp_tools/search`` — a unified in-process search over live registries and states, joined and scored, mirroring today's ``ha_search`` response envelope. * ``ha_mcp_tools/overview`` — the raw in-process reads the server's @@ -23,6 +27,198 @@ capability gate. v1.1.0 ships four commands (three capabilities): enumerated, so the server falls back to its legacy ``/list`` path for an uncovered type (e.g. ``tag``, which has no state entity) instead of trusting an empty result. +* ``ha_mcp_tools/states`` — a bulk state read: ``State.as_dict()`` for each + requested entity_id (a pure ``hass.states.get`` in-memory read) plus the list + of ids with no state, so the server's ``ha_get_state`` serves a 100-entity + bulk call from one in-process frame instead of up to 100 REST GETs. The body + is byte-identical to the REST ``/api/states/`` serialization by + construction; the server maps found/missing onto its per-id error contract. +* ``ha_mcp_tools/blueprint_get`` — the full body of one installed blueprint + (``{metadata, config}``), which core's ``blueprint/list`` never returns (it + serves only ``{metadata}``). The path is jailed under + ``/blueprints//`` (symlink-safe containment, mirroring the + file-tool jail) and the file read + parse run off the event loop in the async + prep. ``!input`` markers are preserved; every other custom tag (``!secret`` / + ``!include`` / …) is neutralized to ``None`` at load time, so no resolved + secret plaintext can ever reach the body. +* ``ha_mcp_tools/device_get`` — one device registry entry by id + (``{device: | None}``), so a single-device lookup no + longer pulls the entire device registry. The body is core's + ``DeviceEntry.dict_repr`` returned VERBATIM — byte-identical to one element of + ``config/device_registry/list`` (which sends ``json_bytes(entry.dict_repr)``) + by construction, since this command's ``connection.send_result`` runs the same + JSON encoder over the same dict. Consumers keep their own transforms over the + raw shape; ``device`` is ``None`` when no such device exists. With + ``include_entities`` set, a sibling ``entities`` key carries the device's + entity-registry rows (``RegistryEntry.as_partial_dict``, the + ``config/entity_registry/list`` shape, disabled entities included) so listing a + device's entities no longer pulls the whole entity registry either — the raw + DeviceEntry stays untouched; the join is a sibling. +* ``ha_mcp_tools/device_list`` — every device registry entry as that same raw + ``DeviceEntry.dict_repr`` shape (``{devices: [...]}``): the in-process + equivalent of ``config/device_registry/list`` served through the component + seam, so ``ha_get_device`` list mode need not mix a legacy WS read with the + component path. +* ``ha_mcp_tools/entity_enrich`` — the area/floor/labels/aliases join for a set + of entity_ids (``{entities: {id: {area, floor, labels, aliases}}}``), computed + by the SAME ``_entity_record``/``_RegistryView`` registry join the search path + uses (device-inherited area/labels included). Lets ``ha_get_entity`` add the + resolved-name enrichment fields the raw registry entry lacks (it carries + ``area_id`` / label *ids*, not resolved names) without the caller fanning out + its own area/floor/label registry reads. Registry-only entities (no state) are + enriched too — the join keys off the registry, not the state machine. +* ``ha_mcp_tools/exposure`` — voice-assistant exposure with names/areas attached. + List mode mirrors core's ``ws_list_exposed_entities`` (``{exposed_entities: + {id: {assistant: True}}}`` — byte-identical to ``homeassistant/expose_entity/ + list``); single-entity mode reads core's module-level + ``async_get_entity_settings``. Both add a sibling ``entity_info`` map enriching + each id through the same registry join (friendly_name/domain/area/floor/labels), + so the server no longer needs a second search + manual correlation to name an + exposed entity. Three parity guardrails hold: only ``should_expose``-true + assistants are reported (the raw helper is not pre-filtered like the legacy + shape); core's ``HomeAssistantError("Unknown entity")`` on a junk id degrades to + the not-exposed default (the legacy path never raises on junk); and a missing + ``hass.states.get(id)`` omits the live-state fields (friendly_name/state) rather + than crashing. +* ``ha_mcp_tools/dashboards`` — Lovelace dashboards read in-process, three modes. + ``list`` mirrors the ``lovelace/dashboards/list`` row shape (id/url_path/title/ + icon/show_in_sidebar/require_admin) with an additive per-row ``mode`` so the + server can exclude YAML dashboards; ``get`` returns one dashboard's config body + (``await store.async_load`` in the async prep) with a structured + ``yaml_excluded`` status for YAML-mode dashboards (the server falls back to + legacy for those — a YAML body may carry resolved ``!secret`` plaintext); + ``search`` walks every STORAGE dashboard's views/cards/sections (plus view-level + badges and sections-view header cards) for a query substring (capped at 200 with a + ``truncated`` flag). YAML-dashboard bodies are never emitted — storage-only. All + Store loads run in :func:`_dashboards_prep`. +* ``ha_mcp_tools/services_list`` — the REST ``/api/services`` service catalog + (``async_get_all_descriptions``) joined with the ``services`` backend + translations (``async_get_translations``), both loaded in the async prep. + Filtered by ``domain`` (exact) only; the server re-runs its exact query filter + + pagination over the payload. No ``query`` coarse-filter (a per-service superset of + the server's concatenation-based filter cannot be built cheaply, and no consumer + forwards ``query``). +* ``ha_mcp_tools/reference_data`` — the service index + entity-id universe the + config-reference validator consumes: ``{services: [{domain, services:{name:{}}}], + entity_ids: [...]}``. The ``services`` shape is the REST ``/api/services`` list + ``build_service_index`` reads (bodies are empty dicts — the index only reads + keys); ``entity_ids`` is every ``hass.states.async_all()`` id. Pure, no prep. +* ``ha_mcp_tools/search`` (``search_visibility`` capability) — the search command + additionally accepts a raw ``visibility`` config dict; when present it excludes + the server's opt-in hidden entities before counts/pagination (the pure + :func:`_visibility_hidden_set` mirrors the server's ``hidden_entity_ids``, with + the Assist dimension delegated to core's ``async_should_expose``), so + ``ha_search`` can route through the component even with an active filter. A + degraded dimension (unknown ``exclude_category`` / empty-registry allowlist / + unavailable Assist) fails open, and :func:`_visibility_warnings` returns the + resolver-parity ``visibility_warnings`` the response carries so the filtering is + not silently incomplete. +* ``ha_mcp_tools/server_entry`` — the component locates its OWN server config + entry (``entry.data[CONF_ENTRY_TYPE] == server``, the one marker key it reads + from ``entry.data``) and returns ``{entry_id, channel, pip_spec}`` (channel / + pip_spec from ``entry.options``), so ``ha_dev_manage_server`` need not probe + every ``ha_mcp_tools``-domain entry's options-flow schema from the outside. +* ``ha_mcp_tools/server_entry_update`` — the WRITE counterpart of ``server_entry``: + applies a ``channel`` / ``pip_spec`` delta to the server entry via + ``hass.config_entries.async_update_entry`` DIRECTLY (what core's finish-flow + does), collapsing ``ha_dev_manage_server(update_source)``'s options-flow start + + submit round-trip in embedded mode. The delta is MERGED against the LIVE + ``entry.options`` AT APPLY time (every existing key preserved — a concurrent + change to another key during the flush window is not clobbered — no blanking of + the URL/secret overrides), so no preserved-key resend is needed. The + ``async_update_entry`` fires the entry's own update listener → reload → the + hardened reinstall; because that reload tears down the very server thread + answering this frame, the call is NOT made inline — it is scheduled on a + HASS-level background task (:func:`hass.async_create_background_task`, NOT the + entry's, so the reload can't cancel it) after :data:`SERVER_ENTRY_UPDATE_FLUSH_DELAY_S`, + and the prep returns ``{scheduled: True, ...}`` immediately so the WS response + flushes first. A no-op (merged options equal the current ones) returns + ``{scheduled: False, unchanged: True, ...}`` without scheduling; no server entry + raises ``HomeAssistantError`` (→ the server's command-error fallback to its legacy + options-flow path). All awaiting-adjacent work (the deferred apply) lives in + :func:`_server_entry_update_prep`; :func:`_do_server_entry_update` is a pure formatter. +* ``ha_mcp_tools/call_service`` — the FIRST write capability. Fires exactly one + ``hass.services.async_call`` in-process and returns the REAL pre→post state + transition for the target ``entity_ids`` — event-confirmed via an + ``EVENT_STATE_CHANGED`` listener registered BEFORE the dispatch (closing the + fast-entity race). The server's optional ``expected_state`` hint governs only WHEN + the confirming state is settled — the waiter confirms on reaching it (skipping a + multi-phase service's intermediate states and attribute-only noise) and immediate- + matches an idempotent no-op — but the RETURNED transition is always the real + observed one, never the hint. All awaiting work (the dispatch, the immediate-match, + the bounded confirmation wait) runs in :func:`_call_service_prep`; + :func:`_do_call_service` is a pure formatter. An AUTHORITATIVE component-side + domain block refuses ``domain == "ha_mcp_tools"`` (case/whitespace-normalized) + BEFORE any ``has_service``/dispatch, independent of (and in addition to) the + server-side guard — so this second write path can NEVER be turned into an + in-process invoker of the admin-gated ``ha_mcp_tools.*`` services + (``get_caller_token`` → arbitrary config-dir file/YAML writes). A confirmation + timeout is reported as ``partial`` (``success`` still holds); a failure BEFORE + the dispatch raises, a failure AFTER it does not (the call already landed). +* ``ha_mcp_tools/bulk_call_service`` — the BATCH write capability (D5a). One frame + runs the D1 ``ha_mcp_tools`` domain block for EVERY operation FIRST, before any + dispatch or listener: a batch is fail-closed — one refused op raises the whole + frame and NOTHING dispatches, so no partial batch can smuggle a + ``ha_mcp_tools.*`` (or unknown-service) op past the guard. It then registers ALL + confirmation listeners in one synchronous pass BEFORE any dispatch + (register-before-fire is trivially correct for the batch), fires the operations + (``parallel`` by default, or sequentially), and waits on ONE shared deadline for + every op's transition. A per-op ``async_call`` failure under ``parallel`` is + captured on that op's result (``error`` + ``dispatched: false``) WITHOUT aborting + the others; a post-dispatch confirmation timeout is ``partial``, never a failure. + All awaiting work lives in :func:`_bulk_call_service_prep`; + :func:`_do_bulk_call_service` is a pure formatter that reuses the single + ``call_service`` guard / transition / diff helpers. + +* ``ha_mcp_tools/config_entries`` — config entries as the ``config_entries/get`` + WS shape (``created_at`` / ``modified_at`` / ``entry_id`` / ``domain`` / + ``title`` / ``state`` / ``source`` / ``supports_*`` / ``supported_subentry_types`` + / ``pref_disable_*`` / ``disabled_by`` / ``reason`` / ``options`` / + ``subentries`` — the full ``as_json_fragment`` field set), filtered by ``domain`` + or fetched by + ``entry_id``. ``state`` is serialized as ``ConfigEntryState.value`` (mirroring + core's ``as_json_fragment``). ``entry.data`` (integration credentials) is + NEVER read; ``options`` is passed through a resolved-``!secret`` scrub (an + options leaf equal to a ``secrets.yaml`` value becomes ``"**redacted**"``, + loaded off the loop by :func:`_config_entries_prep`) — data minimization + parity with the flow-helper indexing. +* ``ha_mcp_tools/registry_lookup`` — entity-registry rows + (``RegistryEntry.as_partial_dict``, the ``config/entity_registry/list`` shape, + disabled entities included) for either a set of ``entity_ids`` (missing ids in + a sibling ``missing`` list) or ALL entities bound to a ``config_entry_id``. The + config-entry scan returns EVERY match — it does not reuse the single-valued + ``_entities_by_config_entry`` index, so a multi-entity flow helper + (utility_meter + its tariffs) does not silently lose its sub-entities. Exactly + one of the two is required; a request with NEITHER raises + ``HomeAssistantError`` rather than silently returning an empty result. +* ``ha_mcp_tools/system_snapshot`` — one consistent synchronous pass over the + live objects the health path reads: ``config_entries`` (identity fields only — + no options/subentries), ``issues`` (the ``_overview_repairs`` slice), + ``entities`` (the ``registry_lookup`` row shape), ``states`` + (``State.as_dict()``). ``include_*`` flags gate each section. Reading them in a + single frame kills the 3x ``config_entries/get`` TOCTOU the server had. +* ``ha_mcp_tools/entity_lookup`` — registry entries whose ``unique_id`` matches + (optionally narrowed by ``domain`` / ``platform``), returned as + ``{matches: [{entity_id, unique_id, platform, domain, config_entry_id, + categories, disabled_by, hidden_by}]}``. Multiple matches across platforms are + all returned; the server picks. The in-process read is authoritative + immediately (no registry-write settle retry). +* ``ha_mcp_tools/backup_prep`` — the backup identity the server needs before a + create: ``{agent_ids, local_agent_id, default_password}`` read from the backup + integration's in-process ``DATA_MANAGER``. ``local_agent_id`` uses the SAME + preference the server's ``_get_local_backup_agent_id`` does (``hassio.local`` + over ``backup.local``). A missing backup integration / manager raises + ``HomeAssistantError`` so the server's command-error fallback fires. The + password is sensitive but the legacy ``backup/config/info`` already serves it + to the same admin connection — parity, not new exposure. +* ``ha_mcp_tools/registries`` — the area / floor / label / category registries as + the FULL-FIELD ``config/_registry/list`` shapes (byte-compatible with the + legacy WS list responses; timestamps as ``created_at`` / ``modified_at`` + floats via ``.timestamp()``). Only the requested ``registries`` keys are + present; ``category`` REQUIRES a non-empty ``category_scopes`` (categories are + scoped) — a ``category`` request without one raises ``HomeAssistantError`` + rather than silently serving ``{}``. ``category_registry`` is imported + function-locally (not needed at module top). ``ha_mcp_tools/config_get`` was withdrawn before release: it served an entity's ``raw_config``, whose freshness lags the config file between a write and the next @@ -69,6 +265,7 @@ import re from collections.abc import Mapping from dataclasses import dataclass from difflib import SequenceMatcher +from pathlib import Path from typing import Any import voluptuous as vol @@ -94,7 +291,17 @@ from homeassistant.helpers import ( label_registry as lr, ) -from .const import COMPONENT_VERSION +from .const import ( + CHANNEL_DEV, + CHANNEL_STABLE, + COMPONENT_VERSION, + CONF_ENTRY_TYPE, + DEFAULT_PIP_SPEC, + DOMAIN, + ENTRY_TYPE_SERVER, + OPT_CHANNEL, + OPT_PIP_SPEC, +) _LOGGER = logging.getLogger(__name__) @@ -104,6 +311,25 @@ WS_INFO = f"{WS_API_PREFIX}/info" WS_SEARCH = f"{WS_API_PREFIX}/search" WS_OVERVIEW = f"{WS_API_PREFIX}/overview" WS_HELPERS_LIST = f"{WS_API_PREFIX}/helpers_list" +WS_STATES = f"{WS_API_PREFIX}/states" +WS_BLUEPRINT_GET = f"{WS_API_PREFIX}/blueprint_get" +WS_DEVICE_GET = f"{WS_API_PREFIX}/device_get" +WS_DEVICE_LIST = f"{WS_API_PREFIX}/device_list" +WS_ENTITY_ENRICH = f"{WS_API_PREFIX}/entity_enrich" +WS_EXPOSURE = f"{WS_API_PREFIX}/exposure" +WS_CONFIG_ENTRIES = f"{WS_API_PREFIX}/config_entries" +WS_REGISTRY_LOOKUP = f"{WS_API_PREFIX}/registry_lookup" +WS_SYSTEM_SNAPSHOT = f"{WS_API_PREFIX}/system_snapshot" +WS_ENTITY_LOOKUP = f"{WS_API_PREFIX}/entity_lookup" +WS_BACKUP_PREP = f"{WS_API_PREFIX}/backup_prep" +WS_REGISTRIES = f"{WS_API_PREFIX}/registries" +WS_DASHBOARDS = f"{WS_API_PREFIX}/dashboards" +WS_SERVICES_LIST = f"{WS_API_PREFIX}/services_list" +WS_REFERENCE_DATA = f"{WS_API_PREFIX}/reference_data" +WS_SERVER_ENTRY = f"{WS_API_PREFIX}/server_entry" +WS_SERVER_ENTRY_UPDATE = f"{WS_API_PREFIX}/server_entry_update" +WS_CALL_SERVICE = f"{WS_API_PREFIX}/call_service" +WS_BULK_CALL_SERVICE = f"{WS_API_PREFIX}/bulk_call_service" # Wire-format generation of the request/response envelopes. Bumped only on an # *incompatible* shape change to an existing command; additive fields do not @@ -112,9 +338,57 @@ SCHEMA_VERSION = 1 # Which commands exist. Grows one entry per shipped command; the server gates # each consumer on ``capability in caps.capabilities``. Never remove an entry -# without a major bump. (``info`` is always present in 1.1.0, so it carries no +# without a major bump. (``info`` is always present in 1.1.0+, so it carries no # capability key of its own.) -CAPABILITIES: list[str] = ["search", "overview", "helpers_list"] +CAPABILITIES: list[str] = [ + "search", + "overview", + "helpers_list", + "states", + "blueprint_get", + "device_get", + "device_list", + "entity_enrich", + "exposure", + "config_entries", + "registry_lookup", + "system_snapshot", + "entity_lookup", + "backup_prep", + "registries", + "dashboards", + "services_list", + "reference_data", + # A flag, not a standalone command: gates the optional ``visibility`` param + # the server may pass to ``ha_mcp_tools/search`` so an old component that + # would ignore the param is never sent it (param-sniffing is banned for + # routing; the CAPABILITIES flag is what the server gates on). + "search_visibility", + "server_entry", + # The WRITE counterpart of ``server_entry`` (Phase 3). The server gates its + # ``ha_dev_manage_server(update_source)`` embedded-mode direct-write on this; an + # old component that lacks it is never sent the frame and the server stays on its + # legacy options-flow submit. + "server_entry_update", + # The first WRITE capability (Phase 3). The server gates its ``ha_call_service`` + # component route on this; an old component that lacks it is never sent a + # component write and stays on the legacy REST path. + "call_service", + # The BATCH write capability (Phase 3, D5a). The server gates its bulk-control + # component route on this; a component that lacks it is never sent a batch + # write and stays on the legacy per-entity path. + "bulk_call_service", +] + +# The registry kinds ``ha_mcp_tools/registries`` can serve. The WS schema gates +# on this so an out-of-range kind never reaches the reader; ``category`` also +# requires ``category_scopes`` (categories are scoped). +REGISTRY_KINDS = ("area", "floor", "label", "category") + +# Blueprint domains this component will read a body for. Mirrors core's blueprint +# domains; the WS schema gates on it so an out-of-range domain never reaches the +# path jail. Kept next to the blueprint command it governs. +BLUEPRINT_DOMAINS = ("automation", "script") # Advisory caps advertised in ``info.limits`` so no single WS frame balloons. MAX_RESULTS = 500 @@ -123,6 +397,26 @@ LIMITS = {"max_results": MAX_RESULTS, "max_body_bytes": MAX_BODY_BYTES} DEFAULT_LIMIT = 10 +# ``call_service`` confirmation-wait bounds. The default mirrors the legacy +# ``ha_call_service`` 10s subscribe-and-sample window; the cap bounds a +# caller-supplied ``timeout`` (schema ``vol.Range``) so a single write frame can +# never park the WS connection for longer than this. ``blocking=True`` only means +# HA finished DISPATCHING — a mesh device (Zigbee/Z-Wave) may still be settling — +# so the wait is bounded and its expiry is ``partial``, never a failure (D4). +CALL_SERVICE_DEFAULT_TIMEOUT = 10.0 +CALL_SERVICE_MAX_TIMEOUT = 60.0 + +# Delay before ``server_entry_update``'s deferred ``async_update_entry`` fires, so +# the WS ``{scheduled: True}`` response flushes to the calling (embedded) server +# BEFORE the resulting entry reload tears that server's thread down. Mirrors the +# server side's ``tools_dev._SELF_ACTION_FLUSH_DELAY_S`` (its legacy options-flow +# self-restart uses the same headroom for the ingress/webhook hop). +SERVER_ENTRY_UPDATE_FLUSH_DELAY_S = 1.0 + +# pip_spec defence-in-depth cap (the SERVER already validates per D6): a single-line +# requirement string under this many chars. Mirrors ``tools_dev._update_source``. +SERVER_ENTRY_UPDATE_MAX_PIP_SPEC = 500 + # Fuzzy floor + hidden penalty, mirrored from the server so the two scorers do # not drift (guarded by the golden parity test). FUZZY_THRESHOLD = 70 @@ -248,10 +542,47 @@ def _command_specs() -> list[tuple[dict[Any, Any], Any, Any]]: ``_do_*`` function a pure, synchronous in-memory read. """ return [ - (_info_schema(), lambda hass, msg: _do_info(), None), + (_info_schema(), lambda hass, msg: _do_info(hass), None), (_search_schema(), _do_search, _search_prep), (_overview_schema(), _do_overview, None), - (_helpers_list_schema(), _do_helpers_list, None), + (_helpers_list_schema(), _do_helpers_list, _helpers_list_prep), + (_states_schema(), _do_states, None), + (_blueprint_get_schema(), _do_blueprint_get, _blueprint_get_prep), + (_device_get_schema(), _do_device_get, None), + (_device_list_schema(), _do_device_list, None), + (_entity_enrich_schema(), _do_entity_enrich, None), + (_exposure_schema(), _do_exposure, None), + (_config_entries_schema(), _do_config_entries, _config_entries_prep), + (_registry_lookup_schema(), _do_registry_lookup, None), + (_system_snapshot_schema(), _do_system_snapshot, None), + (_entity_lookup_schema(), _do_entity_lookup, None), + (_backup_prep_schema(), _do_backup_prep, None), + (_registries_schema(), _do_registries, None), + (_dashboards_schema(), _do_dashboards, _dashboards_prep), + (_services_list_schema(), _do_services_list, _services_list_prep), + (_reference_data_schema(), _do_reference_data, None), + (_server_entry_schema(), _do_server_entry, None), + # The WRITE counterpart of server_entry: the deferred ``async_update_entry`` + # scheduling is inherently async, so it lives in ``_server_entry_update_prep`` + # and ``_do_server_entry_update`` is a pure formatter (same seam as the + # call_service write below). + ( + _server_entry_update_schema(), + _do_server_entry_update, + _server_entry_update_prep, + ), + # The first WRITE command: the dispatch + the bounded confirmation wait are + # inherently async, so ALL of the work lives in the ``_call_service_prep`` + # async pre-step and ``_do_call_service`` is a pure response formatter. + (_call_service_schema(), _do_call_service, _call_service_prep), + # The BATCH write command (D5a): the same async seam — all guards, the + # register-before-fire pass, the dispatches, and the bounded wait live in + # ``_bulk_call_service_prep``; ``_do_bulk_call_service`` is a pure formatter. + ( + _bulk_call_service_schema(), + _do_bulk_call_service, + _bulk_call_service_prep, + ), ] @@ -278,6 +609,39 @@ def _info_schema() -> dict[Any, Any]: return {vol.Required("type"): WS_INFO} +# The nine hide dimensions ``VisibilityConfig.to_wire`` emits, split by wire type +# (seven id/name lists, two bool flags). Kept in lockstep with the server's +# ``to_wire`` and :func:`_visibility_hidden_set`; a new dimension is a new component +# capability, added on BOTH sides (see :func:`_visibility_param_schema`). The union +# is pinned equal to the server resolver's key set by the cross-seam contract test. +_VISIBILITY_LIST_KEYS = ( + "exclude_categories", + "deny_entity_ids", + "exclude_areas", + "exclude_labels", + "allow_entity_ids", + "allow_areas", + "allow_labels", +) +_VISIBILITY_BOOL_KEYS = ("exclude_hidden", "respect_assist_exposure") + + +def _visibility_param_schema() -> Any: + """Voluptuous schema for the ``search`` ``visibility`` dict — exactly the nine keys. + + Enumerating the known dimensions (PREVENT_EXTRA is voluptuous' default for a + nested ``Schema``) makes an unknown key a loud ``invalid_format`` command error + rather than a silent drop. If a newer server emits a tenth dimension to this + 1.2.0 component, the server's error taxonomy converts that into a legacy fallback + with the filter STILL correctly applied — structural fail-closed for free — + instead of partial, unwarned filtering. Built at call time so it honors the + monkeypatched ``vol`` in the unit suite. + """ + schema: dict[Any, Any] = {vol.Optional(key): [str] for key in _VISIBILITY_LIST_KEYS} + schema.update({vol.Optional(key): bool for key in _VISIBILITY_BOOL_KEYS}) + return vol.Schema(schema) + + def _search_schema() -> dict[Any, Any]: return { vol.Required("type"): WS_SEARCH, @@ -293,6 +657,16 @@ def _search_schema() -> dict[Any, Any]: int, vol.Range(min=1, max=MAX_RESULTS) ), vol.Optional("offset", default=0): vol.All(int, vol.Range(min=0)), + # Opt-in entity-visibility filter (``search_visibility`` capability): the + # server's raw VisibilityConfig hide dimensions. When present and + # non-empty, hidden entities are excluded from the entity results before + # counts/pagination, mirroring the legacy ``load_hidden_set`` hard-exclude + # so ``ha_search`` can drop its "filter active -> legacy only" gate. Gated + # by the CAPABILITIES flag, so an old component never receives it. The nine + # known keys are enumerated so an unknown dimension fails loudly (the server + # then falls back to legacy with the filter applied) — see + # :func:`_visibility_param_schema`. + vol.Optional("visibility"): _visibility_param_schema(), } @@ -312,19 +686,240 @@ def _helpers_list_schema() -> dict[Any, Any]: } +def _states_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_STATES, + vol.Required("entity_ids"): [str], + } + + +def _blueprint_get_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_BLUEPRINT_GET, + vol.Required("domain"): vol.In(BLUEPRINT_DOMAINS), + vol.Required("path"): str, + } + + +def _device_get_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_DEVICE_GET, + vol.Required("device_id"): str, + vol.Optional("include_entities", default=False): bool, + } + + +def _device_list_schema() -> dict[Any, Any]: + return {vol.Required("type"): WS_DEVICE_LIST} + + +def _entity_enrich_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_ENTITY_ENRICH, + vol.Required("entity_ids"): [str], + } + + +def _exposure_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_EXPOSURE, + vol.Optional("entity_id"): vol.Any(str, None), + } + + +def _config_entries_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_CONFIG_ENTRIES, + vol.Optional("entry_id"): vol.Any(str, None), + vol.Optional("domain"): vol.Any(str, None), + } + + +def _registry_lookup_schema() -> dict[Any, Any]: + # Exactly one of entity_ids / config_entry_id is meaningful; ``vol.Exclusive`` + # rejects a request carrying BOTH (the two share the ``target`` group). + # Voluptuous has no clean way to express "at least one of" in a flat schema, + # so a request with NEITHER present is caught in ``_do_registry_lookup`` + # instead (raises ``HomeAssistantError`` rather than a silent empty result). + return { + vol.Required("type"): WS_REGISTRY_LOOKUP, + vol.Exclusive("entity_ids", "target"): [str], + vol.Exclusive("config_entry_id", "target"): str, + } + + +def _system_snapshot_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_SYSTEM_SNAPSHOT, + vol.Optional("include_states", default=True): bool, + vol.Optional("include_entities", default=True): bool, + vol.Optional("include_issues", default=True): bool, + vol.Optional("include_config_entries", default=True): bool, + } + + +def _entity_lookup_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_ENTITY_LOOKUP, + vol.Required("unique_id"): str, + vol.Optional("domain"): vol.Any(str, None), + vol.Optional("platform"): vol.Any(str, None), + } + + +def _backup_prep_schema() -> dict[Any, Any]: + return {vol.Required("type"): WS_BACKUP_PREP} + + +def _registries_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_REGISTRIES, + vol.Required("registries"): [vol.In(REGISTRY_KINDS)], + vol.Optional("category_scopes"): [str], + } + + +def _dashboards_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_DASHBOARDS, + vol.Optional("mode", default="list"): vol.In(("list", "get", "search")), + # ``None``/absent url_path = the default dashboard (``get`` mode). + vol.Optional("url_path"): vol.Any(str, None), + vol.Optional("query"): vol.Any(str, None), + } + + +def _services_list_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_SERVICES_LIST, + vol.Optional("domain"): vol.Any(str, None), + vol.Optional("language", default="en"): str, + } + + +def _reference_data_schema() -> dict[Any, Any]: + return { + vol.Required("type"): WS_REFERENCE_DATA, + vol.Optional("include_states", default=True): bool, + } + + +def _server_entry_schema() -> dict[Any, Any]: + return {vol.Required("type"): WS_SERVER_ENTRY} + + +def _single_line_pip_spec(value: str) -> str: + """Reject a multi-line / control-char ``pip_spec`` (defence-in-depth over D6).""" + if any(ord(c) < 32 for c in value): + raise vol.Invalid("pip_spec must be a single-line string") + return value + + +def _server_entry_update_schema() -> dict[Any, Any]: + # Both fields are optional at the schema level (voluptuous cannot cleanly express + # "at least one of"); ``_server_entry_update_prep`` raises when NEITHER is present. + # ``channel`` is gated to the known set and ``pip_spec`` gets the length + + # single-line cap — schema-level defence-in-depth over (and symmetric with) the + # server's own channel validation (D6) and pip-spec normalization. + return { + vol.Required("type"): WS_SERVER_ENTRY_UPDATE, + vol.Optional("channel"): vol.In((CHANNEL_STABLE, CHANNEL_DEV)), + vol.Optional("pip_spec"): vol.All( + str, + vol.Length(max=SERVER_ENTRY_UPDATE_MAX_PIP_SPEC), + _single_line_pip_spec, + ), + } + + +def _call_service_schema() -> dict[Any, Any]: + # ``entity_ids`` is the set of targets to CONFIRM (the pre/post transition is + # built for these), not the service target itself — a caller may pass an empty + # list for a non-entity service (``automation.trigger`` etc.) and still get the + # dispatch result. ``timeout`` is capped at ``CALL_SERVICE_MAX_TIMEOUT`` so a + # single write frame cannot park the connection. Mutable defaults use the + # callable form so each validation produces a fresh ``{}`` / ``[]``. + return { + vol.Required("type"): WS_CALL_SERVICE, + vol.Required("domain"): str, + vol.Required("service"): str, + vol.Optional("service_data", default=dict): dict, + vol.Optional("entity_ids", default=list): [str], + vol.Optional("wait", default=True): bool, + vol.Optional("timeout", default=CALL_SERVICE_DEFAULT_TIMEOUT): vol.All( + vol.Any(int, float), vol.Range(min=0, max=CALL_SERVICE_MAX_TIMEOUT) + ), + vol.Optional("return_response", default=False): bool, + # Optional confirmation HINT (the server's ``_SERVICE_TO_STATE.get(service)``, + # or None): the expected primary state the waiter confirms on REACHING — + # skipping a multi-phase service's intermediate states and attribute-only + # noise — and immediate-matches for an idempotent no-op. Optional/default-None + # so a server that does not send it (or a non-mapped service) keeps today's + # any-first-event confirmation. It governs confirmation TIMING only; the + # returned transition is always the REAL observed one. + vol.Optional("expected_state"): vol.Any(str, None), + } + + +def _bulk_call_service_schema() -> dict[Any, Any]: + # A batch of fully-resolved operations, each the single ``call_service`` row + # minus its own ``wait`` / ``timeout`` / ``return_response`` (those are batch + # scoped: one ``wait`` flag, one shared ``timeout`` deadline, and no per-op + # ``return_response`` — bulk stays simple, the single call covers that need). + # ``operations`` must be non-empty (an empty batch is a caller error, not a + # no-op). ``timeout`` is capped at ``CALL_SERVICE_MAX_TIMEOUT`` so a single + # batch frame cannot park the connection past that bound. Mutable per-op + # defaults use the callable form so each validation yields a fresh ``{}`` / + # ``[]``. + operation = { + vol.Required("domain"): str, + vol.Required("service"): str, + vol.Optional("service_data", default=dict): dict, + vol.Optional("entity_ids", default=list): [str], + # Per-op confirmation HINT (see ``_call_service_schema``): optional/default- + # None so an older server (or a non-mapped service) keeps any-first-event + # confirmation for that op. + vol.Optional("expected_state"): vol.Any(str, None), + } + return { + vol.Required("type"): WS_BULK_CALL_SERVICE, + vol.Required("operations"): vol.All([operation], vol.Length(min=1)), + vol.Optional("parallel", default=True): bool, + vol.Optional("wait", default=True): bool, + vol.Optional("timeout", default=CALL_SERVICE_DEFAULT_TIMEOUT): vol.All( + vol.Any(int, float), vol.Range(min=0, max=CALL_SERVICE_MAX_TIMEOUT) + ), + } + + # ============================================================================= # ha_mcp_tools/info # ============================================================================= -def _do_info() -> dict[str, Any]: - """Return the handshake payload (pure; no hass access).""" +def _do_info(hass: HomeAssistant | None = None) -> dict[str, Any]: + """Return the handshake payload. + + ``timezone`` is an additive field (``hass.config.time_zone``) consumers detect + by presence — it carries NO capability entry and does NOT bump + ``schema_version``. ``hass`` is optional (defaulting to ``None`` so a direct + ``_do_info()`` still works for callers that only need the static handshake); + when absent, ``timezone`` degrades to ``None``. + """ return { "schema_version": SCHEMA_VERSION, "component_version": COMPONENT_VERSION, "capabilities": list(CAPABILITIES), "limits": dict(LIMITS), + "timezone": _config_time_zone(hass), } +def _config_time_zone(hass: HomeAssistant | None) -> str | None: + """``hass.config.time_zone`` as a string, guarded against a hass-less call.""" + config = getattr(hass, "config", None) + tz = getattr(config, "time_zone", None) + return tz if isinstance(tz, str) and tz else None + + # ============================================================================= # ha_mcp_tools/search # ============================================================================= @@ -357,6 +952,31 @@ def _safe(fn: Any, hass: HomeAssistant) -> Any: return None +def _substrate_unavailable(name: str) -> Exception: + """Build a ``HomeAssistantError`` for a drifted / unavailable core substrate. + + A core registry / service / state accessor that RAISED or was renamed comes + back as ``None`` (registries, via ``_safe``) or a non-``Mapping`` + (services / descriptions) from the guarded readers. For the reads whose WHOLE + answer is that substrate — ``entity_lookup``, ``registries``, + ``reference_data``, ``services_list`` — returning a well-formed EMPTY would let + the server trust an authoritative-negative it should instead fall back to legacy + for (mistaking core DRIFT for "no such entry" / "empty catalog"). Raising routes + the server's command-error path to its legacy WS/REST read, mirroring + :func:`_backup_unavailable` / :func:`_registries_missing_category_scopes`. A + genuinely-EMPTY-but-present substrate (a real empty registry) is NOT drift and + keeps returning its empty result — the guards below key off unavailability + (``None`` / non-``Mapping``), never off an empty-but-valid collection. + """ + from homeassistant.exceptions import HomeAssistantError + + err: Exception = HomeAssistantError( + f"ha_mcp_tools: the {name} is unavailable (core drift); the server should " + "fall back to its legacy read" + ) + return err + + def _do_search( hass: HomeAssistant, params: dict[str, Any], @@ -384,10 +1004,18 @@ def _do_search( domain_filter = params.get("domain_filter") area_filter = params.get("area_filter") state_filter = params.get("state_filter") + # Opt-in visibility filter (search_visibility capability). A non-empty dict of + # the server's raw VisibilityConfig fields; applied as a hard entity exclude. + visibility = params.get("visibility") view = _resolve_registries(hass) diagnostics: dict[str, int] = {} partial_reasons: list[str] = [] + # Visibility degradation warnings (unknown category / empty-registry allowlist / + # Assist unavailable), collected in the entity block below when a visibility + # filter is applied. Surfaced additively so the fast path isn't silent about + # incomplete filtering (parity with the server's load_hidden_set warnings). + visibility_warnings: list[str] = [] # ``secret_values`` (loaded off-loop by _search_prep) scrubs resolved-!secret # plaintext from the config-body match corpus: a YAML-loaded automation/script/ @@ -411,6 +1039,35 @@ def _do_search( area_filter=area_filter, state_filter=state_filter, ) + # Opt-in visibility filter: a hard exclude applied BEFORE counts/pagination, + # exactly where the legacy path drops ``visibility_hidden`` entities at the + # top of ``_match_exact_search_entity`` (independent of ``include_hidden``, + # which the ``_search_entities`` join already applied). See + # :func:`_visibility_hidden_set`. + if isinstance(visibility, Mapping) and visibility: + states_list = _iter_states(hass) + # Probe Assist availability once (only when the config asks for it) so a + # requested-but-unavailable Assist dimension both skips its hiding and + # surfaces the resolver-parity degradation warning. + assist_available = ( + _assist_exposure_available(hass) + if visibility.get("respect_assist_exposure") + else True + ) + hidden = _visibility_hidden_set( + view, + states_list, + visibility, + lambda eid: _assist_should_expose(hass, eid), + assist_available=assist_available, + ) + visibility_warnings = _visibility_warnings( + view, states_list, visibility, assist_available=assist_available + ) + if hidden: + scored_entities = [ + r for r in scored_entities if r["entity_id"] not in hidden + ] scored_entities.sort(key=lambda r: (-r["score"], r["entity_id"])) entity_total = len(scored_entities) page = scored_entities[offset : offset + limit] @@ -485,6 +1142,10 @@ def _do_search( } if diagnostics: result["diagnostics"] = diagnostics + # Additive (present only when non-empty, no schema_version bump): the server's + # ha_search consumer merges these into the response's top-level warnings. + if visibility_warnings: + result["visibility_warnings"] = visibility_warnings return result @@ -510,53 +1171,91 @@ async def _search_prep(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str, An return {"secret_values": values} -def _load_secret_values(hass: HomeAssistant) -> frozenset[str]: - """Load the string values from the instance's ``secrets.yaml``. +def _load_secret_scrub(hass: HomeAssistant) -> tuple[frozenset[str], bool]: + """Load the ``secrets.yaml`` scrub values, plus a ``degraded`` flag. - These scrub resolved ``!secret`` plaintext out of the config-body match - corpus: a YAML-loaded automation/script/scene body (or a flow-helper's - options) can carry a secret already resolved to its plaintext value, and - matching inside it would turn ``ha_search`` into a probe oracle — a query - equal to a suspected secret confirmed via ``match_in_config``. Any body leaf - that exactly equals one of these values is dropped before scoring. + Returns ``(values, degraded)``. ``values`` are the plaintext string forms of the + instance's ``secrets.yaml`` scalars; they scrub resolved ``!secret`` plaintext + out of two surfaces: the config-body match corpus (so ``ha_search`` cannot be a + probe oracle — a query equal to a suspected secret confirmed via + ``match_in_config``) and the ``options`` emitted by ``config_entries`` / + ``helpers_list`` (so a resolved secret never leaves the component). - Defensive by design: an absent ``secrets.yaml`` (``FileNotFoundError`` — the - common case) yields an empty set silently; a present-but-unreadable or - malformed file logs one warning and also degrades to an empty set (the scrub - turns OFF but never raises). Only string values are collected — a secret can - be any YAML scalar, but a non-string can't be a plaintext-leak leaf and is - skipped. Loaded off the event loop by :func:`_search_prep` once per search, - never cached across calls, so an edited ``secrets.yaml`` applies on the next - search. ``secrets.yaml`` is a flat ``key: value`` mapping with no custom - tags, so the plain ``yaml.safe_load`` (not HA's ``!secret``/``!include`` - loader) reads it correctly. + Both string AND numeric scalars are collected as their ``str()`` form: an + unquoted ``alarm_code: 1234`` is a YAML int, and a config-entry option can carry + that secret back as an int leaf, so the scrub must be able to match it whether it + arrives as ``1234`` or ``"1234"``. bool scalars are excluded ("True"/"False" are + never credentials and would over-redact). + + ``degraded`` is True ONLY when a ``secrets.yaml`` is PRESENT but could not be + read/parsed: the scrub then silently turns OFF, and a caller emitting options can + surface ``degraded`` so an unredacted response is not mistaken for a cleanly + scrubbed one. An ABSENT ``secrets.yaml`` (the common case) is NOT degraded — + there is simply nothing to scrub. + + Defensive by design — never raises into the WS handler. Loaded off the event loop + by the async preps once per call, never cached, so an edited ``secrets.yaml`` + applies on the next call. ``secrets.yaml`` is a flat ``key: value`` mapping with + no custom tags, so the plain ``yaml.safe_load`` (not HA's ``!secret``/ + ``!include`` loader) reads it correctly. """ config = getattr(hass, "config", None) path_fn = getattr(config, "path", None) if not callable(path_fn): - return frozenset() + return frozenset(), False try: path = path_fn("secrets.yaml") if not path: - return frozenset() + return frozenset(), False with open(path, encoding="utf-8") as handle: raw = yaml.safe_load(handle) except FileNotFoundError: # Expected: many instances have no secrets.yaml — nothing to scrub. - return frozenset() + return frozenset(), False except Exception: - # Present-but-unreadable / malformed / permission error: unexpected, so - # warn once (this runs once per search) to surface a broken scrub, then - # degrade OFF (empty set) rather than raising into the WS handler. + # Present-but-unreadable / malformed / permission error: unexpected, so warn + # once (this runs once per call) AND report degraded so the emission callers + # can signal that options were NOT redacted, rather than raising into the WS + # handler. _LOGGER.warning( - "Could not read secrets.yaml for the search secret-scrub; " - "continuing without it", + "Could not read secrets.yaml for the secret-scrub; continuing WITHOUT " + "redaction (emitted options may be unredacted)", exc_info=True, ) - return frozenset() + return frozenset(), True if not isinstance(raw, dict): - return frozenset() - return frozenset(v for v in raw.values() if isinstance(v, str) and v) + return frozenset(), False + return _collect_secret_strings(raw), False + + +def _collect_secret_strings(raw: dict[Any, Any]) -> frozenset[str]: + """Plaintext ``str()`` forms of ``secrets.yaml`` scalars (str/int/float). + + bool scalars are excluded ("True"/"False" are never credentials and would + over-redact); empty strings are dropped. See :func:`_load_secret_scrub`. + """ + values: set[str] = set() + for v in raw.values(): + if isinstance(v, bool): + continue + if isinstance(v, str): + if v: + values.add(v) + elif isinstance(v, (int, float)): + values.add(str(v)) + return frozenset(values) + + +def _load_secret_values(hass: HomeAssistant) -> frozenset[str]: + """The ``secrets.yaml`` scrub set (see :func:`_load_secret_scrub`); degraded dropped. + + The ``search`` corpus scrub is best-effort and does not surface the degraded + signal (its filtering degrading open is the pre-PR behaviour); the + ``config_entries`` / ``helpers_list`` emission preps call + :func:`_load_secret_scrub` directly so they can surface it. + """ + values, _degraded = _load_secret_scrub(hass) + return values # --- Entity join + scoring --------------------------------------------------- @@ -684,16 +1383,29 @@ def _get_match_type_tier( return "fuzzy_match" -def _entity_record(state: Any, view: _RegistryView) -> dict[str, Any]: - """Join a state with the entity/device/area/floor/label registries.""" - entity_id = getattr(state, "entity_id", "") or "" - domain = entity_id.split(".")[0] if "." in entity_id else "" - attrs = getattr(state, "attributes", None) or {} - friendly = attrs.get("friendly_name", entity_id) +def _registry_enrichment(view: _RegistryView, entity_id: str) -> dict[str, Any]: + """Join one entity_id with the entity/device/area/floor/label registries. + The shared registry read behind BOTH the search record (:func:`_entity_record`) + and the ``entity_enrich`` / ``exposure`` commands, so the area/floor/label-name + resolution lives in exactly one place. Resolves the entity's aliases plus its + area / floor / label NAMES (device-inherited when the entity itself carries + none), keyed off the entity registry — no ``State`` object required, so a + registry-only (stateless) entity is enriched too. Returns the public + enrichment fields (``area`` / ``floor`` / ``labels`` / ``aliases``) alongside + the internal ``_area_id`` / ``_hidden`` / ``_dev_texts`` the scorer consumes. + """ reg = _reg_entity(view, entity_id) + # String entries only: HA core's aliases can carry the COMPUTED_NAME + # sentinel (entity_registry.ComputedNameType._singleton, "the computed + # entity name is an alias"). Blind str() published it as a literal + # "ComputedNameType._singleton" alias on every carrying entity — fake data + # in results AND a scored match_text. The name it stands for is already + # matched via ``friendly``, so dropping the sentinel loses nothing. aliases = ( - sorted(str(a) for a in (getattr(reg, "aliases", None) or [])) if reg else [] + sorted(a for a in (getattr(reg, "aliases", None) or []) if isinstance(a, str)) + if reg + else [] ) area_id = getattr(reg, "area_id", None) if reg else None device_id = getattr(reg, "device_id", None) if reg else None @@ -711,16 +1423,36 @@ def _entity_record(state: Any, view: _RegistryView) -> dict[str, Any]: if val: dev_texts.append(str(val)) - area_name = _area_name(view, area_id) - floor_name = _floor_name_for_area(view, area_id) - label_names = _label_names(view, labels) + return { + "area": _area_name(view, area_id), + "floor": _floor_name_for_area(view, area_id), + "labels": _label_names(view, labels), + "aliases": aliases, + "_area_id": area_id, + "_hidden": hidden, + "_dev_texts": dev_texts, + } + + +def _entity_record(state: Any, view: _RegistryView) -> dict[str, Any]: + """Join a state with the entity/device/area/floor/label registries.""" + entity_id = getattr(state, "entity_id", "") or "" + domain = entity_id.split(".")[0] if "." in entity_id else "" + attrs = getattr(state, "attributes", None) or {} + friendly = attrs.get("friendly_name", entity_id) + + join = _registry_enrichment(view, entity_id) + area_name = join["area"] + floor_name = join["floor"] + label_names = join["labels"] + aliases = join["aliases"] # Scored texts extend the server's id + friendly-name pair with the specific # joined identifiers (alias / area / floor / label / device). The bare domain # is deliberately excluded: matching it would score every entity of a domain # at the exact tier (a "light" query flooding all lights), which the server # does not do — domain is a filter dimension, not a scored text. - match_texts = [entity_id, friendly, *aliases, *label_names, *dev_texts] + match_texts = [entity_id, friendly, *aliases, *label_names, *join["_dev_texts"]] if area_name: match_texts.append(area_name) if floor_name: @@ -735,8 +1467,8 @@ def _entity_record(state: Any, view: _RegistryView) -> dict[str, Any]: "floor": floor_name, "labels": label_names, "aliases": aliases, - "_hidden": hidden, - "_area_id": area_id, + "_hidden": join["_hidden"], + "_area_id": join["_area_id"], "_match_texts": match_texts, } @@ -920,8 +1652,14 @@ def _scene_match_corpus(scene_config: Any) -> dict[str, Any] | None: def _plainify(value: Any) -> Any: - """Best-effort conversion of registry/state objects to plain JSON-able data.""" - if isinstance(value, dict): + """Best-effort conversion of registry/state objects to plain JSON-able data. + + Recurses on any ``Mapping`` (not just ``dict``): a nested ``MappingProxyType`` + (common in ``ConfigEntry.options``) must be walked into a plain dict, NOT + stringified — otherwise a secret buried inside it survives embedded in the repr + string, past the equality scrub that runs on the result. + """ + if isinstance(value, Mapping): return {str(k): _plainify(v) for k, v in value.items()} if isinstance(value, (list, tuple, set)): return [_plainify(v) for v in value] @@ -1471,13 +2209,34 @@ def _enum_value(value: Any) -> Any: """Unwrap a StrEnum-ish registry field (``entity_category``/``hidden_by``/…). HA stores these as enums whose ``.value`` is the wire string; a plain string - (or None) passes through unchanged. + (or None) passes through unchanged. Also unwraps ``ConfigEntryState`` (a plain + ``Enum`` whose ``.value`` is the wire string, e.g. ``"loaded"``) — core's + ``config_entries/get`` serializes it as ``entry.state.value``. """ if value is None or isinstance(value, str): return value return getattr(value, "value", str(value)) +def _timestamp(value: Any) -> float | None: + """Serialize a datetime-ish registry timestamp as a float, like core. + + core's registry WS list responses emit ``created_at`` / ``modified_at`` via + ``entry.created_at.timestamp()`` (a float, seconds since epoch), so mirror + that. A value that is already numeric passes through; anything else (or a + ``.timestamp()`` that raises) degrades to ``None``. + """ + if value is None: + return None + ts = getattr(value, "timestamp", None) + if callable(ts): + try: + return float(ts()) + except Exception: # pragma: no cover - defensive + return None + return float(value) if isinstance(value, (int, float)) else None + + def _reg_name(reg: Any) -> str | None: """Current display name from a registry entry: user override, else original.""" if reg is None: @@ -1509,17 +2268,28 @@ def _current_friendly_name( # ============================================================================= # ha_mcp_tools/helpers_list # ============================================================================= -def _do_helpers_list(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: +def _do_helpers_list( + hass: HomeAssistant, + params: dict[str, Any], + *, + secret_values: frozenset[str] = frozenset(), + secret_scrub_degraded: bool = False, +) -> dict[str, Any]: """List collection helpers (live state bodies) + flow helpers (config-entry options). Flow-helper ``options`` come straight from ``ConfigEntry.options`` — no OptionsFlow start/abort dance, and NEVER ``entry.data`` (integration credentials). Every record carries the CURRENT entity_id + display name from the entity registry so a renamed helper shows current values (issue #1794), - not the stale storage-collection name. No secret scrub: collection bodies come - from the storage collection (or live state attributes) and flow options are - storage-backed — neither is YAML-derived, so no resolved ``!secret`` plaintext - can appear. + not the stale storage-collection name. + + Flow-helper ``options`` share the credential-bearing exposure class of + ``config_entries``'s ``options`` (a flow helper IS a config entry), so they pass + through the SAME best-effort resolved-``!secret`` scrub for uniformity — a + present-but-unreadable ``secrets.yaml`` degrades the scrub to a no-op and sets + ``secret_scrub_degraded: true`` (present ONLY when degraded). Collection-helper + bodies (storage collection / live state attributes) are not scrubbed — they carry + no YAML-resolved secret. ``covered_types`` names exactly the helper_type values this command can enumerate (the state-machine collection domains + the flow domains, minus the @@ -1537,13 +2307,33 @@ def _do_helpers_list(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, A helpers = _collection_helpers_list(hass, view, type_filter) covered = set(HELPERS_LIST_COLLECTION_DOMAINS) if include_flow: - helpers.extend(_flow_helpers_list(hass, view, type_filter)) + helpers.extend(_flow_helpers_list(hass, view, type_filter, secret_values)) covered |= FLOW_HELPER_DOMAINS - return { + result: dict[str, Any] = { "helpers": helpers, "count": len(helpers), "covered_types": sorted(covered), } + if include_flow and secret_scrub_degraded: + result["secret_scrub_degraded"] = True + return result + + +async def _helpers_list_prep( + hass: HomeAssistant, msg: dict[str, Any] +) -> dict[str, Any]: + """Async pre-step for ``helpers_list``: load the secret-scrub set off the loop. + + Only the flow-helper ``options`` are scrubbed, so the blocking ``secrets.yaml`` + read is skipped entirely when ``include_flow_helpers`` is false (perf gate, + mirroring ``search``'s entity-only skip). The read runs in the executor via + :meth:`hass.async_add_executor_job`, keeping :func:`_do_helpers_list` a pure + in-memory read. See :func:`_load_secret_scrub`. + """ + if not msg.get("include_flow_helpers", True): + return {"secret_values": frozenset(), "secret_scrub_degraded": False} + values, degraded = await hass.async_add_executor_job(_load_secret_scrub, hass) + return {"secret_values": values, "secret_scrub_degraded": degraded} def _collection_helpers_list( @@ -1596,9 +2386,17 @@ def _collection_helpers_list( def _flow_helpers_list( - hass: HomeAssistant, view: _RegistryView, type_filter: frozenset[str] | None + hass: HomeAssistant, + view: _RegistryView, + type_filter: frozenset[str] | None, + secret_values: frozenset[str] = frozenset(), ) -> list[dict[str, Any]]: - """Flow (config-entry-backed) helpers — options + title + entry_id, never data.""" + """Flow (config-entry-backed) helpers — options + title + entry_id, never data. + + ``options`` is passed through the same resolved-``!secret`` scrub + ``config_entries`` applies (a flow helper is a config entry, so its ``options`` + share the same exposure class); an empty ``secret_values`` is a no-op. + """ out: list[dict[str, Any]] = [] entity_by_entry = _entities_by_config_entry(view) for entry in _iter_config_entries(hass): @@ -1611,7 +2409,9 @@ def _flow_helpers_list( title = getattr(entry, "title", None) or "" raw_options = getattr(entry, "options", None) options = ( - _plainify(dict(raw_options)) if isinstance(raw_options, Mapping) else {} + _scrub_secret_values(_plainify(dict(raw_options)), secret_values) + if isinstance(raw_options, Mapping) + else {} ) reg = entity_by_entry.get(entry_id) entity_id = getattr(reg, "entity_id", None) if reg is not None else None @@ -1916,11 +2716,18 @@ def _overview_repairs(hass: HomeAssistant) -> list[dict[str, Any]]: ``ignored`` is derived from ``dismissed_version`` so the server's ``filter_active_repairs`` (which keys off ``ignored``) works unchanged. + + Inactive entries are skipped to match HA core's ``ws_list_issues``: on + restart the registry reloads each stored non-persistent issue as an + inactive stub (``active=False``), and it stays inactive until the + integration re-raises or deletes it. """ registry = _safe(ir.async_get, hass) issues = getattr(registry, "issues", None) if registry is not None else None out: list[dict[str, Any]] = [] for issue in _mapping_values(issues): + if getattr(issue, "active", True) is False: + continue dismissed = getattr(issue, "dismissed_version", None) out.append( { @@ -1942,3 +2749,3362 @@ def _overview_repairs(hass: HomeAssistant) -> list[dict[str, Any]]: } ) return out + + +# ============================================================================= +# ha_mcp_tools/states +# ============================================================================= +def _do_states(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """Return ``State.as_dict()`` for each requested entity_id + a ``missing`` list. + + ``hass.states.get(id)`` is a pure O(1) in-memory dict read, and core's + ``State.as_dict()`` is exactly the serialization the REST ``/api/states/`` + endpoint emits — so a component-served record is byte-identical to the legacy + per-id REST fetch by construction (the WS transport JSON-encodes the same + datetimes to the same ISO strings the REST layer does). The body is returned + UNMODIFIED — never ``_plainify``'d — precisely so that byte-parity holds: + ``_plainify``'s ``str()`` would render a datetime with a space separator where + both REST and WS use ``isoformat``'s ``T``. No freshness or secrets concern: + state bodies are always live and carry no ``!secret`` plaintext. The server + enforces its own ``MAX_ENTITIES`` cap before calling, so no per-frame guard is + needed here (100 full states is well within one frame — ``overview`` already + returns every state in one call). + """ + entity_ids = params.get("entity_ids") or [] + states: dict[str, Any] = {} + missing: list[str] = [] + for entity_id in entity_ids: + state = _state_get(hass, entity_id) + if state is None: + missing.append(entity_id) + continue + as_dict = _state_as_dict(state) + if as_dict is None: + # A live state that could not be serialized (core drift) goes to + # ``missing`` rather than emitting a null state indistinguishable from + # a real value — the server maps ``missing`` onto its per-id contract. + missing.append(entity_id) + continue + states[entity_id] = as_dict + return {"states": states, "missing": missing} + + +def _state_get(hass: HomeAssistant, entity_id: str) -> Any: + """``hass.states.get(entity_id)`` guarded against core drift (``None`` if absent).""" + states = getattr(hass, "states", None) + getter = getattr(states, "get", None) if states is not None else None + if getter is None: + return None + try: + return getter(entity_id) + except Exception: # pragma: no cover - defensive + return None + + +def _state_as_dict(state: Any) -> Any: + """core ``State.as_dict()`` verbatim — the REST ``/api/states/`` shape. + + Returned unmodified so the WS transport encodes its datetimes with the same + ``isoformat`` the REST layer uses (byte-parity — see :func:`_do_states`). + """ + as_dict = getattr(state, "as_dict", None) + if callable(as_dict): + try: + return as_dict() + except Exception: # pragma: no cover - defensive + return None + return None + + +# ============================================================================= +# ha_mcp_tools/blueprint_get +# ============================================================================= +def _do_blueprint_get( + hass: HomeAssistant, + params: dict[str, Any], + *, + body: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Return one installed blueprint's full body as ``{metadata, config}``. + + core's ``blueprint/list`` returns only ``{metadata}`` (no triggers / + conditions / actions / sequence), so the server can otherwise serve metadata + only. This reads the on-disk blueprint file and returns the parsed body: + ``config`` is the full file (the server merges it additively over the + ``blueprint/list`` metadata) and ``metadata`` is its ``blueprint:`` section. + When the file is missing, unparseable, or the requested path escapes the jail, + both come back ``None`` (the server keeps metadata-only) — see + :func:`_read_blueprint_file`. + + Pure: the blocking jail-resolve + file read + YAML parse run in the executor + via :func:`_blueprint_get_prep`, which passes the parsed ``body`` in. + """ + if not isinstance(body, dict): + return {"metadata": None, "config": None} + metadata = body.get("blueprint") + return { + "metadata": _plainify(metadata) if isinstance(metadata, dict) else None, + "config": _plainify(body), + } + + +async def _blueprint_get_prep( + hass: HomeAssistant, msg: dict[str, Any] +) -> dict[str, Any]: + """Async pre-step for ``blueprint_get``: jail + read + parse off the loop. + + The path jail (symlink-safe ``Path.resolve`` containment), the ``open()`` and + the YAML parse are all blocking filesystem work, so they run in the executor + via :meth:`hass.async_add_executor_job` — keeping :func:`_do_blueprint_get` a + pure assembler over the parsed ``body`` this returns (``None`` on any failure). + """ + domain = msg["domain"] + path = msg["path"] + body = await hass.async_add_executor_job(_read_blueprint_file, hass, domain, path) + return {"body": body} + + +def _read_blueprint_file( + hass: HomeAssistant, domain: str, path: str +) -> dict[str, Any] | None: + """Resolve + jail + read + parse one blueprint YAML file. ``None`` on failure. + + Blueprint files live under ``/blueprints//``. The requested + ``path`` is joined under that root and resolved symlink-safe (mirrors the + file-tool jail's ``_resolves_within`` — resolve the RAW input, following + symlinks, THEN check containment, so ``//..`` cannot escape). A + path escaping the root — via ``..``, an absolute path, or a symlink — yields + ``None`` (rejected, never opened). A missing file, a non-file target, a read + error, or a YAML parse error also yields ``None``. Only a valid, contained, + parseable blueprint returns its full parsed body. + + Parsed with :class:`_BlueprintLoader`: ``!input`` markers are preserved and + every other custom tag (``!secret`` / ``!include`` / …) is neutralized to + ``None``, so no resolved secret plaintext can ever enter the returned body + (defense in depth — blueprints use ``!input``, not ``!secret``). + """ + config = getattr(hass, "config", None) + path_fn = getattr(config, "path", None) + if not callable(path_fn): + return None + try: + base = Path(path_fn("blueprints", domain)) + candidate = Path(path) if path.startswith("/") else base / path + real = candidate.resolve() + base_real = base.resolve() + except (OSError, ValueError): + return None + if not (real == base_real or real.is_relative_to(base_real)): + return None + try: + with open(real, encoding="utf-8") as handle: + # Instance form (not yaml.load) mirrors the component's existing + # _PackagesDirLoader usage; _BlueprintLoader is a SafeLoader subclass, + # so no !!python/object can construct arbitrary types. + loader = _BlueprintLoader(handle) + try: + parsed = loader.get_single_data() + finally: + loader.dispose() + except (OSError, ValueError, yaml.YAMLError): + return None + return parsed if isinstance(parsed, dict) else None + + +def _construct_blueprint_input(loader: Any, node: Any) -> dict[str, str]: + """Represent ``!input `` as ``{"__input__": }``. + + A JSON-safe, unambiguous marker of a blueprint input substitution point (the + body is a display artifact, not a runnable config), so a consumer can see + which fields an input fills without the tag crashing a plain safe-load. + """ + return {"__input__": str(getattr(node, "value", ""))} + + +def _drop_blueprint_tag(loader: Any, tag_suffix: Any, node: Any) -> None: + """Neutralize every non-``!input`` custom tag to ``None`` (never resolve it). + + ``!secret`` must never resolve to plaintext; ``!include`` / ``!env_var`` / + unknown tags are irrelevant to a read-only body view. Mirrors the component's + ``_ignore_unknown_tag`` pattern in ``__init__.py``. + """ + return None + + +class _BlueprintLoader(yaml.SafeLoader): + """SafeLoader for blueprint files: keep ``!input``, neutralize all other tags.""" + + +_BlueprintLoader.add_constructor("!input", _construct_blueprint_input) +_BlueprintLoader.add_multi_constructor("!", _drop_blueprint_tag) + + +# ============================================================================= +# ha_mcp_tools/device_get + ha_mcp_tools/device_list +# ============================================================================= +def _do_device_get(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """Return one device registry entry by id, optionally with its entities. + + ``{device: | None}`` — ``registry.async_get(device_id)`` + is a pure O(1) in-memory dict read, and the emitted body is core's + ``DeviceEntry.dict_repr`` returned UNMODIFIED — exactly the shape + ``config/device_registry/list`` serializes (it sends + ``json_bytes(entry.dict_repr)``), so a component-served record is byte-identical + to one legacy list element by construction (the WS transport JSON-encodes the + same dict with the same encoder). The body is never ``_plainify``'d: that would + ``str()`` the ``disabled_by`` / ``entry_type`` enums to their repr instead of the + wire value core's encoder emits, breaking parity. ``device`` is ``None`` when no + such device exists — the server maps that onto its own not-found contract. + + When ``include_entities`` is set, a SIBLING ``entities`` key carries the device's + entity-registry rows (``[, ...]`` — the same shape + and serialization ``config/entity_registry/list`` emits), so a single-device + lookup no longer pulls the WHOLE entity registry to list one device's entities. + ``er.async_entries_for_device`` is called with ``include_disabled_entities=True`` + to match what ``config/entity_registry/list`` returns (it lists disabled entities + too). The DeviceEntry dict itself stays exactly the raw shape — the join is a + sibling, so consumers keep their own transforms. The ``entities`` key is present + only when requested. + """ + device_id = params.get("device_id") + include_entities = params.get("include_entities", False) + view = _resolve_registries(hass) + entry = _device(view, device_id) if device_id else None + result: dict[str, Any] = { + "device": _device_dict_repr(entry) if entry is not None else None + } + if include_entities: + result["entities"] = _device_entities(view, device_id) if device_id else [] + return result + + +def _do_device_list(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """Return every device registry entry as ``{devices: [dict_repr, ...]}``. + + The in-process equivalent of ``config/device_registry/list``: each element is + core's ``DeviceEntry.dict_repr`` returned VERBATIM (same byte-parity rationale + as :func:`_do_device_get`), so the server's existing device transforms consume + it unchanged. An entry whose ``dict_repr`` is unavailable is skipped rather + than emitted as a partial record. + """ + view = _resolve_registries(hass) + reg = view.device + devices = getattr(reg, "devices", None) if reg is not None else None + out: list[dict[str, Any]] = [] + for dev in _mapping_values(devices): + repr_dict = _device_dict_repr(dev) + if repr_dict is not None: + out.append(repr_dict) + else: + _LOGGER.warning( + "device_list: skipping device %r with unavailable dict_repr", + getattr(dev, "id", None), + ) + return {"devices": out} + + +def _device_dict_repr(entry: Any) -> dict[str, Any] | None: + """core ``DeviceEntry.dict_repr`` verbatim — the ``config/device_registry/list`` shape. + + Returned UNMODIFIED so the WS transport encodes it with the same JSON + serializer ``config/device_registry/list`` uses (byte-parity — see + :func:`_do_device_get`). Guarded against core drift: a missing/raising + ``dict_repr`` yields ``None`` rather than propagating. + """ + try: + repr_dict = entry.dict_repr + except Exception: # pragma: no cover - defensive; core drift + return None + return repr_dict if isinstance(repr_dict, dict) else None + + +def _device_entities(view: _RegistryView, device_id: str) -> list[dict[str, Any]]: + """The device's entity-registry rows as ``config/entity_registry/list`` elements. + + Each row is core's ``RegistryEntry.as_partial_dict`` returned VERBATIM (the same + shape + serialization ``config/entity_registry/list`` emits — it sends + ``json_bytes(entry.partial_json_repr)`` over ``as_partial_dict``), so the + server's device<->entity map builds identically off the join or the legacy list. + A row whose ``as_partial_dict`` is unavailable is skipped. + """ + out: list[dict[str, Any]] = [] + for entry in _entries_for_device(view, device_id): + partial = _entity_partial_dict(entry) + if partial is not None: + out.append(partial) + return out + + +def _entries_for_device(view: _RegistryView, device_id: str) -> list[Any]: + """Entity-registry entries bound to ``device_id``, disabled ones INCLUDED. + + Delegates to core's ``er.async_entries_for_device`` (its device_id index) with + ``include_disabled_entities=True`` so the result matches what + ``config/entity_registry/list`` returns — that command lists disabled entities + too, and dropping them would diverge the join from the legacy shape. Guarded + against a missing registry / core drift (returns ``[]``). + """ + reg = view.entity + if reg is None or not device_id: + return [] + try: + entries = er.async_entries_for_device( + reg, device_id, include_disabled_entities=True + ) + except Exception: # pragma: no cover - defensive; core drift + return [] + return list(entries) + + +def _entity_partial_dict(entry: Any) -> dict[str, Any] | None: + """core ``RegistryEntry.as_partial_dict`` verbatim — the ``config/entity_registry/list`` shape. + + Returned UNMODIFIED so the WS transport encodes it with the same serializer + ``config/entity_registry/list`` uses (byte-parity, mirroring + :func:`_device_dict_repr`). Guarded against core drift. + """ + try: + partial = entry.as_partial_dict + except Exception: # pragma: no cover - defensive; core drift + return None + return partial if isinstance(partial, dict) else None + + +# ============================================================================= +# ha_mcp_tools/entity_enrich +# ============================================================================= +def _do_entity_enrich(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """Return the area/floor/labels/aliases join for each requested entity_id. + + ``{entities: {id: {area, floor, labels, aliases}}}`` — each id runs through the + SAME :func:`_registry_enrichment` join the search path uses (device-inherited + area/labels, resolved NAMES), so ``ha_get_entity`` adds the resolved-name + fields the raw registry entry lacks without the caller fanning out its own + area/floor/label registry reads. Pure O(id) in-memory registry lookups; a + registry-only (stateless) entity is enriched too (the join keys off the + registry, not the state machine). An id with no registry entry yields empty / + ``None`` fields rather than being dropped, so the caller can pair the result + back to its request by key. + """ + entity_ids = params.get("entity_ids") or [] + view = _resolve_registries(hass) + entities: dict[str, Any] = {} + for entity_id in entity_ids: + join = _registry_enrichment(view, entity_id) + entities[entity_id] = { + "area": join["area"], + "floor": join["floor"], + "labels": join["labels"], + "aliases": join["aliases"], + } + return {"entities": entities} + + +# ============================================================================= +# ha_mcp_tools/exposure +# ============================================================================= +def _do_exposure(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """Return voice-assistant exposure with names/areas attached. + + ``{exposed_entities: {id: {assistant: True}}, entity_info: {id: {...}}}``. + ``exposed_entities`` is byte-identical to core's ``ws_list_exposed_entities`` + result (``homeassistant/expose_entity/list``): only ``should_expose``-true + assistants appear, and an entity with none is omitted from the map — so the + server's existing exposure shaping consumes it unchanged. ``entity_info`` is + the additive half: each relevant id enriched through :func:`_registry_enrichment` + (friendly_name/domain/area/floor/labels), closing the "one call gives a bare + ``{id: {assistant: bool}}`` map with no names/areas" gap. + + Modes: + + * single-entity (``entity_id`` set) — reads core's module-level + ``async_get_entity_settings`` for that id and enriches it (whether exposed or + not — the caller asked about that specific entity). + * list (``entity_id`` omitted) — mirrors ``ws_list_exposed_entities``: walks + the exposed-entities store ids + the entity registry, keeps the exposed ones, + and enriches each. + + Parity guardrails (mirroring the legacy shape, pinned in tests): + + 1. only ``should_expose``-true assistants are reported (the raw helper returns + every assistant that has *any* stored option, not just exposed ones); + 2. core's ``HomeAssistantError("Unknown entity")`` on a junk id is caught and + degrades to the not-exposed default (the legacy ``expose_entity/list`` never + raises on a junk id); + 3. a missing ``hass.states.get(id)`` omits the live-state fields + (friendly_name / state) from ``entity_info`` rather than crashing. + """ + entity_id = params.get("entity_id") + view = _resolve_registries(hass) + + if entity_id: + exposed_to = _entity_exposed_to(hass, entity_id) + return { + "exposed_entities": {entity_id: exposed_to} if exposed_to else {}, + "entity_info": {entity_id: _exposure_enrichment(hass, view, entity_id)}, + } + + exposed_entities: dict[str, Any] = {} + entity_info: dict[str, Any] = {} + for eid in _all_exposable_entity_ids(hass, view): + exposed_to = _entity_exposed_to(hass, eid) + if not exposed_to: + continue + exposed_entities[eid] = exposed_to + entity_info[eid] = _exposure_enrichment(hass, view, eid) + return {"exposed_entities": exposed_entities, "entity_info": entity_info} + + +def _entity_exposed_to(hass: HomeAssistant, entity_id: str) -> dict[str, bool]: + """``{assistant: True}`` for the entity's ``should_expose``-true assistants. + + Reads core's ``async_get_entity_settings`` (via the local + :func:`_async_get_entity_settings` test-seam wrapper) and keeps only assistants + whose settings carry a truthy ``should_expose`` (guardrail 1 — the raw helper is + not pre-filtered like ``ws_list_exposed_entities``). A junk id whose helper raises + ``HomeAssistantError("Unknown entity")`` degrades to ``{}`` (guardrail 2), the + same not-exposed default the legacy path returns for an id it never listed. + """ + try: + settings = _async_get_entity_settings(hass, entity_id) + except Exception as exc: + if _is_unknown_entity_error(exc): + return {} + raise + out: dict[str, bool] = {} + if isinstance(settings, Mapping): + for assistant, opts in settings.items(): + if isinstance(opts, Mapping) and opts.get("should_expose"): + out[str(assistant)] = True + return out + + +def _exposure_enrichment( + hass: HomeAssistant, view: _RegistryView, entity_id: str +) -> dict[str, Any]: + """area/floor/labels + domain for an id, plus live-state fields when present. + + Runs the id through :func:`_registry_enrichment` for area/floor/labels + (device-inherited names). ``domain`` comes from the id itself (no state + needed). ``friendly_name`` and ``state`` are LIVE-STATE fields: included only + when ``hass.states.get(id)`` exists, omitted otherwise (guardrail 3 — a + disabled / legacy-only entity has no state, so those keys are simply absent + rather than crashing the join). + """ + join = _registry_enrichment(view, entity_id) + domain = entity_id.split(".")[0] if "." in entity_id else "" + info: dict[str, Any] = { + "domain": domain, + "area": join["area"], + "floor": join["floor"], + "labels": join["labels"], + } + state = _state_get(hass, entity_id) + if state is not None: + attrs = getattr(state, "attributes", None) or {} + friendly = ( + attrs.get("friendly_name", entity_id) + if isinstance(attrs, Mapping) + else entity_id + ) + info["friendly_name"] = str(friendly) + info["state"] = getattr(state, "state", "unknown") + return info + + +def _all_exposable_entity_ids(hass: HomeAssistant, view: _RegistryView) -> list[str]: + """Every id ``ws_list_exposed_entities`` walks: store ids plus registry ids. + + Core iterates ``chain(exposed_entities.entities, entity_registry.entities)`` — + the legacy store (entities WITHOUT a unique_id, exposed manually) plus every + registry entity. This reproduces that union, de-duplicated with store-first + order, so an exposed YAML entity that lives only in the store is not missed. + """ + ordered: list[str] = [] + seen: set[str] = set() + for eid in _legacy_exposed_entity_ids(hass): + if eid and eid not in seen: + seen.add(eid) + ordered.append(eid) + for entry in _all_entity_entries(view): + entry_eid = getattr(entry, "entity_id", None) + if entry_eid and entry_eid not in seen: + seen.add(entry_eid) + ordered.append(entry_eid) + return ordered + + +def _async_get_entity_settings(hass: HomeAssistant, entity_id: str) -> Any: + """core's ``async_get_entity_settings(hass, entity_id)``; test seam. + + Imported lazily so the fake-hass unit suite (which MagicMock-stubs + ``homeassistant.*`` at import time) can monkeypatch this whole function rather + than the deep core module. Returns ``{assistant: settings_mapping}`` and raises + ``HomeAssistantError("Unknown entity")`` for an id in neither the registry nor + the exposed-entities store — caught by :func:`_entity_exposed_to`. + """ + from homeassistant.components.homeassistant.exposed_entities import ( + async_get_entity_settings, + ) + + return async_get_entity_settings(hass, entity_id) + + +def _legacy_exposed_entity_ids(hass: HomeAssistant) -> list[str]: + """Entity ids in the exposed-entities store (entities without a unique_id). + + The ``exposed_entities.entities`` half of core's ``ws_list_exposed_entities`` + iteration. Imported lazily (test seam); a missing store / core drift yields + ``[]`` so list mode still enumerates the registry half. + """ + try: + from homeassistant.components.homeassistant.const import ( + DATA_EXPOSED_ENTITIES, + ) + + data = getattr(hass, "data", None) + store = data.get(DATA_EXPOSED_ENTITIES) if isinstance(data, Mapping) else None + except Exception: # pragma: no cover - defensive; core drift + return [] + entities = getattr(store, "entities", None) + if isinstance(entities, Mapping): + return [str(eid) for eid in entities] + return [] + + +def _is_unknown_entity_error(exc: Exception) -> bool: + """True for core's ``HomeAssistantError('Unknown entity')`` from the settings helper. + + Keyed off the exception type NAME (not an ``isinstance`` against the imported + class) so the fake-hass suite — which stubs ``homeassistant.exceptions`` — can + raise a stand-in ``HomeAssistantError`` without importing the real class. The + type name alone is too wide: core raises a plain ``HomeAssistantError`` for + other faults too, so the message is also required to carry ``unknown entity`` + (case-insensitive). A store-read failure that raises a bare + ``HomeAssistantError`` therefore propagates instead of being silently reported + as not-exposed; the audit guardrail (junk id → not-exposed default) still + matches because that raise carries the ``Unknown entity`` message. + """ + return ( + type(exc).__name__ == "HomeAssistantError" + and "unknown entity" in str(exc).lower() + ) + + +# ============================================================================= +# ha_mcp_tools/config_entries +# ============================================================================= +def _do_config_entries( + hass: HomeAssistant, + params: dict[str, Any], + *, + secret_values: frozenset[str] = frozenset(), + secret_scrub_degraded: bool = False, +) -> dict[str, Any]: + """Return config entries in the ``config_entries/get`` WS shape. + + ``{entries: [{created_at, modified_at, entry_id, domain, title, state, source, + supports_options, supports_remove_device, supports_unload, supports_reconfigure, + supported_subentry_types, pref_disable_new_entities, pref_disable_polling, + disabled_by, reason, error_reason_translation_key, + error_reason_translation_placeholders, num_subentries, options, subentries}]}``. + The FULL ``as_json_fragment`` field set (``created_at`` / ``modified_at`` as + ``.timestamp()`` floats, ``supported_subentry_types`` as core emits it), so the + component row carries the same fields the legacy REST row does — no field is + dropped on the component path. Filtered by ``domain`` when + given, or the single entry by ``entry_id`` + (``hass.config_entries.async_get_entry`` — an id that matches nothing, + including an empty string, yields an empty list). Only a WHOLLY ABSENT + ``entry_id`` key (``None``) selects list mode; an empty-string ``entry_id`` is + a single-entry lookup for a nonexistent id, so it returns ``entries: []`` + (mirroring ``async_get_entry("")``) rather than falling through to list mode + and returning the first entry. ``state`` is serialized as + ``ConfigEntryState.value`` (mirroring + how core's ``config_entries/get`` emits it via ``as_json_fragment``, whose + ``json_repr`` in ``homeassistant/config_entries.py`` is this row's source + of truth for every field above except ``options``/``subentries``, which + this integration re-derives since ``as_json_fragment`` never carries + credential data). + + Data minimization: ``entry.data`` (integration credentials) is NEVER read. + ``options`` is the only credential-bearing surface emitted, so it is passed + through a BEST-EFFORT resolved-``!secret`` scrub — an options leaf (dict value, + list item, or scalar whose ``str()`` form) that exactly equals a ``secrets.yaml`` + value becomes ``"**redacted**"`` (``secret_values`` loaded off the loop by + :func:`_config_entries_prep`). ``subentries`` carries identity fields only + (``subentry_id`` / ``subentry_type`` / ``title`` / ``unique_id``) — never a + subentry's ``data``; a core version without subentries degrades to ``[]``. + + The scrub is BEST-EFFORT: a present-but-unreadable ``secrets.yaml`` degrades it + to a no-op (options emitted unredacted). That degradation is signalled to the + caller as ``secret_scrub_degraded: true`` (present ONLY when degraded), so an + agent echoing ``options`` onward can tell an unscrubbed response from a clean + one rather than trusting redaction that did not run. + + Pure over ``hass``: the blocking ``secrets.yaml`` read is offloaded by the + async prep, so this stays a synchronous in-memory read. + """ + entry_id = params.get("entry_id") + domain = params.get("domain") + if entry_id is not None: + # Single-entry mode. An empty string is a valid (nonexistent) id — it + # must NOT fall through to list mode, where a truthiness check would + # return the first entry for a bogus id. + entry = _config_entry_by_id(hass, entry_id) + entries: list[Any] = [entry] if entry is not None else [] + else: + entries = _iter_config_entries(hass) + if domain: + entries = [e for e in entries if getattr(e, "domain", None) == domain] + result: dict[str, Any] = { + "entries": [_config_entry_row(e, secret_values) for e in entries] + } + if secret_scrub_degraded: + result["secret_scrub_degraded"] = True + return result + + +async def _config_entries_prep( + hass: HomeAssistant, msg: dict[str, Any] +) -> dict[str, Any]: + """Async pre-step for ``config_entries``: load the secret-scrub set off the loop. + + Unlike ``search`` (which skips the read for an entity-only query), + ``config_entries`` ALWAYS emits ``options``, so the ``secrets.yaml`` read is + unconditional. The blocking ``open()`` + ``yaml.safe_load`` runs in the + executor via :meth:`hass.async_add_executor_job`, keeping + :func:`_do_config_entries` a pure in-memory read. The ``degraded`` flag (a + present-but-unreadable ``secrets.yaml``) rides through so the response can signal + that ``options`` may be unredacted. See :func:`_load_secret_scrub`. + """ + values, degraded = await hass.async_add_executor_job(_load_secret_scrub, hass) + return {"secret_values": values, "secret_scrub_degraded": degraded} + + +def _config_entry_by_id(hass: HomeAssistant, entry_id: str) -> Any: + """``hass.config_entries.async_get_entry(entry_id)`` guarded (``None`` if absent).""" + config_entries = getattr(hass, "config_entries", None) + getter = ( + getattr(config_entries, "async_get_entry", None) + if config_entries is not None + else None + ) + if getter is None: + return None + try: + return getter(entry_id) + except Exception: # pragma: no cover - defensive + return None + + +def _config_entry_row(entry: Any, secret_values: frozenset[str]) -> dict[str, Any]: + """One config entry as the ``config_entries/get`` row (options scrubbed).""" + raw_options = getattr(entry, "options", None) + options = _plainify(dict(raw_options)) if isinstance(raw_options, Mapping) else {} + options = _scrub_secret_values(options, secret_values) + return { + # Timestamps as floats via ``.timestamp()``, mirroring core's + # as_json_fragment (``self.created_at.timestamp()``). Absent on a core old + # enough to predate them -> None (this row is read-only, never restored, so a + # None key is harmless here — unlike the area-registry rows). + "created_at": _timestamp(getattr(entry, "created_at", None)), + "modified_at": _timestamp(getattr(entry, "modified_at", None)), + "entry_id": getattr(entry, "entry_id", None), + "domain": getattr(entry, "domain", None), + "title": getattr(entry, "title", None), + "state": _enum_value(getattr(entry, "state", None)), + "source": getattr(entry, "source", None), + # supports_* are computed properties (they touch the flow handler) — read + # through _safe_prop so a domain whose handler is unavailable degrades + # instead of raising. ``or False`` mirrors core's as_json_fragment, which + # coerces the optional bools to False. + "supports_options": bool(_safe_prop(entry, "supports_options")), + "supports_remove_device": _safe_prop(entry, "supports_remove_device") or False, + "supports_unload": _safe_prop(entry, "supports_unload") or False, + "supports_reconfigure": bool(_safe_prop(entry, "supports_reconfigure")), + # supported_subentry_types is a computed property (it touches the flow + # handler, same hazard class as supports_*) — _safe_prop-guarded, defaulting + # to {} like core's ``self._supported_subentry_types or {}``. + "supported_subentry_types": _safe_prop(entry, "supported_subentry_types", {}) + or {}, + "pref_disable_new_entities": bool( + getattr(entry, "pref_disable_new_entities", False) + ), + "pref_disable_polling": bool(getattr(entry, "pref_disable_polling", False)), + "disabled_by": _enum_value(getattr(entry, "disabled_by", None)), + "reason": getattr(entry, "reason", None), + "error_reason_translation_key": getattr( + entry, "error_reason_translation_key", None + ), + "error_reason_translation_placeholders": getattr( + entry, "error_reason_translation_placeholders", None + ), + "num_subentries": len(_mapping_values(getattr(entry, "subentries", None))), + "options": options, + "subentries": _config_subentries(entry), + } + + +def _config_subentries(entry: Any) -> list[dict[str, Any]]: + """Identity fields of each config subentry — NEVER the subentry ``data``. + + ``entry.subentries`` is a ``MappingProxyType`` keyed by subentry_id in modern + core; a version without it (``getattr`` -> ``None``) degrades to ``[]``. + """ + return [ + { + "subentry_id": getattr(sub, "subentry_id", None), + "subentry_type": getattr(sub, "subentry_type", None), + "title": getattr(sub, "title", None), + "unique_id": getattr(sub, "unique_id", None), + } + for sub in _mapping_values(getattr(entry, "subentries", None)) + ] + + +def _scrub_secret_values(value: Any, secret_values: frozenset[str]) -> Any: + """Recursively replace any leaf equal to a known secret with ``"**redacted**"``. + + Walks ``Mapping`` values, list/tuple items, and scalar leaves of the (already + ``_plainify``'d) options structure. A scalar leaf whose plaintext form + (``str(leaf)``) exactly equals a ``secrets.yaml`` value is redacted, so a + resolved ``!secret`` never leaves the component whether an integration persists + it as a string OR as the original scalar (an int ``alarm_code`` leaves as an int + leaf). ``bool`` leaves are never secrets and pass through unchanged (their + ``"True"``/``"False"`` form would over-redact). An empty ``secret_values`` is a + no-op (fast path). Handles ``Mapping``/``tuple`` directly so it is correct even + if applied to a structure that skipped :func:`_plainify`. + """ + if not secret_values: + return value + if isinstance(value, Mapping): + return {k: _scrub_secret_values(v, secret_values) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_scrub_secret_values(v, secret_values) for v in value] + if isinstance(value, bool): + return value + if isinstance(value, (str, int, float)) and str(value) in secret_values: + return "**redacted**" + return value + + +def _safe_prop(obj: Any, name: str, default: Any = None) -> Any: + """Read a possibly-computed property, degrading to ``default`` if it raises. + + ``getattr`` alone only defaults on ``AttributeError``; a ConfigEntry's + ``supports_options`` / ``supports_reconfigure`` are computed off the flow + handler and can raise other errors when it is unavailable, so catch broadly. + """ + try: + return getattr(obj, name, default) + except Exception: # pragma: no cover - defensive; core drift + return default + + +# ============================================================================= +# ha_mcp_tools/registry_lookup +# ============================================================================= +def _do_registry_lookup(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """Return entity-registry rows for a set of ids or one config entry. + + Rows are core's ``RegistryEntry.as_partial_dict`` VERBATIM (the + ``config/entity_registry/list`` shape, disabled entities included), so the + consumers that parse that exact shape today read the component-served rows + unchanged. + + * ``config_entry_id`` — scans the whole entity registry and returns EVERY + entity bound to that entry (``{entities: [...]}``). It deliberately does + NOT reuse :func:`_entities_by_config_entry` (single-valued — first entity + only), so a multi-entity flow helper (a utility_meter and its tariff + sub-entities) does not silently lose members. + * ``entity_ids`` — looks each up (``{entities: [...], missing: [...]}``); an + id with no registry entry lands in ``missing`` rather than being dropped. + + Pure O(n)/O(id) in-memory registry reads. Exactly one of the two params is + meaningful — the schema rejects both being present; neither present (or only an + empty-string ``config_entry_id`` / empty ``entity_ids`` list — no usable target) + raises ``HomeAssistantError`` (see :func:`_registry_lookup_missing_target`) + rather than silently returning an empty result the caller could mistake for "no + matches". + """ + config_entry_id = params.get("config_entry_id") + entity_ids = params.get("entity_ids") or [] + if not config_entry_id and not entity_ids: + raise _registry_lookup_missing_target() + + view = _resolve_registries(hass) + if config_entry_id: + rows = [ + _entity_partial_dict(entry) + for entry in _all_entity_entries(view) + if getattr(entry, "config_entry_id", None) == config_entry_id + ] + return {"entities": [row for row in rows if row is not None]} + + found: list[dict[str, Any]] = [] + missing: list[str] = [] + for entity_id in entity_ids: + entry = _reg_entity(view, entity_id) + row = _entity_partial_dict(entry) if entry is not None else None + if row is None: + missing.append(entity_id) + else: + found.append(row) + return {"entities": found, "missing": missing} + + +def _registry_lookup_missing_target() -> Exception: + """Build a ``HomeAssistantError`` for a target-less ``registry_lookup`` request. + + Mirrors :func:`_backup_unavailable`: imported function-locally (test-stubbable) + so a request with no usable target — neither ``entity_ids`` nor + ``config_entry_id``, OR only an empty-string / empty-list one — raises instead of + returning ``{entities: [], missing: []}``, a shape indistinguishable from a + genuine "nothing matched" result, letting the server's command-error fallback + fire for the degenerate case. (This is a deliberate divergence from + ``config_entries``, whose empty-string ``entry_id`` is an authoritative empty + result mirroring ``async_get_entry("")`` — a different, intentional semantic.) + """ + from homeassistant.exceptions import HomeAssistantError + + err: Exception = HomeAssistantError( + "ha_mcp_tools/registry_lookup requires a non-empty entity_ids or " + "config_entry_id" + ) + return err + + +# ============================================================================= +# ha_mcp_tools/system_snapshot +# ============================================================================= +def _do_system_snapshot(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """One consistent synchronous pass over the live objects the health path reads. + + ``{config_entries: [...], issues: [...], entities: [...], states: [...]}`` — + every section read from the live in-memory objects in a SINGLE synchronous + handler call, which is the point: it collapses the server's former 3x + ``config_entries/get`` fetches (a TOCTOU where entries changed mid-read) into + one coherent snapshot. ``include_*`` flags gate each section; a disabled + section is an empty list (present, so the consumer need not special-case a + missing key). + + * ``config_entries`` — identity fields only (``entry_id`` / ``domain`` / + ``title`` / ``state`` / ``source`` / ``disabled_by``); the health view does + not need ``options`` / ``subentries`` (and skipping them avoids the secret + scrub here). + * ``issues`` — the ``_overview_repairs`` slice, reused verbatim. + * ``entities`` — the ``registry_lookup`` row shape (``as_partial_dict``). + * ``states`` — ``State.as_dict()`` per state (REST-parity bodies). + """ + view = _resolve_registries(hass) + result: dict[str, Any] = {} + result["config_entries"] = ( + [_config_entry_identity_row(e) for e in _iter_config_entries(hass)] + if params.get("include_config_entries", True) + else [] + ) + result["issues"] = ( + _overview_repairs(hass) if params.get("include_issues", True) else [] + ) + result["entities"] = ( + [ + row + for row in (_entity_partial_dict(e) for e in _all_entity_entries(view)) + if row is not None + ] + if params.get("include_entities", True) + else [] + ) + result["states"] = ( + _snapshot_states(hass) if params.get("include_states", True) else [] + ) + return result + + +def _config_entry_identity_row(entry: Any) -> dict[str, Any]: + """Identity-only config-entry row for the health snapshot (no options/subentries).""" + return { + "entry_id": getattr(entry, "entry_id", None), + "domain": getattr(entry, "domain", None), + "title": getattr(entry, "title", None), + "state": _enum_value(getattr(entry, "state", None)), + "source": getattr(entry, "source", None), + "disabled_by": _enum_value(getattr(entry, "disabled_by", None)), + } + + +def _snapshot_states(hass: HomeAssistant) -> list[dict[str, Any]]: + """Every live state as ``State.as_dict()`` (REST-parity body, unmodified).""" + out: list[dict[str, Any]] = [] + for state in _iter_states(hass): + if not getattr(state, "entity_id", None): + continue + as_dict = _state_as_dict(state) + if as_dict is not None: + out.append(as_dict) + return out + + +# ============================================================================= +# ha_mcp_tools/entity_lookup +# ============================================================================= +def _do_entity_lookup(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """Return registry entries whose ``unique_id`` matches (domain/platform narrow). + + ``{matches: [{entity_id, unique_id, platform, domain, config_entry_id, + categories, disabled_by, hidden_by}]}``. Scans the entity registry for every + entry whose ``unique_id`` equals the requested one, optionally narrowed by + ``domain`` (the entity's own domain, from its entity_id) and ``platform`` + (the owning integration). Multiple matches across platforms are all returned + — the server picks. ``categories`` mirrors ``as_partial_dict``'s + ``dict(entry.categories)``; ``disabled_by`` / ``hidden_by`` are unwrapped to + their wire strings. In-process, so the read is authoritative immediately (no + registry-write settle retry). + + A drifted entity registry (``er.async_get`` raised / renamed → ``None``) RAISES + ``HomeAssistantError`` (→ server command-error fallback to the legacy scan) + rather than returning ``{matches: []}`` — a well-formed empty the server can't + tell from a genuine "no entry with that unique_id". A present-but-empty registry + still returns ``{matches: []}`` (correct: no match). + """ + unique_id = params.get("unique_id") + domain = params.get("domain") + platform = params.get("platform") + view = _resolve_registries(hass) + if view.entity is None: + raise _substrate_unavailable("entity registry") + matches: list[dict[str, Any]] = [] + for entry in _all_entity_entries(view): + if getattr(entry, "unique_id", None) != unique_id: + continue + entity_id = getattr(entry, "entity_id", "") or "" + ent_domain = entity_id.split(".")[0] if "." in entity_id else "" + if domain and ent_domain != domain: + continue + if platform and getattr(entry, "platform", None) != platform: + continue + matches.append( + { + "entity_id": entity_id, + "unique_id": getattr(entry, "unique_id", None), + "platform": getattr(entry, "platform", None), + "domain": ent_domain, + "config_entry_id": getattr(entry, "config_entry_id", None), + "categories": _plainify(getattr(entry, "categories", None) or {}), + "disabled_by": _enum_value(getattr(entry, "disabled_by", None)), + "hidden_by": _enum_value(getattr(entry, "hidden_by", None)), + } + ) + return {"matches": matches} + + +# ============================================================================= +# ha_mcp_tools/backup_prep +# ============================================================================= +def _do_backup_prep(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """Return the backup identity the server needs before a create. + + ``{agent_ids: [...], local_agent_id: , default_password: + }`` read from the backup integration's in-process manager + (``hass.data[DATA_MANAGER]``). ``local_agent_id`` uses the SAME preference the + server's ``_get_local_backup_agent_id`` does — an agent whose ``name`` is + ``"local"``, preferring ``hassio.local`` (Supervised) over ``backup.local`` + (Core). ``default_password`` is getattr-chained off + ``manager.config.data.create_backup.password``. + + A missing backup integration (``ImportError``) or an uninitialized manager + raises ``HomeAssistantError`` so the server's command-error path falls back to + its legacy WS reads — NOT a silent empty result the server could mistake for + "no agents". The password is sensitive, but the legacy ``backup/config/info`` + already serves it to the same admin connection (parity, not new exposure). + + STRUCTURAL core drift raises for the same reason, rather than degrading to a + well-formed authoritative negative: a non-Mapping ``manager.backup_agents`` + (below) or a broken config→data→create_backup chain + (:func:`_backup_default_password`) would otherwise produce a "no agents" / "no + password" answer the server trusts and hard-fails on (or, for the password, + silently drops the restore safety backup) with no fallback. Value-level reads + (an id string, a genuinely-``None`` password on an intact chain) stay + getattr-guarded and pass through. + """ + try: + from homeassistant.components.backup import DATA_MANAGER + except ImportError as exc: + raise _backup_unavailable("backup integration is not available") from exc + manager = _hass_data_get(hass, DATA_MANAGER) + if manager is None: + raise _backup_unavailable("backup manager is not initialized") + agents = getattr(manager, "backup_agents", None) + if not isinstance(agents, Mapping): + raise _backup_unavailable("backup manager exposes no agent mapping") + return { + "agent_ids": [str(a) for a in agents], + "local_agent_id": _preferred_local_agent_id(agents), + "default_password": _backup_default_password(manager), + } + + +def _backup_unavailable(message: str) -> Exception: + """Build a ``HomeAssistantError`` (imported function-locally — test-stubbable).""" + from homeassistant.exceptions import HomeAssistantError + + err: Exception = HomeAssistantError(message) + return err + + +def _hass_data_get(hass: HomeAssistant, key: Any) -> Any: + """``hass.data.get(key)`` guarded against a non-mapping / drift.""" + data = getattr(hass, "data", None) + if not isinstance(data, Mapping): + return None + try: + return data.get(key) + except Exception: # pragma: no cover - defensive + return None + + +def _preferred_local_agent_id(agents: Any) -> str | None: + """The local backup agent id, mirroring the server's hassio-over-core preference. + + Collects agent ids whose agent ``name`` is exactly ``"local"`` + (``hassio.local`` on Supervised, ``backup.local`` on Core both use that + name), prefers ``hassio.local`` then ``backup.local``, else the first local + agent, else ``None``. + """ + if not isinstance(agents, Mapping): + return None + local_ids: list[str] = [ + str(agent_id) + for agent_id, agent in agents.items() + if getattr(agent, "name", None) == "local" + ] + for preferred in ("hassio.local", "backup.local"): + if preferred in local_ids: + return preferred + return local_ids[0] if local_ids else None + + +def _backup_default_password(manager: Any) -> str | None: + """``manager.config.data.create_backup.password`` (getattr-chained; ``str``/None). + + A STRUCTURALLY broken chain (a missing ``config`` / ``data`` / ``create_backup`` + link — core drift) RAISES ``HomeAssistantError`` so the server falls back to its + legacy ``backup/config/info`` read, rather than returning ``None`` the server + reads as "no default password configured" — which on restore silently drops the + safety backup while telling the user the password is unset. Only a genuine + ``None`` ``password`` on an INTACT chain is the authoritative "not configured". + """ + config = getattr(manager, "config", None) + data = getattr(config, "data", None) + create_backup = getattr(data, "create_backup", None) + if config is None or data is None or create_backup is None: + raise _backup_unavailable("backup manager config chain is unavailable") + password = getattr(create_backup, "password", None) + return password if isinstance(password, str) else None + + +# ============================================================================= +# ha_mcp_tools/registries +# ============================================================================= +def _do_registries(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """Return the requested registries as the FULL-FIELD ``config/_registry/list`` shapes. + + ``{areas: [...], floors: [...], labels: [...], categories: {scope: [...]}}`` — + only the requested ``registries`` keys are present. Each row is byte-compatible + with the legacy WS list response the consumers parse (verified against core's + registry serializers): area = aliases / area_id / floor_id / icon / labels / + name / picture / created_at / modified_at, plus humidity_entity_id / + temperature_entity_id ONLY on core >= 2024.12 (emitted conditionally so an older + core's restore does not get None-valued keys it rejects — see + :func:`_area_row`); floor = aliases / created_at / floor_id / icon / + level / name / modified_at (floor rows carry NO ``labels`` — core omits it); + label = color / created_at / description / icon / label_id / name / + modified_at; category = category_id / created_at / icon / modified_at / name. + Timestamps are floats (``.timestamp()``), matching core. + + ``category`` is scoped: ``category_scopes`` names which scopes to list (a + ``{scope: [rows]}`` map) and is REQUIRED when ``category`` is requested — a + scope-less category request raises ``HomeAssistantError`` (see + :func:`_registries_missing_category_scopes`) rather than silently serving + ``{}``, a shape indistinguishable from "every requested scope is empty". The + category registry is imported function-locally (not needed at module top). + Pure in-memory reads over the resolved registries. + """ + requested = params.get("registries") or [] + category_scopes = params.get("category_scopes") or [] + if "category" in requested and not category_scopes: + raise _registries_missing_category_scopes() + + view = _resolve_registries(hass) + result: dict[str, Any] = {} + # A requested registry whose accessor drifted (raised / renamed → ``None`` via + # ``_safe``) RAISES → server command-error fallback to the legacy WS list, + # rather than serving a well-formed empty list the capture pipeline would read + # as "this entity does not exist" and silently skip. A present-but-empty + # registry serves its empty list (correct). + if "area" in requested: + if view.area is None: + raise _substrate_unavailable("area registry") + result["areas"] = [_area_row(a) for a in _all_area_entries(view)] + if "floor" in requested: + if view.floor is None: + raise _substrate_unavailable("floor registry") + result["floors"] = [_floor_row(f) for f in _all_floor_entries(view)] + if "label" in requested: + if view.label is None: + raise _substrate_unavailable("label registry") + result["labels"] = [_label_row(x) for x in _all_label_entries(view)] + if "category" in requested: + result["categories"] = _category_rows(hass, category_scopes) + return result + + +def _registries_missing_category_scopes() -> Exception: + """Build a ``HomeAssistantError`` for a scope-less ``category`` request. + + Mirrors :func:`_backup_unavailable`: imported function-locally (test-stubbable) + so a ``registries`` request naming ``category`` without a non-empty + ``category_scopes`` raises instead of silently returning ``{}`` (a caller + could otherwise mistake that for "no categories in any scope"). + """ + from homeassistant.exceptions import HomeAssistantError + + err: Exception = HomeAssistantError( + "ha_mcp_tools/registries: category_scopes is required when " + "'category' is requested" + ) + return err + + +def _area_row(area: Any) -> dict[str, Any]: + """One area as core's ``AreaEntry.json_fragment`` shape (id renamed to area_id).""" + row: dict[str, Any] = { + "aliases": sorted(str(a) for a in (getattr(area, "aliases", None) or [])), + "area_id": getattr(area, "id", None) or getattr(area, "area_id", None), + "floor_id": getattr(area, "floor_id", None), + "icon": getattr(area, "icon", None), + "labels": sorted(str(x) for x in (getattr(area, "labels", None) or [])), + "name": getattr(area, "name", None), + "picture": getattr(area, "picture", None), + "created_at": _timestamp(getattr(area, "created_at", None)), + "modified_at": _timestamp(getattr(area, "modified_at", None)), + } + # humidity_entity_id / temperature_entity_id were added to AreaEntry in core + # 2024.12. Emit each ONLY when the running core's AreaEntry actually has the + # attribute — a pre-2024.12 core would otherwise get a None-valued key injected + # here that the restore path's config/area_registry/update schema rejects + # ("extra keys not allowed"). ``hasattr`` (not ``getattr(..., None)``) + # distinguishes "core has the field, value is None" from "core has no field". + for attr in ("humidity_entity_id", "temperature_entity_id"): + if hasattr(area, attr): + row[attr] = getattr(area, attr, None) + return row + + +def _floor_row(floor: Any) -> dict[str, Any]: + """One floor as core's ``config/floor_registry/list`` shape (NO ``labels`` field).""" + return { + "aliases": sorted(str(a) for a in (getattr(floor, "aliases", None) or [])), + "created_at": _timestamp(getattr(floor, "created_at", None)), + "floor_id": getattr(floor, "floor_id", None), + "icon": getattr(floor, "icon", None), + "level": getattr(floor, "level", None), + "name": getattr(floor, "name", None), + "modified_at": _timestamp(getattr(floor, "modified_at", None)), + } + + +def _label_row(label: Any) -> dict[str, Any]: + """One label as core's ``config/label_registry/list`` shape.""" + return { + "color": getattr(label, "color", None), + "created_at": _timestamp(getattr(label, "created_at", None)), + "description": getattr(label, "description", None), + "icon": getattr(label, "icon", None), + "label_id": getattr(label, "label_id", None), + "name": getattr(label, "name", None), + "modified_at": _timestamp(getattr(label, "modified_at", None)), + } + + +def _category_rows(hass: HomeAssistant, scopes: list[str]) -> dict[str, Any]: + """``{scope: [category rows]}`` for each requested scope (categories are scoped). + + A drifted / absent category registry (``None`` — ``cr.async_get`` raised / + renamed, or the module is missing on an old core) RAISES rather than serving + ``{scope: []}`` for every scope, so the server falls back to the legacy + ``config/category_registry/list`` instead of trusting an empty map. + """ + registry = _category_registry(hass) + if registry is None: + raise _substrate_unavailable("category registry") + return { + scope: [_category_row(c) for c in _list_categories(registry, scope)] + for scope in scopes + } + + +def _category_row(category: Any) -> dict[str, Any]: + """One category as core's ``config/category_registry/list`` shape.""" + return { + "category_id": getattr(category, "category_id", None), + "created_at": _timestamp(getattr(category, "created_at", None)), + "icon": getattr(category, "icon", None), + "modified_at": _timestamp(getattr(category, "modified_at", None)), + "name": getattr(category, "name", None), + } + + +def _category_registry(hass: HomeAssistant) -> Any: + """The category registry (imported function-locally). Test seam. ``None`` on drift.""" + try: + from homeassistant.helpers import category_registry as cr + except ImportError: # pragma: no cover - defensive; core drift + return None + return _safe(cr.async_get, hass) + + +def _list_categories(registry: Any, scope: str) -> list[Any]: + """``registry.async_list_categories(scope=...)`` guarded (scope is keyword-only).""" + if registry is None: + return [] + lister = getattr(registry, "async_list_categories", None) + if not callable(lister): + return [] + try: + return list(lister(scope=scope)) + except Exception: # pragma: no cover - defensive + return [] + + +def _all_floor_entries(view: _RegistryView) -> list[Any]: + """All floor-registry entries via ``async_list_floors()`` or the ``floors`` mapping.""" + reg = view.floor + if reg is None: + return [] + listed = _call_no_arg(reg, "async_list_floors") + if listed is not None: + try: + return list(listed) + except Exception: # pragma: no cover - defensive + return [] + return _mapping_values(getattr(reg, "floors", None)) + + +def _all_label_entries(view: _RegistryView) -> list[Any]: + """All label-registry entries via ``async_list_labels()`` or the ``labels`` mapping.""" + reg = view.label + if reg is None: + return [] + listed = _call_no_arg(reg, "async_list_labels") + if listed is not None: + try: + return list(listed) + except Exception: # pragma: no cover - defensive + return [] + return _mapping_values(getattr(reg, "labels", None)) + + +# ============================================================================= +# ha_mcp_tools/dashboards +# ============================================================================= +# LovelaceConfig.mode returns these wire strings (MODE_STORAGE / MODE_YAML in +# core's lovelace const). Compared as strings so a MagicMock-stubbed core in the +# unit suite (no real constants) still exercises the branches. +_LOVELACE_MODE_STORAGE = "storage" +_LOVELACE_MODE_YAML = "yaml" + +# Keys of a stored dashboard collection item (core's STORAGE_DASHBOARD_*_FIELDS), +# echoed by ``lovelace/dashboards/list`` — the row shape ``list`` mode mirrors. +_DASHBOARD_ROW_KEYS = ( + "id", + "url_path", + "title", + "icon", + "show_in_sidebar", + "require_admin", +) + +# Cap on ``search``-mode matches per call so one WS frame stays bounded. +_DASHBOARD_MATCH_CAP = 200 + +# Structural keys walked as containers (not scored as leaf strings) in a card. +_DASHBOARD_STRUCTURAL_KEYS = frozenset({"cards", "sections"}) + + +def _do_dashboards( + hass: HomeAssistant, + params: dict[str, Any], + *, + prepped: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Return Lovelace dashboards read in-process (``list`` / ``get`` / ``search``). + + Pure assembler over the plain dicts :func:`_dashboards_prep` loads off the + event loop — every Store load (``async_load``) happens in the prep, so this + function only shapes / walks already-materialized config. ``available`` is + ``False`` when the lovelace integration is not set up (no ``LOVELACE_DATA``); + the server falls back to its legacy ``lovelace/*`` path in that case. + + YAML-mode dashboard bodies are NEVER emitted (``get`` returns a ``yaml_excluded`` + status; ``search`` skips them) — their config may carry resolved ``!secret`` + plaintext, so body emission for YAML belongs to a future file-based tool. + """ + mode = params.get("mode", "list") + prepped = prepped or {} + if not prepped.get("available"): + result: dict[str, Any] = {"mode": mode, "available": False} + if mode == "list": + result["dashboards"] = [] + elif mode == "search": + result["matches"] = [] + result["truncated"] = False + return result + + if mode == "get": + return { + "mode": "get", + "available": True, + "status": prepped.get("status"), + "url_path": prepped.get("url_path"), + "config": prepped.get("config"), + } + if mode == "search": + query_lower = (params.get("query") or "").strip().lower() + matches, truncated = _search_dashboard_docs( + prepped.get("docs") or [], query_lower + ) + return { + "mode": "search", + "available": True, + "matches": matches, + "truncated": truncated, + } + return {"mode": "list", "available": True, "dashboards": prepped.get("rows") or []} + + +async def _dashboards_prep(hass: HomeAssistant, msg: dict[str, Any]) -> dict[str, Any]: + """Async pre-step for ``dashboards``: do ALL the Store loading off the loop. + + Reaching ``hass.data[LOVELACE_DATA].dashboards`` is a pure in-memory read, but + loading a dashboard's config (``LovelaceConfig.async_load``) awaits a Store + read, so every mode's loading lives here and :func:`_do_dashboards` gets plain + dicts. Returns ``{"prepped": {...}}`` with ``available=False`` when lovelace is + not set up (the server falls back to legacy). + """ + mode = msg.get("mode", "list") + dashboards_map = _lovelace_dashboards_map(hass) + if dashboards_map is None: + return {"prepped": {"available": False}} + + prepped: dict[str, Any] = {"available": True} + if mode == "get": + prepped.update(await _dashboard_get_config(dashboards_map, msg.get("url_path"))) + elif mode == "search": + prepped["docs"] = await _dashboard_search_docs(dashboards_map) + else: + prepped["rows"] = _dashboard_list_rows(dashboards_map) + return {"prepped": prepped} + + +def _lovelace_dashboards_map(hass: HomeAssistant) -> Mapping[Any, Any] | None: + """The ``{url_path|None: LovelaceConfig}`` map, or ``None`` if lovelace is absent. + + Reads ``hass.data[LOVELACE_DATA].dashboards`` via a function-local import of + core's key (older cores keyed ``hass.data["lovelace"]``, so both are tried), + guarded so a missing key / core drift degrades to ``None`` (the server keeps + its legacy path) rather than raising. + """ + try: + from homeassistant.components.lovelace import LOVELACE_DATA + + key: Any = LOVELACE_DATA + except Exception: # pragma: no cover - defensive; core drift / older core + key = "lovelace" + data = getattr(hass, "data", None) + if not isinstance(data, Mapping): + return None + container = data.get(key) + if container is None and key != "lovelace": + container = data.get("lovelace") + if container is None: + return None + dashboards = getattr(container, "dashboards", None) + if dashboards is None and isinstance(container, Mapping): + dashboards = container.get("dashboards") + return dashboards if isinstance(dashboards, Mapping) else None + + +def _dashboard_list_rows(dashboards_map: Mapping[Any, Any]) -> list[dict[str, Any]]: + """One metadata row per non-default dashboard, tagged with ``mode``. + + Mirrors the ``lovelace/dashboards/list`` row shape (the stored collection item + for storage dashboards, read from ``LovelaceConfig.config``) plus an additive + ``mode`` so the server can exclude YAML dashboards. The default dashboard + (``url_path`` key ``None``) is omitted — the legacy list omits it too and the + server special-cases the built-in dashboard as always-existing; ``get`` mode + resolves ``None`` to that default instead. + """ + rows: list[dict[str, Any]] = [] + for url_path, dash in dashboards_map.items(): + if url_path is None: + continue + config = getattr(dash, "config", None) + meta = config if isinstance(config, Mapping) else {} + row: dict[str, Any] = {key: meta.get(key) for key in _DASHBOARD_ROW_KEYS} + # The dict key is the authoritative url_path (the metadata may lack it). + row["url_path"] = url_path + row["mode"] = _dashboard_mode(dash) + rows.append(row) + return rows + + +async def _dashboard_get_config( + dashboards_map: Mapping[Any, Any], url_path: Any +) -> dict[str, Any]: + """Load one dashboard's config body; ``status`` names the outcome. + + ``url_path`` ``None``/absent resolves to the default dashboard. A YAML-mode + dashboard returns ``status="yaml_excluded"`` with no body (its config may + carry resolved ``!secret`` plaintext — storage-only emission). A missing + dashboard or a load error returns ``status="not_found"``. Storage freshness is + safe: ``LovelaceStorage.async_save`` mutates the in-memory object + synchronously, so this read never lags a save (audit-verified). + """ + dash = dashboards_map.get(url_path) + resolved = getattr(dash, "url_path", None) if dash is not None else url_path + if dash is None: + return {"status": "not_found", "url_path": url_path, "config": None} + if _dashboard_mode(dash) == _LOVELACE_MODE_YAML: + return {"status": "yaml_excluded", "url_path": resolved, "config": None} + loader = getattr(dash, "async_load", None) + if not callable(loader): + return {"status": "not_found", "url_path": resolved, "config": None} + try: + config = await loader(False) + except Exception: # any load failure degrades to not_found (fail-soft) + return {"status": "not_found", "url_path": resolved, "config": None} + if not isinstance(config, dict): + return {"status": "not_found", "url_path": resolved, "config": None} + return {"status": "ok", "url_path": resolved, "config": _plainify(config)} + + +async def _dashboard_search_docs( + dashboards_map: Mapping[Any, Any], +) -> list[dict[str, Any]]: + """Load every STORAGE dashboard's config for the ``search`` walk. + + Only storage dashboards are loaded — YAML bodies are never searched/emitted. + A per-dashboard load error is skipped (fail-soft) rather than failing the + whole search. Returns ``[{url_path, title, config}, ...]`` plain dicts. + """ + docs: list[dict[str, Any]] = [] + for url_path, dash in dashboards_map.items(): + if _dashboard_mode(dash) != _LOVELACE_MODE_STORAGE: + continue + loader = getattr(dash, "async_load", None) + if not callable(loader): + continue + try: + config = await loader(False) + except Exception: # skip an unreadable dashboard, keep going (fail-soft) + continue + if not isinstance(config, dict): + continue + title = config.get("title") + docs.append( + { + "url_path": url_path, + "title": str(title) if title is not None else None, + "config": config, + } + ) + return docs + + +def _dashboard_mode(dash: Any) -> str | None: + """A dashboard's ``mode`` (``storage``/``yaml``), guarded against core drift.""" + mode = getattr(dash, "mode", None) + return str(mode) if isinstance(mode, str) else None + + +def _search_dashboard_docs( + docs: list[dict[str, Any]], query_lower: str +) -> tuple[list[dict[str, Any]], bool]: + """Walk each dashboard config for ``query_lower``; return ``(matches, truncated)``. + + An empty query matches nothing (a bare substring would match every string). + Matches are capped at :data:`_DASHBOARD_MATCH_CAP` with a ``truncated`` flag. + """ + if not query_lower: + return [], False + matches: list[dict[str, Any]] = [] + for doc in docs: + _collect_dashboard_matches(doc, query_lower, matches) + truncated = len(matches) > _DASHBOARD_MATCH_CAP + return matches[:_DASHBOARD_MATCH_CAP], truncated + + +def _collect_dashboard_matches( + doc: dict[str, Any], query_lower: str, matches: list[dict[str, Any]] +) -> None: + """Append every ``query_lower`` hit in one dashboard config to ``matches``. + + Walks each view's card containers (``cards`` + sections-view ``sections.cards``, + nested cards recursed), plus the two view-level containers the card walk never + visits: ``badges`` and a sections-view ``header.card``. This matches what the + single-dashboard (MODE 2) search covers, so a query answered "no match" here is + a real absence, not a blind spot for entities referenced only as a badge or in a + header card. + """ + config = doc.get("config") + if not isinstance(config, dict): + return + views = config.get("views") + if not isinstance(views, list): + return + url_path = doc.get("url_path") + dash_title = doc.get("title") + for view_index, view in enumerate(views): + if not isinstance(view, dict): + continue + view_title = view.get("title") + for cards, base_path in _view_card_containers(view, view_index): + _collect_card_matches( + cards, + base_path, + url_path, + dash_title, + view_index, + view_title, + query_lower, + matches, + ) + _collect_badge_matches( + view, view_index, url_path, dash_title, view_title, query_lower, matches + ) + _collect_header_card_matches( + view, view_index, url_path, dash_title, view_title, query_lower, matches + ) + + +def _view_card_containers( + view: dict[str, Any], view_index: int +) -> list[tuple[Any, str]]: + """The card lists in a view: top-level ``cards`` plus each section's ``cards``.""" + containers: list[tuple[Any, str]] = [] + if isinstance(view.get("cards"), list): + containers.append((view["cards"], f"views[{view_index}].cards")) + sections = view.get("sections") + if isinstance(sections, list): + for si, section in enumerate(sections): + if isinstance(section, dict) and isinstance(section.get("cards"), list): + containers.append( + (section["cards"], f"views[{view_index}].sections[{si}].cards") + ) + return containers + + +def _dashboard_match( + url_path: Any, + dash_title: Any, + view_index: int, + view_title: Any, + card_path: str, + card_type: Any, + matched_field: str, + matched_value: str, +) -> dict[str, Any]: + """One MODE 4 cross-dashboard search match record (shared, fixed shape). + + Every match site — cards, badges, header cards — builds its record here so the + wire shape stays identical (the server-side legacy walk mirrors it for parity). + """ + return { + "url_path": url_path, + "title": dash_title, + "view_index": view_index, + "view_title": view_title, + "card_path": card_path, + "card_type": card_type, + "matched_field": matched_field, + "matched_value": matched_value, + } + + +def _collect_card_matches( + cards: Any, + base_path: str, + url_path: Any, + dash_title: Any, + view_index: int, + view_title: Any, + query_lower: str, + matches: list[dict[str, Any]], +) -> None: + """Recurse a card list, recording one match per string leaf containing the query. + + ``matched_field`` is the leaf's immediate key (``entity`` / ``entities`` / + ``camera_image`` / any plain-string field); nested ``cards`` are walked as + their own cards (their strings are attributed to the nested card, not the + parent), so ``card_path`` / ``card_type`` always name the card the string + actually lives on. + """ + if not isinstance(cards, list): + return + for card_index, card in enumerate(cards): + if not isinstance(card, dict): + continue + _collect_one_card_matches( + card, + f"{base_path}[{card_index}]", + url_path, + dash_title, + view_index, + view_title, + query_lower, + matches, + ) + + +def _collect_one_card_matches( + card: dict[str, Any], + card_path: str, + url_path: Any, + dash_title: Any, + view_index: int, + view_title: Any, + query_lower: str, + matches: list[dict[str, Any]], +) -> None: + """Record matches for a SINGLE card at ``card_path`` and recurse its nested cards. + + Shared by :func:`_collect_card_matches` (list-indexed cards) and + :func:`_collect_header_card_matches` (a header card is a single card, not + list-indexed). + """ + card_type = card.get("type") + for field, value in _card_string_leaves(card): + if query_lower in value.lower(): + matches.append( + _dashboard_match( + url_path, + dash_title, + view_index, + view_title, + card_path, + card_type, + field, + value, + ) + ) + nested = card.get("cards") + if isinstance(nested, list): + _collect_card_matches( + nested, + f"{card_path}.cards", + url_path, + dash_title, + view_index, + view_title, + query_lower, + matches, + ) + + +def _collect_badge_matches( + view: dict[str, Any], + view_index: int, + url_path: Any, + dash_title: Any, + view_title: Any, + query_lower: str, + matches: list[dict[str, Any]], +) -> None: + """Record query hits in a view's ``badges`` — entity refs the card walk misses. + + View-level badges are entity references by construction: a bare string + (``sensor.x``) or a dict (``{type: entity, entity: sensor.x}``). A bare-string + badge is recorded as a ``badges`` leaf; a dict badge's string leaves are walked + like a card's. Mirrors the single-dashboard (MODE 2) badge coverage. + """ + badges = view.get("badges") + if not isinstance(badges, list): + return + for badge_index, badge in enumerate(badges): + badge_path = f"views[{view_index}].badges[{badge_index}]" + if isinstance(badge, str): + if badge and query_lower in badge.lower(): + matches.append( + _dashboard_match( + url_path, + dash_title, + view_index, + view_title, + badge_path, + "badge", + "badges", + badge, + ) + ) + elif isinstance(badge, dict): + badge_type = badge.get("type") or "badge" + for field, value in _card_string_leaves(badge): + if query_lower in value.lower(): + matches.append( + _dashboard_match( + url_path, + dash_title, + view_index, + view_title, + badge_path, + badge_type, + field, + value, + ) + ) + + +def _collect_header_card_matches( + view: dict[str, Any], + view_index: int, + url_path: Any, + dash_title: Any, + view_title: Any, + query_lower: str, + matches: list[dict[str, Any]], +) -> None: + """Record query hits in a sections-view header card (``views[n].header.card``). + + The header accepts a card (typically Markdown) that can carry entity refs; the + card walk never visits it. Mirrors the single-dashboard (MODE 2) header-card + coverage. + """ + header = view.get("header") + if not isinstance(header, dict): + return + header_card = header.get("card") + if not isinstance(header_card, dict): + return + _collect_one_card_matches( + header_card, + f"views[{view_index}].header.card", + url_path, + dash_title, + view_index, + view_title, + query_lower, + matches, + ) + + +def _card_string_leaves(card: dict[str, Any]) -> list[tuple[str, str]]: + """``(immediate_key, string)`` for every string leaf of a card. + + Descends into nested dicts/lists but NOT the structural ``cards``/``sections`` + keys (those are walked as their own cards). The key attributed to a leaf is + the nearest dict key, so ``entities: [{entity: light.a}]`` yields + ``("entity", "light.a")`` and ``entities: [light.a]`` yields + ``("entities", "light.a")`` — matching the brief's field taxonomy. + """ + out: list[tuple[str, str]] = [] + _walk_card_leaves(card, "", out) + return out + + +def _walk_card_leaves(value: Any, key: str, out: list[tuple[str, str]]) -> None: + """Recursive worker for :func:`_card_string_leaves` (module-level for clarity). + + Descends dicts/lists collecting ``(nearest_key, string)`` leaves, skipping the + structural ``cards``/``sections`` keys (walked as their own cards). A top-level + card dict enters the ``dict`` branch, so its own keys attribute their leaves. + """ + if isinstance(value, str): + if value: + out.append((key, value)) + elif isinstance(value, dict): + for k, v in value.items(): + if k not in _DASHBOARD_STRUCTURAL_KEYS: + _walk_card_leaves(v, str(k), out) + elif isinstance(value, (list, tuple)): + for item in value: + _walk_card_leaves(item, key, out) + + +# ============================================================================= +# ha_mcp_tools/services_list +# ============================================================================= +def _do_services_list( + hass: HomeAssistant, + params: dict[str, Any], + *, + descriptions: Mapping[str, Any] | None = None, + translations: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Reshape the service catalog to the REST ``/api/services`` list, ``domain``-filtered. + + ``descriptions`` (``async_get_all_descriptions`` — ``{domain: {service: + desc}}``) and ``translations`` (``async_get_translations`` for the ``services`` + category) are loaded off the loop by :func:`_services_list_prep`. + + ``domain`` is the only filter — an exact match, same semantics both paths. + There is deliberately NO ``query`` coarse-filter: the server's exact filter + matches against the CONCATENATION ``f"{domain}.{service} {name} {description}"`` + (with a title-cased ``service.replace("_"," ").title()`` name fallback), and a + cheap per-field component pass is NOT a superset of that — it misses queries + spanning the ``domain.service`` / name / description boundaries and the + title-cased fallback, so forwarding ``query`` would silently drop matching + services. No consumer forwards ``query`` (the server re-runs its exact filter + + pagination over the full payload), so the component simply doesn't accept it. + Translations are scoped to the kept domains' key prefixes. + """ + descriptions = descriptions or {} + translations = translations or {} + domain_filter = params.get("domain") + + services: list[dict[str, Any]] = [] + kept_domains: set[str] = set() + for domain, domain_services in descriptions.items(): + if domain_filter and domain != domain_filter: + continue + services_map = ( + dict(domain_services) if isinstance(domain_services, Mapping) else {} + ) + services.append({"domain": domain, "services": services_map}) + kept_domains.add(str(domain)) + + return { + "services": services, + "translations": _filter_service_translations(translations, kept_domains), + } + + +async def _services_list_prep( + hass: HomeAssistant, msg: dict[str, Any] +) -> dict[str, Any]: + """Async pre-step for ``services_list``: load descriptions + translations off-loop. + + Both ``async_get_all_descriptions`` and ``async_get_translations`` await + (translation loads touch the filesystem / integration setup), so they run in + the prep via the seam wrappers below and :func:`_do_services_list` stays a pure + reshape/filter. + """ + language = msg.get("language") or "en" + descriptions = await _fetch_service_descriptions(hass) + translations = await _fetch_service_translations(hass, language) + return {"descriptions": descriptions, "translations": translations} + + +async def _fetch_service_descriptions(hass: HomeAssistant) -> Mapping[str, Any]: + """core ``async_get_all_descriptions(hass)``; function-local import test seam. + + A non-``Mapping`` return is core drift, NOT an empty catalog: RAISE + ``HomeAssistantError`` (→ server command-error fallback to the legacy REST + ``/api/services`` read) rather than serving a well-formed empty catalog the + server would trust as authoritative. A raising ``async_get_all_descriptions`` + already propagates the same way. + """ + from homeassistant.helpers.service import async_get_all_descriptions + + result = await async_get_all_descriptions(hass) + if not isinstance(result, Mapping): + raise _substrate_unavailable("service descriptions") + return result + + +async def _fetch_service_translations( + hass: HomeAssistant, language: str +) -> Mapping[str, Any]: + """core ``async_get_translations(hass, language, "services")``; test seam. + + Fails soft (empty map) so a translation-load failure degrades to the untranslated + service list rather than failing the whole command. + """ + from homeassistant.helpers.translation import async_get_translations + + try: + result = await async_get_translations(hass, language, "services") + except Exception: # translations are additive; degrade to none on any error + _LOGGER.warning( + "services_list: could not load service translations; " + "continuing without them", + exc_info=True, + ) + return {} + return result if isinstance(result, Mapping) else {} + + +def _filter_service_translations( + translations: Mapping[str, Any], kept_domains: set[str] +) -> dict[str, Any]: + """Keep only translation keys whose domain segment is in ``kept_domains``. + + Backend ``services``-category keys are ``component..services..…`` + so the domain is the second dotted segment. + """ + return { + key: value + for key, value in translations.items() + if _translation_key_domain(key) in kept_domains + } + + +def _translation_key_domain(key: Any) -> str | None: + """The ```` segment of a ``component..services.…`` translation key.""" + if not isinstance(key, str): + return None + parts = key.split(".") + return parts[1] if len(parts) > 1 else None + + +# ============================================================================= +# ha_mcp_tools/reference_data +# ============================================================================= +def _do_reference_data(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """Return the service index + entity-id universe the reference validator reads. + + ``services`` is the REST ``/api/services`` list shape ``build_service_index`` + consumes — reusing :func:`_overview_services`, whose per-service bodies are + EMPTY dicts (the index only reads service-name keys). ``entity_ids`` is every + ``hass.states.async_all()`` id (the ``build_entity_set`` universe). Pure, + synchronous, no prep — both are in-memory reads. + + A drifted service registry (``hass.services.async_services()`` raised / renamed + → non-``Mapping``) or state machine (``hass.states.async_all`` absent / renamed) + RAISES ``HomeAssistantError`` (→ server command-error fallback to the legacy + REST ``get_services()`` / ``get_states()`` pair) rather than returning empty + catalogs — which would make EVERY reference emit a false "not found" warning, + where the legacy failure mode is skip-validation. A genuinely-empty (but + present) substrate still returns its empty result. + """ + include_states = params.get("include_states", True) + if not isinstance( + _call_no_arg(getattr(hass, "services", None), "async_services"), Mapping + ): + raise _substrate_unavailable("service registry") + entity_ids: list[str] = [] + if include_states: + states_obj = getattr(hass, "states", None) + if not callable(getattr(states_obj, "async_all", None)): + raise _substrate_unavailable("state machine") + for state in _iter_states(hass): + entity_id = getattr(state, "entity_id", None) + if entity_id: + entity_ids.append(entity_id) + return {"services": _overview_services(hass), "entity_ids": entity_ids} + + +# ============================================================================= +# ha_mcp_tools/search — search_visibility (opt-in hidden-set exclusion) +# ============================================================================= +# HA's EntityCategory enum values (homeassistant.const). Mirrors the server +# resolver's KNOWN_ENTITY_CATEGORIES so an unknown exclude_category hides nothing. +_KNOWN_ENTITY_CATEGORIES = frozenset({"config", "diagnostic"}) + +# Degradation warnings surfaced when a visibility dimension fails open (mirrors the +# server resolver so the two paths emit byte-identical text — pinned by the +# cross-seam warnings contract test). The component cannot import the server +# package (it ships over HACS independently), so these strings are duplicated here +# and the contract test asserts they stay equal to ``visibility.resolver``'s. +_ASSIST_UNAVAILABLE_WARNING = ( + "Entity visibility filter is enabled with respect_assist_exposure but the " + "Assist exposure data was unavailable; that dimension is skipped for this " + "request (other dimensions still apply)." +) +_ALLOWLIST_REGISTRY_EMPTY_WARNING = ( + "Entity visibility filter is enabled with an area/label allowlist but the " + "entity registry returned no entries; those allow dimensions are skipped for " + "this request (an allow_entity_ids list, if set, still applies) so the filter " + "does not blank every entity." +) + + +def _unknown_categories_warning(unknown_categories: set[str]) -> str: + """The resolver's unknown-``exclude_categories`` warning text (byte-identical).""" + return ( + "Entity visibility: ignoring unknown exclude_categories " + f"{sorted(unknown_categories)} (valid: config, diagnostic)." + ) + + +def _visibility_hidden_set( + view: _RegistryView, + states: Any, + visibility: Mapping[str, Any], + should_expose_fn: Any, + *, + assist_available: bool = True, +) -> set[str]: + """Compute the opt-in hidden entity_id set, mirroring the server's resolver. + + A pure replication of ``visibility.resolver.hidden_entity_ids`` over the live + ``_RegistryView`` + ``states`` (rather than the WS ``{success, result}`` + payloads the server passes): a conjunction of independent hide dimensions — + the deny list, the category / hidden / area / label excludes (area/labels are + device-inherited), the allow-list restrict mode, and the Assist dimension. The + Assist dimension delegates to the injectable ``should_expose_fn(entity_id) -> + bool`` (:func:`_assist_should_expose` in production — a READ-ONLY reconstruction + of core's ``async_should_expose`` from the explicit exposure map + expose_new + + domain defaults, matching the resolver; a fake in tests), the injection point the + cross-seam contract test aligns with the server's own Assist result. The + production seam is read-only by design: core's own ``async_should_expose`` writes + computed defaults back, which this fast path must not do (see + :func:`_assist_should_expose`). + + ``should_expose_fn`` is consulted only when ``respect_assist_exposure`` is set + AND ``assist_available`` is True. When the config requests the Assist dimension + but the exposure machinery is unavailable (``assist_available=False``), the + dimension is SKIPPED — hiding nothing by Assist — mirroring the resolver's + "skip the dimension when its data is unavailable" fail-open (the paired + degradation warning is surfaced by :func:`_visibility_warnings`). Kept a + standalone pure function so it is unit-testable without the full search + pipeline. + """ + exclude_categories = set(visibility.get("exclude_categories") or []) + categories = exclude_categories & _KNOWN_ENTITY_CATEGORIES + exclude_hidden = bool(visibility.get("exclude_hidden")) + denied = set(visibility.get("deny_entity_ids") or []) + exclude_areas = set(visibility.get("exclude_areas") or []) + exclude_labels = set(visibility.get("exclude_labels") or []) + allow_entity_ids = set(visibility.get("allow_entity_ids") or []) + allow_areas = set(visibility.get("allow_areas") or []) + allow_labels = set(visibility.get("allow_labels") or []) + respect_assist = bool(visibility.get("respect_assist_exposure")) + # ``assist_active`` gates the per-entity Assist should_expose sub-check (skipped + # when the config asks for Assist but its data is unavailable). The allow/assist + # loop below is guarded by ``allow_active or respect_assist`` — a structural + # mirror of the resolver's guard, not a functional requirement: when Assist is + # requested-but-unavailable and no allowlist is set, the loop runs but hides + # nothing (both sub-checks are inert). Kept identical so the two paths match. + assist_active = respect_assist and assist_available + allow_active = bool(allow_areas or allow_labels or allow_entity_ids) + + registry_by_id = _registry_index_by_id(view) + # states-only entity universe (YAML/template entities absent from the registry + # that the allow / Assist dimensions must still be able to hide). + state_ids = { + eid for eid in (getattr(s, "entity_id", None) for s in states or []) if eid + } + + # Fail-open guard (mirrors the resolver): an area/label allowlist needs registry + # data to match; if the registry is empty but there are states-only candidates, + # restrict mode would blank everything, so drop those allow dimensions. + if (allow_areas or allow_labels) and not registry_by_id and state_ids: + allow_areas = set() + allow_labels = set() + allow_active = bool(allow_entity_ids) + + hidden: set[str] = set(denied) + _apply_visibility_excludes( + view, + registry_by_id, + denied, + categories, + exclude_hidden, + exclude_areas, + exclude_labels, + hidden, + ) + if allow_active or respect_assist: + _apply_visibility_allow_assist( + view, + registry_by_id, + state_ids, + allow_active, + allow_entity_ids, + allow_areas, + allow_labels, + assist_active, + should_expose_fn, + hidden, + ) + return hidden + + +def _visibility_warnings( + view: _RegistryView, + states: Any, + visibility: Mapping[str, Any], + *, + assist_available: bool = True, +) -> list[str]: + """Degradation warnings for a visibility computation, mirroring the resolver. + + Companion to :func:`_visibility_hidden_set`: the hidden-set function silently + fails open on a degraded dimension (an unknown ``exclude_category``, an + area/label allowlist against an empty registry, or a requested-but-unavailable + Assist dimension), so this returns the operator-facing warnings the server's + ``load_hidden_set`` would emit for the same config. The ha_search consumer + merges them into the response so the component fast path is no longer silent + about incomplete filtering. Byte-identical to ``visibility.resolver``'s warning + text (pinned by the cross-seam contract test). Kept a standalone pure function + so each degraded dimension is unit-testable. + + The resolver's registry-unavailable warning has no analog here: the component + reads HA's live in-process registry, which is never the failed-WS payload the + server can receive. + """ + warnings: list[str] = [] + + exclude_categories = set(visibility.get("exclude_categories") or []) + unknown = exclude_categories - _KNOWN_ENTITY_CATEGORIES + if unknown: + warnings.append(_unknown_categories_warning(unknown)) + + allow_areas = set(visibility.get("allow_areas") or []) + allow_labels = set(visibility.get("allow_labels") or []) + registry_by_id = _registry_index_by_id(view) + state_ids = { + eid for eid in (getattr(s, "entity_id", None) for s in states or []) if eid + } + # Empty-registry allowlist fail-open: same guard as _visibility_hidden_set — + # only fires when there are states-only candidates the restrict mode would + # otherwise blank. + if (allow_areas or allow_labels) and not registry_by_id and state_ids: + warnings.append(_ALLOWLIST_REGISTRY_EMPTY_WARNING) + + if bool(visibility.get("respect_assist_exposure")) and not assist_available: + warnings.append(_ASSIST_UNAVAILABLE_WARNING) + + return warnings + + +def _registry_index_by_id(view: _RegistryView) -> dict[str, Any]: + """Index the entity-registry entries by entity_id.""" + index: dict[str, Any] = {} + for entry in _all_entity_entries(view): + eid = getattr(entry, "entity_id", None) + if eid: + index[eid] = entry + return index + + +def _apply_visibility_excludes( + view: _RegistryView, + registry_by_id: dict[str, Any], + denied: set[str], + categories: set[str], + exclude_hidden: bool, + exclude_areas: set[str], + exclude_labels: set[str], + hidden: set[str], +) -> None: + """Add the exclude-dimension hits to ``hidden`` (registry-derived, deny-first). + + Mirrors the resolver's exclude loop. The empty-set dimensions are inert (``x in + set()`` / ``set() & x`` are falsy), so an inactive dimension hides nothing + without a guard; only ``exclude_hidden`` is a bool flag and keeps its guard. + """ + for eid, entry in registry_by_id.items(): + if eid in denied: + continue + if _enum_value(getattr(entry, "entity_category", None)) in categories: + hidden.add(eid) + continue + if exclude_hidden and getattr(entry, "hidden_by", None) is not None: + hidden.add(eid) + continue + if _effective_area_for_entry(view, entry) in exclude_areas: + hidden.add(eid) + continue + if exclude_labels & _effective_labels_for_entry(view, entry): + hidden.add(eid) + + +def _apply_visibility_allow_assist( + view: _RegistryView, + registry_by_id: dict[str, Any], + state_ids: set[str], + allow_active: bool, + allow_entity_ids: set[str], + allow_areas: set[str], + allow_labels: set[str], + assist_active: bool, + should_expose_fn: Any, + hidden: set[str], +) -> None: + """Add allow-restrict + Assist hits to ``hidden`` over registry + states. + + These conjunctive filters must reach states-only entities, so they iterate the + full candidate universe. Mirrors the resolver's allow/assist loop. ``should_expose_fn`` + is consulted only when ``assist_active`` (Assist requested AND its data + available); an unavailable-Assist request degrades to no Assist hiding here, + with the warning surfaced separately. + """ + for eid in registry_by_id.keys() | state_ids: + if eid in hidden: + continue + entry = registry_by_id.get(eid) + if allow_active and not _entity_allowed( + view, eid, entry, allow_entity_ids, allow_areas, allow_labels + ): + hidden.add(eid) + continue + if assist_active and not should_expose_fn(eid): + hidden.add(eid) + + +def _entity_allowed( + view: _RegistryView, + eid: str, + entry: Any, + allow_entity_ids: set[str], + allow_areas: set[str], + allow_labels: set[str], +) -> bool: + """Whether an entity satisfies the allowlist (restrict mode) — matched, so kept.""" + if eid in allow_entity_ids: + return True + if entry is None: + return False + if _effective_area_for_entry(view, entry) in allow_areas: + return True + return bool(allow_labels & _effective_labels_for_entry(view, entry)) + + +def _effective_area_for_entry(view: _RegistryView, entry: Any) -> str | None: + """An entity's ``area_id`` falling back to its device's (HA area inheritance).""" + area_id = getattr(entry, "area_id", None) + if isinstance(area_id, str) and area_id: + return area_id + device_id = getattr(entry, "device_id", None) + if isinstance(device_id, str) and device_id: + dev = _device(view, device_id) + dev_area = getattr(dev, "area_id", None) if dev is not None else None + return dev_area if isinstance(dev_area, str) and dev_area else None + return None + + +def _effective_labels_for_entry(view: _RegistryView, entry: Any) -> set[str]: + """An entity's labels plus its device's labels (device labels apply to entities).""" + labels = set(getattr(entry, "labels", None) or []) + device_id = getattr(entry, "device_id", None) + if isinstance(device_id, str) and device_id: + dev = _device(view, device_id) + if dev is not None: + labels |= set(getattr(dev, "labels", None) or []) + return labels + + +def _assist_should_expose(hass: HomeAssistant, entity_id: str) -> bool: + """Whether ``entity_id`` is exposed to the ``conversation`` assistant — READ-ONLY. + + A read-only replication of core's ``async_should_expose`` for the conversation + assistant. core's real function is NOT called because it has a WRITE side effect: + for an entity with no explicit stored exposure it computes the default and + persists it back (``entity_registry.async_update_entity_options`` for a registry + entity, or the exposed-entities store for a legacy one — see core + ``exposed_entities.py``). Consulting it once per candidate entity from a + ``readOnlyHint`` search would stamp exposure onto the whole entity universe and + pin those defaults, which violates this module's pure-read contract. So this + reconstructs the SAME precedence (matching the server resolver's + ``_is_assist_exposed``) with no write: an explicit per-entity ``should_expose`` + wins; otherwise the "expose new entities" flag gates the default-exposure check. + + Composed from three read-only, individually monkeypatchable seams. Fails OPEN + (returns ``True`` — do not hide) on any error, matching the resolver's "skip the + Assist dimension when its data is unavailable" behaviour. + """ + try: + explicit = _explicit_assist_exposure(hass, entity_id) + if explicit is not None: + return explicit + if not _assist_expose_new_entities(hass): + return False + return _assist_default_exposed(hass, entity_id) + except Exception: # fail open (do not hide) on any error, mirroring the resolver + return True + + +def _explicit_assist_exposure(hass: HomeAssistant, entity_id: str) -> bool | None: + """The entity's explicit ``conversation`` ``should_expose`` (True/False), else None. + + Reads the SAME read-only surface :func:`_do_exposure`'s list mode mirrors — core's + ``async_get_entity_settings`` (registry ``options`` for a registry entity, else + the legacy exposed-entities store), which never writes. Returns ``None`` when there + is no explicit override (an id in neither the registry nor the store, or no + ``conversation.should_expose`` key), so the caller falls through to the + expose-new default. A non-``Unknown entity`` raise propagates (fails open upstream). + """ + try: + settings = _async_get_entity_settings(hass, entity_id) + except Exception as exc: + if _is_unknown_entity_error(exc): + return None + raise + conv = settings.get("conversation") if isinstance(settings, Mapping) else None + if isinstance(conv, Mapping) and "should_expose" in conv: + return bool(conv["should_expose"]) + return None + + +def _assist_expose_new_entities(hass: HomeAssistant) -> bool: + """core's ``ExposedEntities.async_get_expose_new_entities("conversation")`` — read-only. + + Function-local import + a standalone seam so the fake-hass suite can monkeypatch + it. A ``@callback`` that only reads the assistant preferences (no store write). + """ + from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ) + + exposed = hass.data[DATA_EXPOSED_ENTITIES] + return bool(exposed.async_get_expose_new_entities("conversation")) + + +def _assist_default_exposed(hass: HomeAssistant, entity_id: str) -> bool: + """Read-only mirror of ``ExposedEntities._is_default_exposed`` for ``conversation``. + + core's ``async_should_expose`` calls the private ``_is_default_exposed`` and then + WRITES the result back; this recomputes it WITHOUT the write. Imports core's own + default-exposure constants (no drift — the running core defines them) and uses + core's ``get_device_class``, so the domain / device-class verdict matches core + exactly. entity_category / hidden_by entities are never a default exposure. A + standalone seam so the fake-hass suite can monkeypatch it. + """ + from homeassistant.components.homeassistant.exposed_entities import ( + DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES, + DEFAULT_EXPOSED_DOMAINS, + DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES, + ) + from homeassistant.helpers import entity_registry as er + + entry = er.async_get(hass).async_get(entity_id) + if entry is not None and ( + getattr(entry, "entity_category", None) is not None + or getattr(entry, "hidden_by", None) is not None + ): + return False + domain = entity_id.split(".")[0] if "." in entity_id else entity_id + if domain in DEFAULT_EXPOSED_DOMAINS: + return True + from homeassistant.exceptions import HomeAssistantError + from homeassistant.helpers.entity import get_device_class + + try: + device_class = get_device_class(hass, entity_id) + except HomeAssistantError: # the entity no longer exists — matches core + return False + if domain == "binary_sensor": + return device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES + if domain == "sensor": + return device_class in DEFAULT_EXPOSED_SENSOR_DEVICE_CLASSES + return False + + +def _assist_exposure_available(hass: HomeAssistant) -> bool: + """Whether core's Assist exposure machinery can be consulted for this request. + + The resolver emits ``_ASSIST_UNAVAILABLE_WARNING`` when its expose-list fetch + fails wholesale; the component's analog is core's ``async_should_expose`` being + unavailable — it reads ``hass.data[DATA_EXPOSED_ENTITIES]`` and raises when the + exposed_entities store isn't set up (``_assist_should_expose`` then fails open + per entity, hiding nothing but warning about nothing either). Probing the store + once lets the caller skip the Assist dimension AND surface the resolver-parity + degradation warning instead of degrading silently. Imported lazily and a test + seam (monkeypatched alongside ``_assist_should_expose``). + """ + try: + from homeassistant.components.homeassistant.exposed_entities import ( + DATA_EXPOSED_ENTITIES, + ) + except Exception: + return False + data = getattr(hass, "data", None) + return isinstance(data, Mapping) and DATA_EXPOSED_ENTITIES in data + + +# ============================================================================= +# ha_mcp_tools/server_entry +# ============================================================================= +def _find_server_config_entry(hass: HomeAssistant) -> Any | None: + """Return the component's OWN server config entry, or ``None`` if absent. + + Picks the single ``DOMAIN`` entry stamped with + ``entry.data[CONF_ENTRY_TYPE] == ENTRY_TYPE_SERVER`` (the one ``entry.data`` + key the component reads — the documented data-minimization exception). Shared + by the ``server_entry`` READ cap (:func:`_do_server_entry`) and the + ``server_entry_update`` WRITE cap (:func:`_server_entry_update_prep`), so both + discriminate the entry identically. + """ + for entry in _iter_config_entries(hass): + if getattr(entry, "domain", None) != DOMAIN: + continue + if _entry_marker_type(entry) != ENTRY_TYPE_SERVER: + continue + return entry + return None + + +def _do_server_entry(hass: HomeAssistant, params: dict[str, Any]) -> dict[str, Any]: + """Locate the component's OWN server config entry and return its identity. + + Discriminates the server-type entry via :func:`_find_server_config_entry` + (the ``entry.data[CONF_ENTRY_TYPE] == ENTRY_TYPE_SERVER`` marker). ``channel`` / + ``pip_spec`` come from ``entry.options`` (``None`` when absent); ``entry_id`` is + ``None`` when no server entry exists. + """ + entry = _find_server_config_entry(hass) + if entry is None: + return {"entry_id": None, "channel": None, "pip_spec": None} + options = getattr(entry, "options", None) + opts = options if isinstance(options, Mapping) else {} + return { + "entry_id": getattr(entry, "entry_id", None), + "channel": opts.get(OPT_CHANNEL), + "pip_spec": opts.get(OPT_PIP_SPEC), + } + + +def _entry_marker_type(entry: Any) -> Any: + """Read ONLY ``entry.data[CONF_ENTRY_TYPE]`` — the entry-type marker key.""" + data = getattr(entry, "data", None) + if isinstance(data, Mapping): + return data.get(CONF_ENTRY_TYPE) + return None + + +# ============================================================================= +# ha_mcp_tools/server_entry_update (the server_entry WRITE counterpart — Phase 3) +# ============================================================================= +def _do_server_entry_update( + hass: HomeAssistant, params: dict[str, Any], *, result: dict[str, Any] +) -> dict[str, Any]: + """Pure sync formatter for ``server_entry_update``. + + ALL of the work — locating the entry, merging the delta against the LIVE + options, and (unless it is a no-op) scheduling the deferred + ``async_update_entry`` — happens in the async :func:`_server_entry_update_prep`, + which hands the finished envelope in as ``result``. This only returns it (the WS + wrapper's ``send_result`` adds the outer success frame), so no scheduling / + awaiting work ever runs in a ``_do_*`` step. + """ + return result + + +async def _server_entry_update_prep( + hass: HomeAssistant, msg: dict[str, Any] +) -> dict[str, Any]: + """Apply a ``channel`` / ``pip_spec`` delta to the server entry, DEFERRED. + + Returns ``{"result": }``. The order is load-bearing: + + 1. Locate the server entry (:func:`_find_server_config_entry`); a missing entry + raises ``HomeAssistantError`` so the server's command-error path falls back to + its legacy options-flow submit. + 2. Require at least one of ``channel`` / ``pip_spec`` (the server always sends + one — this is defence-in-depth). + 3. Snapshot the ``delta`` (the provided fields, keyed by the OPT_* option keys) + and the CURRENT ``entry.options`` — used ONLY for the no-op check and the + ``previous``/``applying`` response envelope. Every UNtouched key is preserved + because the actual write MERGES the delta against the LIVE ``entry.options`` + at APPLY time (step 5), NOT against this snapshot — so a concurrent change to + another key during the flush window is not clobbered, and none of the + URL/secret overrides get blanked (which is why the server drops its + preserved-key resend on this path). + 4. No-op short-circuit: if the delta applied to the snapshot equals the current + options, return ``{scheduled: False, unchanged: True, ...}`` WITHOUT + scheduling. (The update listener's own ``DATA_LAST_OPTIONS`` guard would also + make such an update not reload, but skipping the schedule keeps it explicit.) + 5. Otherwise schedule the deferred ``_apply`` — which re-reads the LIVE + ``entry.options`` and merges the delta against THAT before calling + ``async_update_entry`` — on a HASS-owned background task after + :data:`SERVER_ENTRY_UPDATE_FLUSH_DELAY_S`, and return + ``{scheduled: True, ...}`` immediately. + + **The deferred-reload crux.** ``async_update_entry`` fires the server entry's + update listener, which reloads the entry — tearing down the very in-process + server thread answering THIS frame. Calling it inline would kill that thread + before the WS ``{scheduled: True}`` response flushed, so the caller would never + get its confirmation. It is therefore scheduled after a flush delay. The task is + created with :func:`hass.async_create_background_task` (hass-owned), NOT + ``entry.async_create_background_task``: an entry-owned task is cancelled by the + unload the reload performs, so it could cancel itself before firing — the exact + trap ``embedded_entry._on_version_update`` documents. Being hass-owned, the task + survives to invoke ``async_update_entry`` (a synchronous ``@callback`` that + returns as soon as it schedules the listener), then completes; the reload it + triggers runs as its own hass task after the response has flushed. + """ + import asyncio + + from homeassistant.exceptions import HomeAssistantError + + entry = _find_server_config_entry(hass) + if entry is None: + raise HomeAssistantError( + "no ha_mcp_tools in-process server config entry to update" + ) + + has_channel = "channel" in msg + has_pip_spec = "pip_spec" in msg + if not has_channel and not has_pip_spec: + raise HomeAssistantError( + "server_entry_update needs at least one of channel / pip_spec" + ) + + options = getattr(entry, "options", None) + current = dict(options) if isinstance(options, Mapping) else {} + # ``delta`` is the applied write (merged against the LIVE options at APPLY time); + # ``applying`` is its response-envelope view. The prep-time ``new_options`` is a + # snapshot used ONLY for the no-op check below, never for the write. + delta: dict[str, Any] = {} + applying: dict[str, Any] = {} + if has_channel: + delta[OPT_CHANNEL] = msg["channel"] + applying["channel"] = msg["channel"] + if has_pip_spec: + # Normalize like the options flow's ``_normalize`` (config_flow.py): a + # whitespace-only value OR the default unpinned dist (``DEFAULT_PIP_SPEC``) + # means "no override" — collapse it to "" so the channel keeps + # auto-updating. Persisting it verbatim would read as an intentional + # override and disable auto-updates. This keeps the no-op check honest: a + # frame that normalizes to the stored value is unchanged, not a schedule. + pip_spec = msg["pip_spec"] + if str(pip_spec).strip() in ("", DEFAULT_PIP_SPEC): + pip_spec = "" + delta[OPT_PIP_SPEC] = pip_spec + applying["pip_spec"] = pip_spec + new_options = {**current, **delta} + + entry_id = getattr(entry, "entry_id", None) + previous = { + "channel": current.get(OPT_CHANNEL), + "pip_spec": current.get(OPT_PIP_SPEC), + } + + if new_options == current: + return { + "result": { + "scheduled": False, + "unchanged": True, + "entry_id": entry_id, + "applying": applying, + "previous": previous, + } + } + + async def _apply() -> None: + # Deferred so the WS response flushes before the reload this triggers tears + # down the serving thread. See the docstring for why it is hass-owned. The + # delta is merged against the LIVE ``entry.options`` HERE (not at prep) so a + # concurrent change to another key during the flush window is preserved. + await asyncio.sleep(SERVER_ENTRY_UPDATE_FLUSH_DELAY_S) + try: + live = getattr(entry, "options", None) + merged = {**(dict(live) if isinstance(live, Mapping) else {}), **delta} + applied = hass.config_entries.async_update_entry(entry, options=merged) + if applied is False: + # ``async_update_entry`` returns False when the merged options already + # match (e.g. a concurrent write applied the same delta first). No + # caller is left to answer, so surface the no-apply in the log. + _LOGGER.warning( + "server_entry_update: no change applied to entry %s (options " + "already current)", + entry_id, + ) + else: + _LOGGER.info( + "server_entry_update applied %s to entry %s", applying, entry_id + ) + except Exception: # pragma: no cover - defensive; no caller left to raise to + _LOGGER.exception("server_entry_update deferred apply failed") + + hass.async_create_background_task( + _apply(), name=f"{WS_API_PREFIX} server_entry_update" + ) + return { + "result": { + "scheduled": True, + "entry_id": entry_id, + "applying": applying, + "previous": previous, + } + } + + +# ============================================================================= +# ha_mcp_tools/call_service (the first WRITE capability — Phase 3, issue #1813) +# ============================================================================= +def _do_call_service( + hass: HomeAssistant, params: dict[str, Any], *, result: dict[str, Any] +) -> dict[str, Any]: + """Pure sync formatter for ``call_service``. + + ALL of the work — the authoritative domain block, the ``ServiceNotFound`` + check, the pre-state capture, the expected-aware register-before-fire listener, + the single ``async_call`` dispatch, the immediate-match, and the bounded + confirmation wait — happens in the async :func:`_call_service_prep`, which hands + the finished result dict in as ``result``. This function only returns that + envelope (mirroring every other ``_do_*``: the WS wrapper's ``send_result`` adds + the outer success frame), so no awaiting / blocking work ever runs in a ``_do_*`` + step (D2). + """ + return result + + +async def _call_service_prep( + hass: HomeAssistant, msg: dict[str, Any] +) -> dict[str, Any]: + """Do all of ``call_service``'s async work; return ``{"result": }``. + + The order is load-bearing: + + 1. **D1 — authoritative domain block (security-critical).** Refuse + ``domain == "ha_mcp_tools"`` (case/whitespace-normalized) BEFORE any + ``has_service`` / dispatch. This is enforced HERE, in the component that + fires the call, independent of (and in addition to) the server-side guard: + a component ``call_service`` that skipped it would let a caller invoke the + admin-gated ``ha_mcp_tools.get_caller_token`` in-process (the server IS + admin) and then every file/YAML service. The block keys off the RESOLVED + domain, so it holds no matter which path reaches this function. + 2. **ServiceNotFound** before dispatch, so an unknown service is a clean + ``SERVICE_NOT_FOUND`` and never a landed-but-unreported write. + 3. Pre-state capture for each ``entity_id`` (a synchronous in-memory read). + 4. Register the expected-aware ``EVENT_STATE_CHANGED`` waiter BEFORE the dispatch + (D5) so a fast entity's event can't arrive before the listener exists. The + waiter confirms only on reaching the server's ``expected_state`` hint (skipping + intermediate/noise events); a ``None`` hint keeps any-first-event confirmation. + 5. Fire exactly ONE ``async_call`` (``blocking=True``); flip ``dispatched`` + immediately after so a post-dispatch problem is never retried as a failed + call (D3/D9). + 6. Immediate-match (:func:`_match_immediate`) for an idempotent no-op — a target + whose CURRENT state already equals its hint confirms with NO wait — then a + bounded wait for whatever is still unconfirmed (D4); expiry is ``partial``. + 7. Diff pre→post into the real transition(s) — the REAL observed transition, never + the hint value. + + Raised exceptions PROPAGATE — the WS handler turns them into a command error the + server maps (D7). Two distinct classes propagate: the D1 domain block and + ``ServiceNotFound`` are PRE-dispatch (they raise before ``async_call``, so nothing + landed); an ``async_call`` / ``return_response`` validation error is MID-dispatch + (the handler may mutate state and THEN raise — the documented D9 at-most-once + residual, NOT pre-dispatch). A confirmation timeout is caught and reported as + ``partial`` (never re-raised): the call already landed. + + The whole POST-dispatch section — the immediate-match re-read, the wait, and the + pre→post diff — runs inside ONE ``try`` that, once ``dispatched`` is True, never + lets a raise escape (I1): a raise mapped to a command error would re-POST an + already-landed write. A pre-confirmation ``async_call`` failure (``dispatched`` + still False) re-raises so the server can map it; any post-dispatch failure degrades + to a minimal dispatched-but-unconfirmed envelope. The immediate-match re-read is + itself raise-proof (:func:`_match_immediate`), the attribute diff is raise-proofed + (:func:`_values_differ`), and ``unsub`` always runs in the ``finally``. + Serialization residual (bounded, no sanitize pass): the transition embeds each + state's ``as_dict()`` and the WS transport re-encodes it; HA core enforces + JSON-serializable state attributes for its own REST/WS/recorder APIs, so a state + that reached the component already serializes — re-encoding it here is safe. + """ + domain = msg["domain"] + service = msg["service"] + + # 1./2. Pre-dispatch guards (D1 domain block + ServiceNotFound) — both raise + # BEFORE any listener registration or dispatch, so a refused call is never a + # landed-but-unreported write and ``async_call`` is provably never reached. + _guard_call_service_target(hass, domain, service) + + service_data = msg.get("service_data") or {} + entity_ids = list(msg.get("entity_ids") or []) + wait = msg.get("wait", True) + timeout = msg.get("timeout", CALL_SERVICE_DEFAULT_TIMEOUT) + return_response = msg.get("return_response", False) + should_confirm = bool(wait and entity_ids) + # The server's confirmation HINT (``_SERVICE_TO_STATE.get(service)``), applied to + # every confirmation target. Absent / None keeps any-first-event confirmation. + expected_state = msg.get("expected_state") + expected_by_entity = dict.fromkeys(entity_ids, expected_state) + + # 3. Pre-state capture (synchronous in-memory reads, guarded against drift). + pre = {eid: _state_as_dict(_state_get(hass, eid)) for eid in entity_ids} + + # 4. Register-before-fire (D5): only when there is something to confirm. + evt: Any = None + captured: dict[str, Any] = {} + unsub: Any = None + if should_confirm: + evt, captured, unsub = _register_transition_waiter( + hass, set(entity_ids), expected_by_entity + ) + + # 5. Dispatch exactly once. 6. Immediate-match + bounded wait. 7. Build the diff. + # Everything after ``dispatched = True`` is inside this ONE try so no post-dispatch + # raise (a drifted re-read, an exotic-attribute diff) escapes (I1) — that would be + # mapped to legacy and re-POST an already-landed write. ``unsub`` always runs. + response: Any = None + dispatched = False + result: dict[str, Any] + try: + response = await hass.services.async_call( + domain, + service, + dict(service_data), + blocking=True, + return_response=return_response, + ) + dispatched = True + if should_confirm: + await _await_confirmation( + hass, entity_ids, expected_by_entity, captured, evt, timeout + ) + result = _build_call_service_result( + hass, + domain, + service, + entity_ids, + pre, + captured, + should_confirm=should_confirm, + dispatched=dispatched, + return_response=return_response, + response=response, + ) + except Exception: + # PRE-confirmation ``async_call`` failure (never dispatched) → re-raise so the + # server maps it (D7/D9 MID-dispatch residual). Any POST-dispatch failure → + # degrade to a minimal dispatched-but-unconfirmed envelope (never re-POSTed). + if not dispatched: + raise + _LOGGER.exception( + "call_service post-dispatch step failed after dispatch; returning " + "dispatched-but-unconfirmed envelope (%s.%s)", + domain, + service, + ) + result = _dispatched_unconfirmed_result(domain, service) + finally: + if unsub is not None: + unsub() + return {"result": result} + + +def _guard_call_service_target(hass: HomeAssistant, domain: str, service: str) -> None: + """Pre-dispatch refusals for ``call_service`` — raise BEFORE any dispatch. + + * **D1 (security-critical)** — refuse ``domain == "ha_mcp_tools"`` + (case/whitespace-normalized). Enforced HERE, in the component that fires the + call, independent of (and in addition to) the server-side guard: a component + ``call_service`` that skipped it would let a caller invoke the admin-gated + ``ha_mcp_tools.get_caller_token`` in-process (the server IS admin) and then + every file/YAML service. The block keys off the RESOLVED domain, so it holds + no matter which path reaches this function. + * **ServiceNotFound** — an unknown service is a clean ``SERVICE_NOT_FOUND``, not + a phantom write. Both raises propagate to the WS handler (D7). + """ + if str(domain).strip().lower() == DOMAIN: + from homeassistant.exceptions import HomeAssistantError + + raise HomeAssistantError( + "the ha_mcp_tools domain is not callable through call_service; " + "use the dedicated ha_* tools" + ) + if not hass.services.has_service(domain, service): + from homeassistant.exceptions import ServiceNotFound + + raise ServiceNotFound(domain, service) + + +def _event_state_value(state: Any) -> Any: + """The primary state string from a ``State`` object OR an ``as_dict()`` mapping. + + Raise-proof: an exotic/stub shape (or a ``.state`` accessor that raises) degrades + to ``None`` so the expected-aware waiter and the post-dispatch immediate-match can + never propagate past the dispatch (I1). ``None`` never equals a (str) expected + hint, so an unreadable state simply does not confirm — it keeps waiting. + """ + try: + if isinstance(state, Mapping): + return state.get("state") + return getattr(state, "state", None) + except Exception: # pragma: no cover - defensive; exotic/stub shapes + return None + + +def _register_transition_waiter( + hass: HomeAssistant, target_set: set[str], expected_by_entity: Mapping[str, Any] +) -> tuple[Any, dict[str, Any], Any]: + """Register the ``EVENT_STATE_CHANGED`` listener BEFORE the dispatch (D5). + + Returns ``(evt, captured, unsub)``: ``evt`` is set once every id in + ``target_set`` has reported a CONFIRMING ``new_state``; ``captured`` maps each id + to its raw new_state; ``unsub`` tears the listener down. Registering before the + dispatch closes the race where a fast entity's event arrives before the + listener exists. + + ``expected_by_entity`` supplies each target's server-computed expected-state HINT + (``_SERVICE_TO_STATE``). With a hint, ONLY the event that reaches that state + confirms — a multi-phase service's intermediate states (``lock``: + unlocked→locking→locked) and attribute-only noise (a ``media_player`` position + tick while ``state`` stays "playing") are skipped, NOT captured. With no hint + (``None``, e.g. ``set_temperature``) any first ``new_state`` confirms — today's + unchanged behavior. + """ + import asyncio + + from homeassistant.const import EVENT_STATE_CHANGED + + evt = asyncio.Event() + captured: dict[str, Any] = {} + + def _on_change(event: Any) -> None: + data = getattr(event, "data", None) or {} + eid = data.get("entity_id") + new = data.get("new_state") + # M-newstate-none: a state_changed with new_state=None means the entity was + # REMOVED mid-wait — that is not a confirmed transition, so do NOT capture it + # (leaving the target uncaptured keeps the op ``partial``, not falsely + # confirmed). ``_post_state`` still re-reads a best-available current state. + if eid in target_set and new is not None: + exp = expected_by_entity.get(eid) + # Hint present → confirm ONLY on reaching the expected state (skip + # intermediate/noise events). Hint None → any first event confirms. + if exp is None or _event_state_value(new) == exp: + captured[eid] = new + if target_set <= set(captured): + evt.set() + + # Mark the listener a HA callback so ``EventBus.async_listen`` classifies its + # ``HassJob`` as ``HassJobType.Callback`` and runs it INLINE on the event loop + # (``is_callback`` reads exactly ``getattr(func, "_hass_callback", False)``). + # Without this a plain function is ``HassJobType.Executor``: every instance-wide + # ``state_changed`` gets thrown at the thread pool, and ``evt.set()`` then runs + # cross-thread on a non-thread-safe ``asyncio.Event`` → delayed/spurious + # ``partial`` confirmations and an ``InvalidStateError`` race with the + # timeout-cancel. We set the attribute directly rather than using ``@callback``: + # the unit-test harness MagicMock-stubs ``homeassistant.core``, so the decorator + # would be a MagicMock and break the listener, whereas the plain attribute set is + # exactly what ``callback`` does (``func.__dict__["_hass_callback"] = True``) and + # is inert under the stub. + _on_change._hass_callback = True # type: ignore[attr-defined] + + unsub = hass.bus.async_listen(EVENT_STATE_CHANGED, _on_change) + return evt, captured, unsub + + +def _match_immediate( + hass: HomeAssistant, + entity_ids: list[str], + expected_by_entity: Mapping[str, Any], + captured: dict[str, Any], +) -> None: + """Capture a target whose CURRENT state already equals its expected hint. + + Mirrors legacy's "sample current state first": for each not-yet-captured target + with a known expected state, re-read the live state right after ``async_call`` + returns and, if it already equals the expected value, capture it as confirmation + so NO wait is needed — a ``turn_on`` on an already-on light confirms instantly + (pre == expected == "on") instead of timing out to a false ``partial``. No-hint + targets (``exp is None``) are left for the any-first-event waiter — unchanged. + + Called AFTER the dispatch, so it MUST be raise-proof (I1): a re-read that raised + and reached the WS handler would be mapped to legacy and re-POST an already-landed + write. ``_state_get`` is guarded (``None`` on drift) and ``_event_state_value`` is + raise-proof, so this never propagates past dispatch. ``captured`` is mutated in + place with the raw ``State`` (``_post_state``/``_state_as_dict`` normalize it, the + same shape the waiter captures). + """ + for eid in entity_ids: + exp = expected_by_entity.get(eid) + if exp is None or eid in captured: + continue + cur = _state_get(hass, eid) + if cur is not None and _event_state_value(cur) == exp: + captured[eid] = cur + + +async def _await_confirmation( + hass: HomeAssistant, + entity_ids: list[str], + expected_by_entity: Mapping[str, Any], + captured: dict[str, Any], + evt: Any, + timeout: float, +) -> None: + """Immediate-match the idempotent no-ops, then bounded-wait whatever remains (D4). + + Runs AFTER the single ``async_call``: :func:`_match_immediate` captures a target + whose current state already equals its hint (a ``turn_on`` on an already-on light) + with NO wait; the bounded wait runs only if some target is still unconfirmed and + its expiry is swallowed (``partial``, never a failure). Kept as its own helper so + :func:`_call_service_prep` stays under the complexity gate; raise-proof re-read + (``_match_immediate``), so nothing here escapes past the dispatch (I1). + """ + import asyncio + + _match_immediate(hass, entity_ids, expected_by_entity, captured) + if set(entity_ids) <= set(captured): + return # every target confirmed by the immediate-match — no wait needed + try: + await asyncio.wait_for(evt.wait(), timeout) + except TimeoutError: + pass # partial confirmation, not a failure (D4) + + +def _build_call_service_result( + hass: HomeAssistant, + domain: str, + service: str, + entity_ids: list[str], + pre: Mapping[str, Any], + captured: Mapping[str, Any], + *, + should_confirm: bool, + dispatched: bool, + return_response: bool, + response: Any, +) -> dict[str, Any]: + """Assemble the ``call_service`` response envelope from the captured transition. + + ``confirmed`` is True only when every target reported within the wait; + ``partial`` is a confirmation that lapsed (never a failure). ``dispatched`` is + only ever ``True`` here (a pre-dispatch problem raised out of the prep); + reporting the flag rather than a literal keeps the D9 at-most-once boundary — + "reached this shape ⇒ the single async_call fired" — explicit for the server, + which never retries a dispatched write. ``service_response`` is present only + when it was both requested AND non-``None``. + """ + transitions = [ + _call_service_transition(eid, pre.get(eid), _post_state(hass, eid, captured)) + for eid in entity_ids + ] + confirmed = bool(should_confirm and set(entity_ids) <= set(captured)) + result: dict[str, Any] = { + "domain": domain, + "service": service, + "dispatched": dispatched, + "confirmed": confirmed, + "partial": bool(should_confirm and not confirmed), + "transitions": transitions, + } + if return_response and response is not None: + result["service_response"] = response + return result + + +def _dispatched_unconfirmed_result(domain: str, service: str) -> dict[str, Any]: + """Minimal ``call_service`` envelope when post-dispatch formatting raised (I1). + + The single ``async_call`` already fired; building the rich transition raised (an + exotic captured state / serialization edge). Report dispatched-but-unconfirmed with + no transitions rather than propagating — a propagated raise would become a command + error the server maps to legacy and re-POST an already-landed write (double-apply). + Reads only the plain domain/service strings, so it cannot itself raise. + """ + return { + "domain": domain, + "service": service, + "dispatched": True, + "confirmed": False, + "partial": True, + "transitions": [], + } + + +def _post_state( + hass: HomeAssistant, entity_id: str, captured: Mapping[str, Any] +) -> Any: + """The post-dispatch state for ``entity_id`` as a plain dict (or ``None``). + + Prefers the listener-captured ``new_state`` (the event that confirmed the + transition, normalized through the shared :func:`_state_as_dict`); falls back + to a fresh guarded ``hass.states`` re-read when the target did not report within + the wait (the ``partial`` case), so a transition row is still populated with the + best-available current state. Both ``None`` (a vanished/stateless entity) and + core drift degrade to ``None`` rather than raising. + """ + if entity_id in captured: + as_dict = _state_as_dict(captured[entity_id]) + if as_dict is not None: + return as_dict + return _state_as_dict(_state_get(hass, entity_id)) + + +def _values_differ(a: Any, b: Any) -> bool: + """Whether two attribute values differ, raise-proof for array-like values. + + A plain ``a != b`` raises "truth value ... is ambiguous" for numpy arrays and + other exotic ``__ne__`` results that aren't a bool. Post-dispatch formatting MUST + NOT raise (a raise here would surface as a command error the server maps to legacy + → a re-POST of an already-landed write), so fall back to a ``repr`` compare when + the direct compare's truthiness is not a plain bool. + """ + try: + return bool(a != b) + except Exception: # array-like / exotic __ne__ whose result isn't a plain bool + return repr(a) != repr(b) + + +def _call_service_transition( + entity_id: str, + old_state: dict[str, Any] | None, + new_state: dict[str, Any] | None, +) -> dict[str, Any]: + """The real pre→post transition for one target entity. + + ``changed`` compares the top-level ``state`` (always a plain string — safe); + ``attributes_changed`` lists the attribute keys whose values differ (added/removed + keys included), compared through :func:`_values_differ` so an array-like attribute + value cannot raise. Both sides may be ``None`` (a stateless or vanished entity), + which the ``or {}`` guards fold into an all-``None`` comparison rather than raising. + """ + old = old_state or {} + new = new_state or {} + old_attrs = old.get("attributes") or {} + new_attrs = new.get("attributes") or {} + attributes_changed = sorted( + key + for key in set(old_attrs) | set(new_attrs) + if _values_differ(old_attrs.get(key), new_attrs.get(key)) + ) + return { + "entity_id": entity_id, + "old_state": old_state, + "new_state": new_state, + "changed": old.get("state") != new.get("state"), + "attributes_changed": attributes_changed, + } + + +# ============================================================================= +# ha_mcp_tools/bulk_call_service (the BATCH write capability — Phase 3, D5a) +# ============================================================================= +def _do_bulk_call_service( + hass: HomeAssistant, params: dict[str, Any], *, result: dict[str, Any] +) -> dict[str, Any]: + """Pure sync formatter for ``bulk_call_service``. + + Like :func:`_do_call_service`, ALL of the work — the per-op D1 domain block, + the ``ServiceNotFound`` checks, the expected-aware register-before-fire pass, the + dispatches, the per-op immediate-match, and the one bounded batch wait — happens + in the async :func:`_bulk_call_service_prep`, which hands the finished envelope in + as ``result``. This function only returns it, so no awaiting / blocking work runs + in a ``_do_*`` step (D2). + """ + return result + + +async def _bulk_call_service_prep( + hass: HomeAssistant, msg: dict[str, Any] +) -> dict[str, Any]: + """Do all of ``bulk_call_service``'s async work; return ``{"result": ...}``. + + Register-before-fire is trivially correct for the batch: every listener is + registered in one synchronous pass BEFORE any ``async_call`` is issued, so no + op's confirming event can arrive before its listener exists. The order is + load-bearing: + + 1. **D1 batch fail-closed (security-critical).** Run + :func:`_guard_call_service_target` for EVERY operation FIRST — before any + pre-state read, listener, or dispatch. A single op targeting the + ``ha_mcp_tools`` domain (or an unknown service) makes the WHOLE frame raise: + no partial batch is dispatched, so no ``ha_mcp_tools.*`` op can ever slip + through in a batch (register-before-fire + all-guards-first means a refused + op aborts before any real write lands). + 2. Pre-state capture for every op's ``entity_ids`` (synchronous in-memory). + 3. Register ALL expected-aware confirmation listeners in one pass BEFORE any + dispatch (each op's ``expected_state`` hint governs its waiter); every + ``unsub`` is torn down in the ``finally``. + 4. Dispatch: ``parallel`` fans the ``async_call``s out through + :func:`asyncio.gather` with ``return_exceptions=True`` so one op's failure + does not abort the others (its exception is recorded on that op, NOT raised — + UNLIKE the step-1 guards, which DO raise the whole frame pre-dispatch); + ``parallel=False`` awaits them in order. Each op flips its own ``dispatched`` + flag the moment its ``async_call`` returns. + 5. Immediate-match per dispatched op (:func:`_bulk_match_immediate`) — an + idempotent no-op whose current state already equals its hint confirms with no + wait — then ONE shared bounded deadline (D4, not per-op serial timeouts) for + every op still unconfirmed. + 6. Per-op pre→post diff, reusing the single-call transition/build helpers. + 7. Return every op's result plus batch counts. + """ + operations = list(msg.get("operations") or []) + parallel = bool(msg.get("parallel", True)) + wait = bool(msg.get("wait", True)) + timeout = msg.get("timeout", CALL_SERVICE_DEFAULT_TIMEOUT) + + # 1. D1 batch fail-closed: guard EVERY op before ANY pre-state / listener / + # dispatch. A refused op (``ha_mcp_tools`` domain or unknown service) raises + # the whole frame here, so nothing in the batch is ever dispatched partially. + for op in operations: + _guard_call_service_target(hass, op["domain"], op["service"]) + + # 2. Normalize + pre-state capture (synchronous in-memory reads) per op. + ops = [_bulk_op_record(hass, op, wait=wait) for op in operations] + + # 3. Register-before-fire (D5): every confirmable op's listener is registered in + # one synchronous pass BEFORE any dispatch; ALL unsubs torn down in finally. + unsubs = _bulk_register_all(hass, ops) + try: + await _bulk_dispatch_all(hass, ops, parallel=parallel) # 4 + _bulk_match_immediate(hass, ops) # 5a immediate-match idempotent no-ops + await _bulk_wait_all(ops, timeout) # 5b (one shared deadline; expiry=partial) + finally: + for unsub in unsubs: + unsub() + + # 6./7. Per-op pre→post diff + batch counts. Post-dispatch assembly MUST be total + # (I1): the ops already fired, so a raise here would be mapped to legacy and + # re-dispatch every landed op (double-fire). On any failure return a minimal + # envelope reporting each op dispatched-but-unconfirmed (its real dispatched/error + # preserved) instead of propagating. + try: + result = _build_bulk_result(hass, ops) + except Exception: + _LOGGER.exception( + "bulk_call_service post-dispatch assembly failed after dispatch; " + "returning dispatched-but-unconfirmed batch envelope" + ) + result = _dispatched_unconfirmed_bulk_result(ops) + return {"result": result} + + +def _bulk_op_record( + hass: HomeAssistant, op: Mapping[str, Any], *, wait: bool +) -> dict[str, Any]: + """A mutable working record for one batch operation (incl. its pre-state). + + Reads the resolved ``{domain, service, service_data?, entity_ids?, + expected_state?}`` row defensively (the direct-prep tests pass raw dicts that + never went through the schema, so the mutable defaults are re-applied here). + ``pre`` is the synchronous in-memory pre-state per target; ``expected_by_entity`` + maps every target to this op's confirmation hint (``_SERVICE_TO_STATE``) so the + waiter + immediate-match key off it; ``dispatched`` / ``error`` / ``response`` + start empty and are filled during dispatch; ``should_confirm`` is true only when + the batch is waiting AND this op names targets to confirm. + """ + entity_ids = list(op.get("entity_ids") or []) + expected_state = op.get("expected_state") + return { + "domain": op["domain"], + "service": op["service"], + "service_data": op.get("service_data") or {}, + "entity_ids": entity_ids, + "expected_by_entity": dict.fromkeys(entity_ids, expected_state), + "should_confirm": bool(wait and entity_ids), + "pre": {eid: _state_as_dict(_state_get(hass, eid)) for eid in entity_ids}, + "evt": None, + "captured": {}, + "dispatched": False, + "response": None, + "error": None, + } + + +async def _bulk_dispatch_one(hass: HomeAssistant, op: dict[str, Any]) -> None: + """Fire exactly one op's ``async_call`` and flip its ``dispatched`` flag. + + Mirrors the single-call dispatch (``blocking=True``), but bulk never requests a + per-op ``return_response`` (D5a keeps the batch simple — the single + ``call_service`` covers response-returning calls). ``dispatched`` is set only + AFTER ``async_call`` returns, so a raise (captured per-op by the caller) leaves + it ``False`` and the op is never counted as a landed write. + """ + op["response"] = await hass.services.async_call( + op["domain"], + op["service"], + dict(op["service_data"]), + blocking=True, + return_response=False, + ) + op["dispatched"] = True + + +def _bulk_register_all(hass: HomeAssistant, ops: list[dict[str, Any]]) -> list[Any]: + """Register every confirmable op's transition listener in one pass (D5). + + One synchronous sweep BEFORE any dispatch, so no op's confirming event can + arrive before its listener exists. Each confirmable op is handed its own ``evt`` + / ``captured`` (mutating the op record); the returned ``unsub`` list is torn down + in the prep's ``finally``. Non-confirmable ops register nothing. + + If ``async_listen`` raises mid-sweep (near-impossible in practice), every listener + already registered in this pass is unsubbed before re-raising — the prep's + ``try/finally`` has not been entered yet, so those would otherwise leak on the bus. + """ + unsubs: list[Any] = [] + try: + for op in ops: + if op["should_confirm"]: + evt, captured, unsub = _register_transition_waiter( + hass, set(op["entity_ids"]), op["expected_by_entity"] + ) + op["evt"] = evt + op["captured"] = captured + unsubs.append(unsub) + except Exception: + for unsub in unsubs: + unsub() + raise + return unsubs + + +async def _bulk_dispatch_all( + hass: HomeAssistant, ops: list[dict[str, Any]], *, parallel: bool +) -> None: + """Fire every op's dispatch, recording a per-op failure without aborting the batch. + + ``parallel`` fans the dispatches out through :func:`asyncio.gather` with + ``return_exceptions=True`` so one op's ``async_call`` raising is captured on THAT + op (``error`` set, ``dispatched`` left ``False``) and the others still run; + ``parallel=False`` awaits them in order, catching each op's failure the same way. + Neither mode propagates a per-op dispatch error — that is the whole point of the + batch (UNLIKE the pre-dispatch D1/ServiceNotFound guards, which DO raise). + """ + import asyncio + + if parallel: + outcomes = await asyncio.gather( + *(_bulk_dispatch_one(hass, op) for op in ops), + return_exceptions=True, + ) + for op, outcome in zip(ops, outcomes, strict=True): + if isinstance(outcome, BaseException): + op["error"] = _bulk_op_error(outcome) + else: + for op in ops: + try: + await _bulk_dispatch_one(hass, op) + except Exception as err: + op["error"] = _bulk_op_error(err) + + +def _bulk_match_immediate(hass: HomeAssistant, ops: list[dict[str, Any]]) -> None: + """Immediate-match every dispatched, confirmable op (idempotent no-ops). + + Runs the SAME raise-proof :func:`_match_immediate` per op AFTER the batch + dispatch and BEFORE the shared wait: an op whose target already sits at its + expected hint (a ``turn_on`` on an already-on light) is captured here so + :func:`_bulk_wait_all` skips it — no full-timeout stall for a batch of no-ops. + Raise-proof (``_match_immediate`` guards each re-read), so it cannot propagate + past the batch dispatch (I1). + """ + for op in ops: + if op["should_confirm"] and op["dispatched"]: + _match_immediate( + hass, op["entity_ids"], op["expected_by_entity"], op["captured"] + ) + + +async def _bulk_wait_all(ops: list[dict[str, Any]], timeout: float) -> None: + """Bounded confirmation wait for the batch: ONE shared deadline (D4). + + Waits up to ``timeout`` for every dispatched, confirmable op's transition on a + single shared deadline (not per-op serial timeouts). An op already FULLY captured + by the immediate-match (:func:`_bulk_match_immediate`) is skipped — its ``evt`` was + never ``set`` (the match populates ``captured`` directly), so waiting on it would + stall the whole batch to the timeout. Expiry is swallowed — whichever ops did not + report are ``partial`` (never a failure); the ops that did report stay confirmed. + A batch with nothing left to confirm returns immediately. + """ + import asyncio + + waiters = [ + op["evt"].wait() + for op in ops + if op["should_confirm"] + and op["dispatched"] + and not (set(op["entity_ids"]) <= set(op["captured"])) + ] + if not waiters: + return + try: + await asyncio.wait_for(asyncio.gather(*waiters), timeout) + except TimeoutError: + pass # partial confirmation for whichever ops did not report (D4) + + +def _bulk_op_error(exc: BaseException) -> str: + """A short, stable error string for a per-op dispatch failure. + + A per-op ``async_call`` exception under the batch is recorded here (never + propagated past the frame — the other ops still return their results), so the + server can surface which op failed and why without the whole batch aborting. + """ + text = str(exc).strip() + return f"{type(exc).__name__}: {text}" if text else type(exc).__name__ + + +def _build_bulk_op_result(hass: HomeAssistant, op: Mapping[str, Any]) -> dict[str, Any]: + """Assemble one op's result envelope from its captured transition. + + Reuses the single-call :func:`_call_service_transition` / :func:`_post_state` + diff helpers so the per-op transition shape is byte-identical to + ``call_service``. ``confirmed`` requires the op to have DISPATCHED and every + target to have reported within the shared wait; ``partial`` is a dispatched-but + -unconfirmed op (never a failure); an op whose ``async_call`` raised carries + ``error`` with ``dispatched: false`` and is neither confirmed nor partial. + """ + entity_ids = list(op["entity_ids"]) + captured = op["captured"] + should_confirm = op["should_confirm"] + dispatched = op["dispatched"] + transitions = [ + _call_service_transition( + eid, op["pre"].get(eid), _post_state(hass, eid, captured) + ) + for eid in entity_ids + ] + confirmed = bool(should_confirm and dispatched and set(entity_ids) <= set(captured)) + result: dict[str, Any] = { + "domain": op["domain"], + "service": op["service"], + "entity_ids": entity_ids, + "dispatched": dispatched, + "confirmed": confirmed, + "partial": bool(should_confirm and dispatched and not confirmed), + "transitions": transitions, + } + if op["error"] is not None: + result["error"] = op["error"] + return result + + +def _build_bulk_result( + hass: HomeAssistant, ops: list[dict[str, Any]] +) -> dict[str, Any]: + """The batch envelope: every op's result plus the batch counts. + + ``dispatched`` counts ops whose single ``async_call`` fired; ``failed`` counts + ops that recorded a per-op ``error`` (dispatch raised). ``total`` is the batch + size, so ``total - dispatched`` is the refused/failed-before-landing count. + """ + op_results = [_build_bulk_op_result(hass, op) for op in ops] + return { + "operations": op_results, + "total": len(op_results), + "dispatched": sum(1 for r in op_results if r["dispatched"]), + "failed": sum(1 for r in op_results if r.get("error") is not None), + } + + +def _dispatched_unconfirmed_bulk_result( + ops: list[dict[str, Any]], +) -> dict[str, Any]: + """Minimal batch envelope when post-dispatch assembly raised (I1 total-formatting). + + Every op already fired (or recorded a pre-landing ``error``); building the rich + transition rows raised (an exotic attribute value / serialization edge). Report + each op with empty transitions rather than propagating — a propagated raise would + become a command error the server maps to legacy and re-dispatch every landed op + (double-fire). Reads only each record's plain ``domain``/``service``/``entity_ids`` + /``dispatched``/``should_confirm``/``error`` fields (never the rich captured + state), so it cannot itself raise; the real per-op ``dispatched``/``error`` are + preserved so a genuinely-failed op is not misreported as landed. + """ + op_results = [ + { + "domain": op["domain"], + "service": op["service"], + "entity_ids": list(op["entity_ids"]), + "dispatched": op["dispatched"], + "confirmed": False, + "partial": bool(op["should_confirm"] and op["dispatched"]), + "transitions": [], + **({"error": op["error"]} if op["error"] is not None else {}), + } + for op in ops + ] + return { + "operations": op_results, + "total": len(op_results), + "dispatched": sum(1 for r in op_results if r["dispatched"]), + "failed": sum(1 for r in op_results if r.get("error") is not None), + } diff --git a/custom_components/ha_mcp_tools/yaml_rt.py b/custom_components/ha_mcp_tools/yaml_rt.py index b09c2f6..6212f2e 100644 --- a/custom_components/ha_mcp_tools/yaml_rt.py +++ b/custom_components/ha_mcp_tools/yaml_rt.py @@ -2,13 +2,16 @@ from __future__ import annotations +import math import re import threading from collections.abc import Callable +from datetime import date, datetime from io import StringIO from typing import Any from ruamel.yaml import YAML +from ruamel.yaml.scalarbool import ScalarBoolean class _TaggedScalar: @@ -171,3 +174,57 @@ def yaml_dumps(ry: YAML, data: Any) -> str: buf = StringIO() ry.dump(data, buf) return buf.getvalue() + + +def _jsonify_float(node: float) -> float | str: + """Narrow a float to something json can encode. + + ``.inf``/``.nan`` are valid YAML with no JSON encoding, so they render back + to their YAML source form — the same treatment a tag gets. + """ + if math.isnan(node): + return ".nan" + if math.isinf(node): + return ".inf" if node > 0 else "-.inf" + return float(node) + + +def yaml_jsonify(node: Any) -> Any: + """Convert a round-trip node into JSON-serializable plain Python. + + An HA tag is rendered back to its SOURCE form (``!secret api_key``), never + resolved: the value behind a ``!secret`` lives in secrets.yaml and is not + looked up here, so a parsed view carries no plaintext-secret surface — the + same property the round-trip text view has. Lives here because this module + owns ``_TaggedScalar`` and the tag registry. + + ruamel's scalar types subclass the builtins (``ScalarInt``/``ScalarFloat``/ + ``ScalarString``), so they are narrowed to the plain type; timestamps + (``!!timestamp``) come back as ``date``/``datetime``, which json cannot + encode, and become ISO strings. Non-finite floats (``.inf``/``.nan``) have + no JSON encoding either, so they render back to their YAML source form — + the same treatment a tag gets. + """ + if isinstance(node, _TaggedScalar): + return f"{node.tag} {node.value}".strip() + if isinstance(node, dict): + return {str(key): yaml_jsonify(value) for key, value in node.items()} + if isinstance(node, (list, tuple)): + return [yaml_jsonify(item) for item in node] + # Both branches must precede int: plain bool subclasses int, and a bool + # carrying an anchor loads as ruamel's ScalarBoolean, which subclasses int + # WITHOUT subclassing bool — so an `enabled: &flag true` would otherwise + # serialize as 1. + if node is None or isinstance(node, bool): + return node + if isinstance(node, ScalarBoolean): + return bool(node) + if isinstance(node, int): + return int(node) + if isinstance(node, float): + return _jsonify_float(node) + if isinstance(node, str): + return str(node) + if isinstance(node, (datetime, date)): + return node.isoformat() + return str(node) diff --git a/custom_components/ha_washdata/__init__.py b/custom_components/ha_washdata/__init__.py index ee7c977..7395514 100644 --- a/custom_components/ha_washdata/__init__.py +++ b/custom_components/ha_washdata/__init__.py @@ -75,15 +75,9 @@ from .const import ( CONF_SMOOTHING_WINDOW, CONF_PROFILE_DURATION_TOLERANCE, CONF_INTERRUPTED_MIN_SECONDS, - CONF_ABRUPT_DROP_WATTS, - CONF_ABRUPT_DROP_RATIO, - CONF_ABRUPT_HIGH_LOAD_FACTOR, DEFAULT_SMOOTHING_WINDOW, DEFAULT_PROFILE_DURATION_TOLERANCE, DEFAULT_INTERRUPTED_MIN_SECONDS, - DEFAULT_ABRUPT_DROP_WATTS, - DEFAULT_ABRUPT_DROP_RATIO, - DEFAULT_ABRUPT_HIGH_LOAD_FACTOR, CONF_PROFILE_MATCH_INTERVAL, CONF_PROFILE_MATCH_MIN_DURATION_RATIO, CONF_PROFILE_MATCH_MAX_DURATION_RATIO, @@ -142,7 +136,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - if version == 3 and minor_version >= 6: + if version == 3 and minor_version >= 7: + return True + + # 3.6 → 3.7: remove initial_profile stub key from entry.data. + if version == 3 and minor_version == 6: + new_data = {k: v for k, v in entry.data.items() if k != "initial_profile"} + hass.config_entries.async_update_entry( + entry, data=new_data, minor_version=7 + ) + minor_version = 7 + _log.debug("Migrated WashData entry from 3.6 to 3.7") + + if version == 3 and minor_version >= 7: return True data: dict[str, Any] = dict(entry.data) @@ -185,9 +191,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_PROFILE_DURATION_TOLERANCE, DEFAULT_PROFILE_DURATION_TOLERANCE ) options.setdefault(CONF_INTERRUPTED_MIN_SECONDS, DEFAULT_INTERRUPTED_MIN_SECONDS) - options.setdefault(CONF_ABRUPT_DROP_WATTS, DEFAULT_ABRUPT_DROP_WATTS) - options.setdefault(CONF_ABRUPT_DROP_RATIO, DEFAULT_ABRUPT_DROP_RATIO) - options.setdefault(CONF_ABRUPT_HIGH_LOAD_FACTOR, DEFAULT_ABRUPT_HIGH_LOAD_FACTOR) options.setdefault( CONF_DEVICE_TYPE, data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE) @@ -280,10 +283,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data=data, options=options, version=3, - minor_version=6, + minor_version=7, ) _log.info( - "Migrated WashData entry from version %s.%s to 3.6", version, minor_version + "Migrated WashData entry from version %s.%s to 3.7", version, minor_version ) return True @@ -370,28 +373,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await manager.async_setup() await _migrate_online_to_global(hass, entry, manager) - # Check for initial profile from onboarding - if "initial_profile" in entry.data: - init_prof = entry.data["initial_profile"] - name = init_prof.get("name") - duration = init_prof.get("avg_duration") - if name: - try: - # Create the profile immediately - await manager.profile_store.create_profile_standalone( - name, avg_duration=duration - ) - manager._logger.info("Created initial profile '%s' from onboarding", name) - - # Clean up config entry (remove initial_profile to avoid re-creation or cruft) - new_data = { - k: v for k, v in entry.data.items() if k != "initial_profile" - } - hass.config_entries.async_update_entry(entry, data=new_data) - - except Exception as e: # pylint: disable=broad-exception-caught - manager._logger.error("Failed to create initial profile: %s", e) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) _apply_device_link(hass, entry) diff --git a/custom_components/ha_washdata/analysis.py b/custom_components/ha_washdata/analysis.py index ceddcae..195d1f3 100644 --- a/custom_components/ha_washdata/analysis.py +++ b/custom_components/ha_washdata/analysis.py @@ -24,6 +24,8 @@ import numpy as np from .const import ( DEFAULT_DTW_MODE, + DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO, + DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO, MATCH_CORR_WEIGHT, MATCH_DDTW_DIST_SCALE, MATCH_DTW_BLEND, @@ -270,8 +272,8 @@ def compute_matches_worker( """Worker function to compute matches against snapshots.""" candidates: list[dict[str, Any]] = [] - min_duration_ratio = config.get("min_duration_ratio", 0.07) - max_duration_ratio = config.get("max_duration_ratio", 1.3) + min_duration_ratio = config.get("min_duration_ratio", DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO) + max_duration_ratio = config.get("max_duration_ratio", DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO) dtw_bandwidth = config.get("dtw_bandwidth", 0.1) dtw_mode = config.get("dtw_mode", DEFAULT_DTW_MODE) keep_min = float(config.get("keep_min_score", MATCH_KEEP_MIN_SCORE)) diff --git a/custom_components/ha_washdata/config_flow.py b/custom_components/ha_washdata/config_flow.py index 6fc2151..1e2dd75 100644 --- a/custom_components/ha_washdata/config_flow.py +++ b/custom_components/ha_washdata/config_flow.py @@ -147,7 +147,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # pylint: disable=a """Handle a config flow for WashData.""" VERSION = 3 - MINOR_VERSION = 6 + MINOR_VERSION = 7 def __init__(self) -> None: """Initialize the config flow.""" @@ -182,42 +182,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # pylint: disable=a ) self._user_input = user_input - return await self.async_step_first_profile() - - async def async_step_first_profile( - self, user_input: dict[str, Any] | None = None # pylint: disable=unused-argument - ) -> FlowResult: - """Step to optionally create the first profile.""" - - if user_input is not None: - profile_name = user_input.get("profile_name", "").strip() - data = dict(self._user_input) - - if profile_name: - duration_mins = user_input.get("manual_duration") - duration_sec = (duration_mins * 60.0) if duration_mins else None - data["initial_profile"] = { - "name": profile_name, - "avg_duration": duration_sec, - } - - return self.async_create_entry(title=data[CONF_NAME], data=data) - - schema = vol.Schema( - { - vol.Optional("profile_name"): str, - vol.Optional("manual_duration", default=120): selector.NumberSelector( - selector.NumberSelectorConfig( - min=0, - max=480, - unit_of_measurement="min", - mode=selector.NumberSelectorMode.BOX, - ) - ), - } - ) - - return self.async_show_form(step_id="first_profile", data_schema=schema) + return self.async_create_entry(title=self._user_input[CONF_NAME], data=self._user_input) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None diff --git a/custom_components/ha_washdata/const.py b/custom_components/ha_washdata/const.py index 3b72b4e..f456abd 100644 --- a/custom_components/ha_washdata/const.py +++ b/custom_components/ha_washdata/const.py @@ -96,13 +96,33 @@ CONF_POWER_OFF_DELAY = ( "power_off_delay" # Seconds below the power-off threshold before Finished/Clean -> Off ) CONF_EXPOSE_DEBUG_ENTITIES = "expose_debug_entities" # Expose detailed debug sensors +# Per-device opt-in: blend the phase-resolved (per-role budget) ETA into the +# time-remaining estimate for phase-matching-supported device types (washing +# machine, washer-dryer). Default off. Validated by the Phase-0 ETA gate; see +# docs/superpowers/specs/2026-07-17-phase-segmented-matching-design.md. +CONF_ENABLE_PHASE_MATCHING = "enable_phase_matching" +# Phase-structure consistency advisory (Profiles tab, never a notification). +# A single-program/temperature profile should have a fairly consistent heating +# block; wildly varying heating time or heating present in only some cycles +# usually means different programs/temperatures were labelled under one profile +# (the "mixed labels" data-hygiene problem). Pure statistics from the cached +# phase profile - no relabeling (phase matching does not label better than the +# whole-cycle matcher; see the Phase-0 gate). +# Minimum member cycles before a profile's cached phase profile is trusted to +# drive the live phase-resolved ETA (mirrors the envelope's cycle_count>=2 gate); +# below this the priors are too noisy (single-cycle -> zero variance) and the +# estimate falls back to the classic one. Design §6 cold-start floor. +PHASE_PROFILE_MIN_CYCLES = 2 +PHASE_CONSISTENCY_MIN_CYCLES = 4 +# Heating-time std/mean above this -> likely mixed temperatures under one label. +# A clean single-temperature profile sits ~0.2 (load variation only); a profile +# mixing 30/40/90C sits ~0.45-0.6, so 0.45 catches genuine mixing with margin. +PHASE_HEAT_CV_WARN = 0.45 +PHASE_HEAT_OCC_MIXED_LO = 0.25 # heating present in only 25%-75% of cycles -> +PHASE_HEAT_OCC_MIXED_HI = 0.75 # mixed with a non-heating program CONF_SAVE_DEBUG_TRACES = ( "save_debug_traces" # Improve historical cycle data with rich debug info ) -# Cycle interruption detection settings (not exposed in UI, but used internally) -CONF_ABRUPT_DROP_WATTS = "abrupt_drop_watts" # Power cliff threshold for interrupted status -CONF_ABRUPT_DROP_RATIO = "abrupt_drop_ratio" # Relative drop ratio for interrupted status -CONF_ABRUPT_HIGH_LOAD_FACTOR = "abrupt_high_load_factor" # High load factor threshold CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD = "auto_tune_noise_events_threshold" # Noise events before auto-tune CONF_EXTERNAL_END_TRIGGER_ENABLED = "external_end_trigger_enabled" # Enable external cycle end trigger CONF_EXTERNAL_END_TRIGGER = "external_end_trigger" # Binary sensor entity for external cycle end @@ -138,6 +158,14 @@ CONF_NOTIFY_CHANNEL = "notify_channel" # Android channel for status/live/remind CONF_NOTIFY_FINISH_CHANNEL = "notify_finish_channel" # Distinct Android channel for finished/clean CONF_ENERGY_PRICE_STATIC = "energy_price_static" CONF_ENERGY_PRICE_ENTITY = "energy_price_entity" +# Optional external cumulative energy meter (issue #316). When set, each cycle's +# reported energy is taken from this counter's start->end delta instead of the +# integrated power trace, which systematically under-counts on report-on-change +# plugs. Strictly opt-in: with no entity configured, behaviour is unchanged. The +# integrated value is always still computed and stored (energy_wh) so matching, +# ML and anomaly stats stay internally consistent; the meter only supplies the +# user-facing reported figure (cost, lifetime total, notifications, panel). +CONF_ENERGY_SENSOR = "energy_sensor" # Peak-rate awareness: when the current price meets/exceeds this threshold, the # start notification gets an informational tip appended (purely advisory). CONF_PEAK_RATE_THRESHOLD = "peak_rate_threshold" @@ -323,10 +351,6 @@ TERMINAL_DROP_MIN_PEAK_RATIO = 5.0 # cycle must have been clearly ON (peak # rather than assumed to be a stop. TERMINAL_DROP_PEAK_FAMILIAR_TOL = 0.4 -# Cycle interruption detection defaults (internal) -DEFAULT_ABRUPT_DROP_WATTS = 500.0 # Power cliff detection threshold (W) -DEFAULT_ABRUPT_DROP_RATIO = 0.6 # 60% drop considered abrupt -DEFAULT_ABRUPT_HIGH_LOAD_FACTOR = 5.0 # High load factor threshold DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD = 3 # Ghost cycles before threshold adjustment # Anti-wrinkle defaults (advanced; disabled by default) @@ -435,6 +459,8 @@ REFERENCE_PROFILE_CURVE_POINTS = 50 # actually DROPPING (62.7%->59.9%). Raising weight alone at the old loose scale # inflated both recall and FP (net-negative), so both knobs move together. MATCH_DURATION_WEIGHT = 0.22 +# Despite the name, "energy" here means mean power (W), not Wh — the Stage-4 +# agreement term compares cur_energy=mean(curr_arr) vs profile_mean_power. MATCH_ENERGY_WEIGHT = 0.22 MATCH_DURATION_SCALE = 0.175 # ~ln ratio at which duration agreement halves MATCH_ENERGY_SCALE = 0.25 # ~ln ratio at which energy agreement halves @@ -688,7 +714,10 @@ GROUP_MIN_COHESION = 0.80 # v9: pre-initialize additive top-level keys (lifetime_energy_wh, # settings_changelog, maintenance_log) so they are present from first load # rather than only appearing lazily on first use. -STORAGE_VERSION = 10 +# v11 is a marker-only bump: per-phase profiles (envelope["phase_profile"]) are +# derived cache populated by async_rebuild_envelope, so no data migration is +# needed - they self-populate on the next envelope rebuild. +STORAGE_VERSION = 11 STORAGE_KEY = "ha_washdata" # Notification events diff --git a/custom_components/ha_washdata/cycle_detector.py b/custom_components/ha_washdata/cycle_detector.py index 0e3c24d..4d88c2d 100644 --- a/custom_components/ha_washdata/cycle_detector.py +++ b/custom_components/ha_washdata/cycle_detector.py @@ -107,9 +107,6 @@ class CycleDetectorConfig: device_type: str = DEVICE_TYPE_WASHING_MACHINE smoothing_window: int = 5 interrupted_min_seconds: int = 150 - abrupt_drop_watts: float = 500.0 - abrupt_drop_ratio: float = 0.6 - abrupt_high_load_factor: float = 5.0 completion_min_seconds: int = 600 start_duration_threshold: float = 5.0 start_energy_threshold: float = 0.005 @@ -273,7 +270,6 @@ class CycleDetector: self._matched_profile: str | None = None self._verified_pause: bool = False - self._abrupt_drop: bool = False self._last_power: float | None = None self._time_in_state: float = 0.0 @@ -780,7 +776,6 @@ class CycleDetector: self._energy_since_idle_wh = power * (dt / 3600.0) if dt > 0 else 0.0 self._cycle_max_power = max(candidate_peak, power) - self._abrupt_drop = False elif self._state != STATE_ANTI_WRINKLE: self._anti_wrinkle_candidate_start = None self._anti_wrinkle_candidate_peak = 0.0 @@ -891,7 +886,6 @@ class CycleDetector: self._power_readings = [(timestamp, power)] self._energy_since_idle_wh = power * (dt / 3600.0) if dt > 0 else 0.0 self._cycle_max_power = power - self._abrupt_drop = False # NOTE: terminal-state expiry (Finished/Interrupted/Force-Stopped -> Off) # is owned solely by the manager (WashDataManager._handle_state_expiry), # which has a wall-clock timer that also fires when a change-only power @@ -941,7 +935,6 @@ class CycleDetector: if timestamp != start_timestamp: self._power_readings.append((timestamp, power)) self._cycle_max_power = max(start_power, power) - self._abrupt_drop = False else: # Power dropped back below start threshold - clear the # high-power streak anchor so the next high reading @@ -1729,10 +1722,6 @@ class CycleDetector: status = "interrupted" elif duration < self._config.completion_min_seconds: status = "interrupted" - elif self._abrupt_drop and duration < ( - self._config.interrupted_min_seconds + 90 - ): - status = "interrupted" # Trim leading/trailing zero readings for cleaner data # If we keep tail, we explicitly do NOT trim end zeros diff --git a/custom_components/ha_washdata/diagnostics.py b/custom_components/ha_washdata/diagnostics.py index 855674c..62d1842 100644 --- a/custom_components/ha_washdata/diagnostics.py +++ b/custom_components/ha_washdata/diagnostics.py @@ -54,6 +54,7 @@ _SENSITIVE_KEYS = { "door_sensor_entity", "switch_entity", "energy_price_entity", + "energy_sensor", } diff --git a/custom_components/ha_washdata/features.py b/custom_components/ha_washdata/features.py index 55d9665..da6f2f3 100644 --- a/custom_components/ha_washdata/features.py +++ b/custom_components/ha_washdata/features.py @@ -35,7 +35,6 @@ class CycleSignature: duration: float total_energy: float max_power: float - event_density: float # Deprecated/reserved: always 0.0 (event detector removed); kept for signature back-compat time_to_first_high: float # Seconds to first HEATER/HIGH phase high_phase_ratio: float # Duration of high phases / total duration # Distributions (quantiles of power) @@ -59,7 +58,7 @@ def compute_signature( """ if len(power) == 0: # Return empty/zero signature - return CycleSignature(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + return CycleSignature(0, 0, 0, 0, 0, 0, 0, 0, 0, 0) duration = timestamps[-1] - timestamps[0] @@ -97,15 +96,10 @@ def compute_signature( else: high_phase_ratio = 0.0 - # Event density: always 0 now that the event detector is gone; retained as a - # signature field for backward compatibility with stored signatures. - event_density = 0.0 - return CycleSignature( duration=float(duration), total_energy=float(total_energy), max_power=float(max_p), - event_density=float(event_density), time_to_first_high=float(time_to_first_high), high_phase_ratio=float(high_phase_ratio), p05=float(qs[0]), diff --git a/custom_components/ha_washdata/manager.py b/custom_components/ha_washdata/manager.py index 84ca084..6ef829e 100644 --- a/custom_components/ha_washdata/manager.py +++ b/custom_components/ha_washdata/manager.py @@ -68,9 +68,6 @@ from .const import ( CONF_SMOOTHING_WINDOW, CONF_PROFILE_DURATION_TOLERANCE, CONF_INTERRUPTED_MIN_SECONDS, - CONF_ABRUPT_DROP_WATTS, - CONF_ABRUPT_DROP_RATIO, - CONF_ABRUPT_HIGH_LOAD_FACTOR, CONF_PROGRESS_RESET_DELAY, CONF_LEARNING_CONFIDENCE, CONF_DURATION_TOLERANCE, @@ -133,9 +130,6 @@ from .const import ( DEFAULT_SMOOTHING_WINDOW, DEFAULT_PROFILE_DURATION_TOLERANCE, DEFAULT_INTERRUPTED_MIN_SECONDS, - DEFAULT_ABRUPT_DROP_WATTS, - DEFAULT_ABRUPT_DROP_RATIO, - DEFAULT_ABRUPT_HIGH_LOAD_FACTOR, DEFAULT_COMPLETION_MIN_SECONDS, DEFAULT_NOTIFY_BEFORE_END_MINUTES, DEFAULT_PROFILE_MATCH_THRESHOLD, @@ -175,6 +169,7 @@ from .const import ( CONF_NOTIFY_FINISH_CHANNEL, CONF_ENERGY_PRICE_STATIC, CONF_ENERGY_PRICE_ENTITY, + CONF_ENERGY_SENSOR, CONF_PEAK_RATE_THRESHOLD, CONF_PEAK_RATE_MESSAGE, DEFAULT_PEAK_RATE_MESSAGE, @@ -249,9 +244,8 @@ from .learning import LearningManager from .profile_store import ( ProfileStore, decompress_power_data, - device_active_peak_range, - earliest_sustained_quiet_offset, is_terminal_drop, + terminal_drop_baseline, ) from .signal_processing import integrate_wh, energy_gap_threshold_s from .recorder import CycleRecorder @@ -260,6 +254,7 @@ from .log_utils import DeviceLoggerAdapter from .time_utils import power_data_to_offsets from . import progress as progress_mod from . import notification_rules as notif_rules +from .phase_segmenter import phase_matching_enabled _LOGGER = logging.getLogger(__name__) @@ -418,6 +413,12 @@ class WashDataManager: # Matching always uses real readings only; this list is for display/anomaly. self._restart_gaps: list[dict[str, Any]] = [] + # External energy-meter snapshot for the current cycle (issue #316). + # Captured at cycle start, read back at cycle end for the start->end delta. + # Both survive a restart via the active-cycle snapshot. + self._energy_meter_start: float | None = None + self._energy_meter_source: str | None = None + # Pause tracking (user-triggered) self._user_pause_start: datetime | None = None self._total_user_paused_seconds: float = 0.0 @@ -607,12 +608,6 @@ class WashDataManager: interrupted_min_seconds = int( config_entry.options.get("interrupted_min_seconds", 150) ) - abrupt_drop_watts = float(config_entry.options.get("abrupt_drop_watts", 500.0)) - abrupt_drop_ratio = float(config_entry.options.get("abrupt_drop_ratio", 0.6)) - abrupt_high_load_factor = float( - config_entry.options.get("abrupt_high_load_factor", 5.0) - ) - # Get device specific default for completion threshold device_default_completion = DEVICE_COMPLETION_THRESHOLDS.get( self.device_type, DEFAULT_COMPLETION_MIN_SECONDS @@ -647,9 +642,6 @@ class WashDataManager: off_delay=int(off_delay), smoothing_window=smoothing_window, interrupted_min_seconds=interrupted_min_seconds, - abrupt_drop_watts=abrupt_drop_watts, - abrupt_drop_ratio=abrupt_drop_ratio, - abrupt_high_load_factor=abrupt_high_load_factor, completion_min_seconds=completion_min_seconds, start_duration_threshold=start_duration_threshold, running_dead_zone=running_dead_zone, @@ -796,6 +788,9 @@ class WashDataManager: self._terminal_drop_cache: ( tuple[int, float | None, tuple[float, float] | None] | None ) = None + # Cycle count an executor refresh of the baseline is in-flight/done for, so + # the loop never recomputes it (issue #311) and never double-schedules. + self._terminal_drop_refresh_n: int | None = None self._remove_listener = None self._remove_external_trigger_listener = None # External cycle end trigger @@ -895,6 +890,11 @@ class WashDataManager: self._logger.debug("Matching skipped: no readings") return + # Skip match entirely when no real profiles exist — nothing to match against. + if not self.profile_store.has_real_profiles: + self._logger.debug("Matching skipped: no real profiles configured yet") + return + self._matching_task = self.hass.async_create_task(self._async_do_perform_matching(readings)) except Exception as e: self._logger.error("Perform combined matching trigger failed: %s", e) @@ -1577,6 +1577,15 @@ class WashDataManager: "manual_program", False ) + # Restore the external energy-meter snapshot (issue #316) so a + # restart mid-cycle keeps an accurate start->end delta. + self._energy_meter_start = active_snapshot_to_restore.get( + "energy_meter_start" + ) + self._energy_meter_source = active_snapshot_to_restore.get( + "energy_meter_source" + ) + # If we restored into a low-power state, ensure we don't # immediately quit. For now we just log this; the cycle # detector's off_delay will handle actual shutdown. @@ -1887,9 +1896,6 @@ class WashDataManager: old_off_delay = self.detector.config.off_delay old_smoothing = self.detector.config.smoothing_window old_interrupted_min = self.detector.config.interrupted_min_seconds - old_abrupt_drop_watts = self.detector.config.abrupt_drop_watts - old_abrupt_drop_ratio = self.detector.config.abrupt_drop_ratio - old_abrupt_high_load = self.detector.config.abrupt_high_load_factor # Get new values from config new_min_power = float( @@ -1904,12 +1910,6 @@ class WashDataManager: CONF_INTERRUPTED_MIN_SECONDS, DEFAULT_INTERRUPTED_MIN_SECONDS ) ) - new_abrupt_drop_watts = float( - config_entry.options.get(CONF_ABRUPT_DROP_WATTS, DEFAULT_ABRUPT_DROP_WATTS) - ) - new_abrupt_drop_ratio = float( - config_entry.options.get(CONF_ABRUPT_DROP_RATIO, DEFAULT_ABRUPT_DROP_RATIO) - ) self.detector.config.match_interval = int( config_entry.options.get( CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL @@ -1918,11 +1918,6 @@ class WashDataManager: self.profile_store.dtw_bandwidth = float( config_entry.options.get(CONF_DTW_BANDWIDTH, DEFAULT_DTW_BANDWIDTH) ) - new_abrupt_high_load = float( - config_entry.options.get( - CONF_ABRUPT_HIGH_LOAD_FACTOR, DEFAULT_ABRUPT_HIGH_LOAD_FACTOR - ) - ) # Device default dev_def = DEVICE_COMPLETION_THRESHOLDS.get( @@ -2017,9 +2012,6 @@ class WashDataManager: self.detector.config.off_delay = new_off_delay self.detector.config.smoothing_window = new_smoothing self.detector.config.interrupted_min_seconds = new_interrupted_min - self.detector.config.abrupt_drop_watts = new_abrupt_drop_watts - self.detector.config.abrupt_drop_ratio = new_abrupt_drop_ratio - self.detector.config.abrupt_high_load_factor = new_abrupt_high_load self.detector.config.completion_min_seconds = new_completion_min self.detector.config.start_duration_threshold = new_start_threshold self.detector.config.running_dead_zone = new_running_dead_zone @@ -2048,14 +2040,10 @@ class WashDataManager: or old_off_delay != new_off_delay or old_smoothing != new_smoothing or old_interrupted_min != new_interrupted_min - or old_abrupt_drop_watts != new_abrupt_drop_watts - or old_abrupt_drop_ratio != new_abrupt_drop_ratio - or old_abrupt_high_load != new_abrupt_high_load ): self._logger.info( - "Updated detector config: min_power %.1fW→%.1fW, off_delay %ds→%ds, " - "smoothing %d→%d, interrupted_min %ds→%ds, abrupt_drop %.0fW→%.0fW, " - "abrupt_ratio %.2f→%.2f, high_load %.1f→%.1f", + "Updated detector config: min_power %.1fW->%.1fW, off_delay %ds->%ds, " + "smoothing %d->%d, interrupted_min %ds->%ds", old_min_power, new_min_power, old_off_delay, @@ -2064,12 +2052,6 @@ class WashDataManager: new_smoothing, old_interrupted_min, new_interrupted_min, - old_abrupt_drop_watts, - new_abrupt_drop_watts, - old_abrupt_drop_ratio, - new_abrupt_drop_ratio, - old_abrupt_high_load, - new_abrupt_high_load, ) # Update profile matching parameters @@ -2190,8 +2172,6 @@ class WashDataManager: self._reset_live_notification_state() self._check_live_progress_notification() - self._logger.info("Configuration reloaded successfully") - # Trigger entity updates to reflect any changes async_dispatcher_send(self.hass, f"ha_washdata_update_{self.entry_id}") @@ -2270,15 +2250,7 @@ class WashDataManager: # Save active state before shutdown if self.detector.state in {STATE_RUNNING, STATE_PAUSED, STATE_STARTING, STATE_ENDING}: - snapshot = self.detector.get_state_snapshot() - snapshot["manual_program"] = self._manual_program_active - snapshot["notified_start"] = self._notified_start - snapshot["start_event_fired"] = self._start_event_fired - snapshot["is_user_paused"] = self._is_user_paused - snapshot["user_pause_start"] = ( - self._user_pause_start.isoformat() if self._user_pause_start else None - ) - snapshot["total_user_paused_seconds"] = self._total_user_paused_seconds + snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot()) await self.profile_store.async_save_active_cycle(snapshot) self._last_reading_time = None @@ -2829,15 +2801,7 @@ class WashDataManager: if not last_save or (now - last_save).total_seconds() > 60: # Fire and forget save task # Inject manual program flag into snapshot before saving - snapshot = self.detector.get_state_snapshot() - snapshot["manual_program"] = self._manual_program_active - snapshot["notified_start"] = self._notified_start - snapshot["start_event_fired"] = self._start_event_fired - snapshot["is_user_paused"] = self._is_user_paused - snapshot["user_pause_start"] = ( - self._user_pause_start.isoformat() if self._user_pause_start else None - ) - snapshot["total_user_paused_seconds"] = self._total_user_paused_seconds + snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot()) self.hass.async_create_task( self.profile_store.async_save_active_cycle(snapshot) @@ -3463,6 +3427,9 @@ class WashDataManager: self._cycle_start_time = self.detector.current_cycle_start or dt_util.now() self._ranking_snapshot_cycle_id = str(uuid.uuid4()) self._reset_live_notification_state() + # Snapshot the external energy meter (issue #316) so cycle end can + # take an accurate start->end delta. No-op when none is configured. + self._snapshot_energy_meter_start() # Reset pause tracking and clean state for new cycle self._is_user_paused = False @@ -3623,6 +3590,16 @@ class WashDataManager: # Store energy for notification and persistence (calculated above for ghost detection) cycle_data["energy_wh"] = round(cycle_energy_wh, 3) + # External energy meter (issue #316): when an accurate start->end delta is + # available, record it alongside the integrated value and mark the source. + # The integrated energy_wh above is left untouched so matching / ML / anomaly + # stats stay internally consistent; only user-facing figures prefer the meter. + cycle_data["energy_source"] = "integration" + meter_wh = self._compute_meter_energy_wh() + if meter_wh is not None: + cycle_data["energy_meter_wh"] = round(meter_wh, 3) + cycle_data["energy_source"] = "meter" + # Schedule heavy post-processing asynchronously. Capture this cycle's identity # token so the async tail can tell if a NEW cycle started while it was awaiting # (power changes are handled synchronously, so a back-to-back load can drive the @@ -3736,23 +3713,53 @@ class WashDataManager: Both are learned from the device's completed cycles and used by the terminal-drop detector (anomaly + familiarity gates). Keyed by cycle - count so it refreshes as history grows without re-decompressing every - trace on each low-power reading.""" + count so it refreshes as history grows. + + The recompute decompresses every completed trace, which is too heavy to run + on the event loop inside the detector's reading path (issue #311). So this + NEVER recomputes synchronously: on a miss/stale cache it schedules an + executor refresh and serves the last known baseline in the meantime (one + cycle stale is harmless for an anomaly heuristic). Until the first refresh + lands there is no baseline, so it returns ``(None, None)`` and + ``is_terminal_drop`` defers to the proven slow end-detection.""" cycles = self.profile_store.get_past_cycles() n = len(cycles) cache = self._terminal_drop_cache if cache is not None and cache[0] == n: return cache[1], cache[2] - stop_threshold = float(self.detector.config.stop_threshold_w) - earliest = earliest_sustained_quiet_offset( - cycles, - stop_threshold, - TERMINAL_DROP_MIN_QUIET_SPAN_S, - TERMINAL_DROP_MIN_CLEAN_CYCLES, - ) - peak_range = device_active_peak_range(cycles, TERMINAL_DROP_MIN_CLEAN_CYCLES) - self._terminal_drop_cache = (n, earliest, peak_range) - return earliest, peak_range + self._schedule_terminal_drop_refresh(n) + if cache is not None: + return cache[1], cache[2] + return None, None + + def _schedule_terminal_drop_refresh(self, n: int) -> None: + """Kick a one-shot executor refresh of the terminal-drop baseline for the + current cycle count, unless one is already in-flight/done for it.""" + if self._terminal_drop_refresh_n == n: + return + self._terminal_drop_refresh_n = n + self.hass.async_create_task(self._async_refresh_terminal_drop_baseline(n)) + + async def _async_refresh_terminal_drop_baseline(self, n: int) -> None: + """Recompute the baseline off the event loop and cache it. Never raises - + the anomaly signal must never break detection.""" + try: + # Snapshot the list on the loop before handing it to the executor. + cycles = list(self.profile_store.get_past_cycles()) + stop_threshold = float(self.detector.config.stop_threshold_w) + earliest, peak_range = await self.hass.async_add_executor_job( + terminal_drop_baseline, + cycles, + stop_threshold, + TERMINAL_DROP_MIN_QUIET_SPAN_S, + TERMINAL_DROP_MIN_CLEAN_CYCLES, + ) + self._terminal_drop_cache = (len(cycles), earliest, peak_range) + except Exception as err: # noqa: BLE001 - anomaly signal must never break detection + self._logger.debug("Terminal-drop baseline refresh failed: %s", err) + # Allow a later reading to retry the refresh for this count. + if self._terminal_drop_refresh_n == n: + self._terminal_drop_refresh_n = None def _ml_progress_percent( self, @@ -3899,6 +3906,102 @@ class WashDataManager: pass return None + def _read_energy_meter(self) -> tuple[float, str] | None: + """Read the configured external energy meter, normalized to Wh. + + Returns ``(value_wh, entity_id)`` or ``None`` when no meter is configured + or its reading is not usable (unknown/unavailable/non-numeric state, or an + unrecognised unit). Never raises -- every failure path returns ``None`` so + the caller falls back to the integrated energy (issue #316). + """ + entity_id = self.config_entry.options.get(CONF_ENERGY_SENSOR) + if not entity_id: + return None + state = self.hass.states.get(entity_id) + if state is None or state.state in (None, "unknown", "unavailable", ""): + return None + try: + value = float(state.state) + except (ValueError, TypeError): + return None + unit = str(state.attributes.get("unit_of_measurement") or "").strip().lower() + # Normalize to Wh. An unrecognised unit is treated as unusable so a + # mis-configured entity falls back rather than reporting a wrong figure. + scale = {"wh": 1.0, "kwh": 1000.0, "mwh": 1_000_000.0}.get(unit) + if scale is None: + return None + return value * scale, entity_id + + def _snapshot_energy_meter_start(self) -> None: + """Capture the meter reading at cycle start (issue #316).""" + snap = self._read_energy_meter() + if snap is None: + self._energy_meter_start = None + self._energy_meter_source = None + else: + self._energy_meter_start, self._energy_meter_source = snap + + def _compute_meter_energy_wh(self) -> float | None: + """Cycle energy from the external meter's start->end delta, or None. + + Falls back (returns ``None``) when: no start was captured; no meter is + configured now; the configured entity differs from the one snapshotted at + start (source changed mid-cycle -- no cross-meter delta); the current + reading is unusable; or the delta is <= 0 (counter reset or stuck plug). + """ + start = self._energy_meter_start + source = self._energy_meter_source + if start is None or source is None: + return None + cur = self._read_energy_meter() + if cur is None: + return None + cur_wh, cur_source = cur + if cur_source != source: + return None + delta = cur_wh - start + if delta <= 0: + return None + return delta + + @staticmethod + def _cycle_report_energy_wh(cycle_data: dict[str, Any]) -> float: + """User-facing reported energy (Wh): meter value if present, else integrated. + + The integrated ``energy_wh`` is always stored and used internally (matching, + ML, anomaly, envelopes); only cost/lifetime/notifications/panel display + prefer the more accurate meter figure when one was captured (issue #316). + """ + meter = cycle_data.get("energy_meter_wh") + if meter is not None: + try: + return float(meter) + except (ValueError, TypeError): + pass + try: + return float(cycle_data.get("energy_wh", 0.0)) + except (ValueError, TypeError): + return 0.0 + + def _augment_active_snapshot(self, snapshot: dict[str, Any]) -> dict[str, Any]: + """Add manager-owned fields to a detector snapshot before persisting. + + The detector snapshot only carries detector state; these fields are owned + by the manager and must survive a restart alongside it. Kept in one place + so every save site (shutdown, periodic, pause, resume) stays consistent. + """ + snapshot["manual_program"] = self._manual_program_active + snapshot["notified_start"] = self._notified_start + snapshot["start_event_fired"] = self._start_event_fired + snapshot["is_user_paused"] = self._is_user_paused + snapshot["user_pause_start"] = ( + self._user_pause_start.isoformat() if self._user_pause_start else None + ) + snapshot["total_user_paused_seconds"] = self._total_user_paused_seconds + snapshot["energy_meter_start"] = self._energy_meter_start + snapshot["energy_meter_source"] = self._energy_meter_source + return snapshot + @staticmethod def _format_vs_typical( duration: float, @@ -4145,7 +4248,9 @@ class WashDataManager: price = self._resolve_energy_price() if price is not None: cycle_data["energy_price"] = price - cycle_data["cost"] = round(cycle_data.get("energy_wh", 0.0) / 1000.0 * price, 4) + cycle_data["cost"] = round( + self._cycle_report_energy_wh(cycle_data) / 1000.0 * price, 4 + ) # Score cycle quality with the ML model before persisting so the score is # stored on the cycle record and available to the learning manager immediately. @@ -4209,7 +4314,7 @@ class WashDataManager: if cycle_persisted: try: await self.profile_store.async_add_lifetime_energy_wh( - cycle_data.get("energy_wh", 0.0) + self._cycle_report_energy_wh(cycle_data) ) except Exception as e: # pylint: disable=broad-exception-caught self._logger.debug("Failed to accumulate lifetime energy: %s", e) @@ -4269,7 +4374,7 @@ class WashDataManager: duration_min = int(cycle_data['duration'] / 60) program_name = event_cycle_data.get("profile_name", "unknown") - energy_kwh = round(cycle_data.get("energy_wh", 0.0) / 1000, 3) + energy_kwh = round(self._cycle_report_energy_wh(cycle_data) / 1000, 3) # Reuse the cost frozen onto the cycle above (same price resolution). cost_val = cycle_data.get("cost") @@ -5252,6 +5357,10 @@ class WashDataManager: # the waiting message again. self._live_waiting_notification_sent = False if not has_profile_match: + # Suppress the waiting notification when no profiles exist at all — + # the setup card explains the state instead. + if not self.profile_store.has_real_profiles: + return if self._live_waiting_notification_sent: return @@ -5650,6 +5759,24 @@ class WashDataManager: trace, duration_so_far, self._current_program ) ml_pct = self._ml_progress_percent(trace, self._current_program) + + # Opt-in phase-resolved ETA (washing machine / washer-dryer only). Segment + # the observed-so-far trace, match against cached per-profile phase profiles, + # and blend the per-role budget remaining into the estimate (progress.py + # owns the blend). Gated + guarded: any failure leaves the proven estimate + # untouched (phase_remaining_s stays None -> byte-identical behaviour). + phase_remaining_s: float | None = None + if ( + len(trace) >= 10 + and self._current_program not in ("detecting...", "off", None) + and phase_matching_enabled(self.config_entry.options, self.device_type) + ): + pr = self.profile_store.phase_remaining( + trace, self.device_type, self._current_program + ) + if pr is not None: + phase_remaining_s = pr.get("remaining_s") + result = progress_mod.compute_progress( self.device_type, float(self._matched_profile_duration), @@ -5658,6 +5785,7 @@ class WashDataManager: phase_result, ml_pct, self._logger, + phase_remaining_s=phase_remaining_s, ) self._cycle_progress = result.progress @@ -6135,15 +6263,7 @@ class WashDataManager: self.detector.set_verified_pause(prev_verified) return False - snapshot = self.detector.get_state_snapshot() - snapshot["manual_program"] = self._manual_program_active - snapshot["notified_start"] = self._notified_start - snapshot["start_event_fired"] = self._start_event_fired - snapshot["is_user_paused"] = self._is_user_paused - snapshot["user_pause_start"] = ( - self._user_pause_start.isoformat() if self._user_pause_start else None - ) - snapshot["total_user_paused_seconds"] = self._total_user_paused_seconds + snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot()) self.hass.async_create_task(self.profile_store.async_save_active_cycle(snapshot)) self._notify_update() return True @@ -6203,15 +6323,7 @@ class WashDataManager: # early with the card still up, matching the real (still-paused) state. self._clear_timer_pause_notification() - snapshot = self.detector.get_state_snapshot() - snapshot["manual_program"] = self._manual_program_active - snapshot["notified_start"] = self._notified_start - snapshot["start_event_fired"] = self._start_event_fired - snapshot["is_user_paused"] = self._is_user_paused - snapshot["user_pause_start"] = ( - self._user_pause_start.isoformat() if self._user_pause_start else None - ) - snapshot["total_user_paused_seconds"] = self._total_user_paused_seconds + snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot()) self.hass.async_create_task(self.profile_store.async_save_active_cycle(snapshot)) self._notify_update() return True diff --git a/custom_components/ha_washdata/manifest.json b/custom_components/ha_washdata/manifest.json index fb7a047..a96b265 100644 --- a/custom_components/ha_washdata/manifest.json +++ b/custom_components/ha_washdata/manifest.json @@ -21,5 +21,5 @@ "requirements": [ "numpy" ], - "version": "0.5.0" + "version": "0.5.1" } \ No newline at end of file diff --git a/custom_components/ha_washdata/ml/cycle_end_detector_model.py b/custom_components/ha_washdata/ml/cycle_end_detector_model.py index a7e7c54..74d1e86 100644 --- a/custom_components/ha_washdata/ml/cycle_end_detector_model.py +++ b/custom_components/ha_washdata/ml/cycle_end_detector_model.py @@ -43,6 +43,8 @@ MODEL_NAME = 'cycle_end_detector' MODEL_TARGET = 'cycle_truly_ended' MODEL_KIND = 'standardized_logistic' TARGET_UNITS = '' +# Training artifact for select_threshold only — NOT used at live inference. +# The live defer-finish gate reads DEFAULT_DEFER_FINISH_CONFIDENCE from const.py. THRESHOLD = 0.6 FEATURE_COLUMNS = [ diff --git a/custom_components/ha_washdata/ml/hybrid_curve_quality_model.py b/custom_components/ha_washdata/ml/hybrid_curve_quality_model.py index 280c432..515af4a 100644 --- a/custom_components/ha_washdata/ml/hybrid_curve_quality_model.py +++ b/custom_components/ha_washdata/ml/hybrid_curve_quality_model.py @@ -43,6 +43,8 @@ MODEL_NAME = 'hybrid_curve_quality' MODEL_TARGET = 'problem_cycle' MODEL_KIND = 'standardized_logistic' TARGET_UNITS = '' +# Training artifact for select_threshold only — NOT used at live inference. +# The live quality-suspicious gate reads ML_QUALITY_SUSPICIOUS_THRESHOLD from const.py. THRESHOLD = 0.19 FEATURE_COLUMNS = [ diff --git a/custom_components/ha_washdata/ml/live_match_commit_model.py b/custom_components/ha_washdata/ml/live_match_commit_model.py index 44a1f6e..7cc4707 100644 --- a/custom_components/ha_washdata/ml/live_match_commit_model.py +++ b/custom_components/ha_washdata/ml/live_match_commit_model.py @@ -43,6 +43,8 @@ MODEL_NAME = 'live_match_commit' MODEL_TARGET = 'match_top1_correct' MODEL_KIND = 'standardized_logistic' TARGET_UNITS = '' +# Training artifact for select_threshold only — NOT used at live inference. +# The live early-commit gate reads ML_MATCH_COMMIT_THRESHOLD from const.py. THRESHOLD = 0.371786 FEATURE_COLUMNS = [ diff --git a/custom_components/ha_washdata/phase_match.py b/custom_components/ha_washdata/phase_match.py new file mode 100644 index 0000000..1689800 --- /dev/null +++ b/custom_components/ha_washdata/phase_match.py @@ -0,0 +1,320 @@ +# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs. +# Copyright (C) 2026 Lukas Bandura +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Phase-aware profile matching and phase-resolved ETA (Phase 0 prototype). + +Given a cycle segmented into phases (:mod:`phase_segmenter`), this module: + +* builds a per-profile :class:`PhaseProfile` (per-role duration/energy priors) + from the profile's member cycles; +* scores candidate profiles by **per-role** duration + energy agreement, so two + profiles that differ mainly in heating length (i.e. temperature) are separated + by a large per-phase signal instead of a small whole-cycle-correlation delta; +* projects **time-remaining** as a per-role budget (Σ expected_role_total − + consumed_role), which personalises the ETA to the matched variant. + +The matcher handles **partial** (observed-so-far) cycles for progressive +narrowing: completed roles are compared fully; the open current role is scored +one-sided (a candidate is only penalised if the observed duration already +*exceeds* its expected total), so the correct larger-heating variant is not +prematurely ruled out while heating is still in progress. + +Constraint: NumPy only, no Home Assistant imports. Pure and executor-safe; +consumed live (behind the default-off ``enable_phase_matching`` per-device gate) +by ``ProfileStore.phase_remaining`` and the progress blend. See +`docs/superpowers/specs/2026-07-17-phase-segmented-matching-design.md`. +""" +from __future__ import annotations + +import math +from dataclasses import asdict, dataclass + +from .phase_segmenter import ( + ROLE_HEATING, + ROLE_IDLE, + ROLE_SPIN, + ROLE_WASH, + PhaseSegment, +) + +# Per-role weight in the match score. Heating dominates because it is the +# temperature discriminator; the wash middle is load-variable; spin/idle are +# weak/noisy signals. Overridable via the match config. +_ROLE_WEIGHTS: dict[str, float] = { + ROLE_HEATING: 0.50, + ROLE_WASH: 0.25, + ROLE_SPIN: 0.15, + ROLE_IDLE: 0.10, +} +_DUR_SCALE = 0.35 # log-ratio agreement scale for per-role duration +_EN_SCALE = 0.40 # ... and per-role energy +# Agreement score credited when a role is present on one side but absent on the +# other (structural mismatch, e.g. a heating vs no-heating cycle). 0.0 = full +# penalty (structural difference is a strong, wanted discriminator); raise toward +# 1.0 to soften. Config-overridable via ``occ_penalty``. +_OCC_PENALTY = 0.0 + + +@dataclass(frozen=True) +class RoleStat: + """Aggregated per-role prior across a profile's member cycles.""" + + dur_mean: float + dur_std: float + dur_p50: float + en_mean: float + occurrence: float # fraction of member cycles that contain this role + + +@dataclass(frozen=True) +class PhaseProfile: + """Per-profile phase model: per-role priors + total-duration prior.""" + + name: str + roles: dict[str, RoleStat] + total_dur_mean: float + total_dur_std: float + n_cycles: int + + +@dataclass(frozen=True) +class PhaseMatchResult: + name: str + score: float + + +def phase_profile_to_dict(profile: PhaseProfile) -> dict: + """Serialize a :class:`PhaseProfile` for storage (JSON-safe).""" + return { + "name": profile.name, + "n_cycles": profile.n_cycles, + "total_dur_mean": profile.total_dur_mean, + "total_dur_std": profile.total_dur_std, + "roles": {role: asdict(stat) for role, stat in profile.roles.items()}, + } + + +def phase_profile_from_dict(data: dict | None) -> PhaseProfile | None: + """Rebuild a :class:`PhaseProfile` from stored form. Never raises.""" + if not isinstance(data, dict): + return None + try: + roles = { + str(role): RoleStat( + dur_mean=float(s.get("dur_mean", 0.0)), + dur_std=float(s.get("dur_std", 0.0)), + dur_p50=float(s.get("dur_p50", 0.0)), + en_mean=float(s.get("en_mean", 0.0)), + occurrence=float(s.get("occurrence", 0.0)), + ) + for role, s in (data.get("roles") or {}).items() + if isinstance(s, dict) + } + if not roles: + return None + return PhaseProfile( + name=str(data.get("name", "")), + roles=roles, + total_dur_mean=float(data.get("total_dur_mean", 0.0)), + total_dur_std=float(data.get("total_dur_std", 0.0)), + n_cycles=int(data.get("n_cycles", 0)), + ) + except (TypeError, ValueError, AttributeError): + return None + + +def _agree(observed: float, expected: float, scale: float) -> float: + """Log-ratio agreement in (0, 1]; 1.0 when equal, sharper for small scale.""" + if observed <= 0.0 or expected <= 0.0: + # Both ~zero → perfect agreement; one zero → no agreement. + return 1.0 if observed <= 0.0 and expected <= 0.0 else 0.0 + scale = scale if scale > 1e-9 else 1e-9 # avoid div-by-zero on a 0 config scale + return 1.0 / (1.0 + abs(math.log(observed / expected)) / scale) + + +def _role_totals(segments: list[PhaseSegment]) -> dict[str, dict[str, float]]: + """Sum duration + energy per role across a cycle's segments.""" + totals: dict[str, dict[str, float]] = {} + for seg in segments: + acc = totals.setdefault(seg.role, {"dur": 0.0, "en": 0.0}) + acc["dur"] += max(0.0, seg.duration_s) + acc["en"] += max(0.0, seg.energy_wh) + return totals + + +def build_phase_profile(name: str, segmented_cycles: list[list[PhaseSegment]]) -> PhaseProfile | None: + """Aggregate a profile's member cycles into per-role priors. Never raises. + + Returns ``None`` when no usable cycles are supplied. + """ + cycles = [c for c in segmented_cycles if c] + if not cycles: + return None + n = len(cycles) + per_role_durs: dict[str, list[float]] = {} + per_role_ens: dict[str, list[float]] = {} + role_count: dict[str, int] = {} + totals_dur: list[float] = [] + for segs in cycles: + totals = _role_totals(segs) + totals_dur.append(sum(v["dur"] for v in totals.values())) + for role, v in totals.items(): + per_role_durs.setdefault(role, []).append(v["dur"]) + per_role_ens.setdefault(role, []).append(v["en"]) + role_count[role] = role_count.get(role, 0) + 1 + + def _mean(xs: list[float]) -> float: + return float(sum(xs) / len(xs)) if xs else 0.0 + + def _std(xs: list[float], m: float) -> float: + return float((sum((x - m) ** 2 for x in xs) / len(xs)) ** 0.5) if xs else 0.0 + + def _p50(xs: list[float]) -> float: + if not xs: + return 0.0 + s = sorted(xs) + mid = len(s) // 2 + return float(s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2.0) + + roles: dict[str, RoleStat] = {} + for role, durs in per_role_durs.items(): + m = _mean(durs) + roles[role] = RoleStat( + dur_mean=m, dur_std=_std(durs, m), dur_p50=_p50(durs), + en_mean=_mean(per_role_ens[role]), + occurrence=role_count[role] / n, + ) + tm = _mean(totals_dur) + return PhaseProfile( + name=name, roles=roles, + total_dur_mean=tm, total_dur_std=_std(totals_dur, tm), n_cycles=n, + ) + + +def match_phase_profiles( + observed: list[PhaseSegment], + candidates: list[PhaseProfile], + config: dict | None = None, +) -> list[PhaseMatchResult]: + """Rank ``candidates`` for the ``observed`` (full or partial) cycle. Never raises. + + Score = weighted mean over roles of ``sqrt(dur_agree * energy_agree)``, with a + structural (occurrence-mismatch) penalty. The observed cycle's *open* role + (partial cycle) is scored one-sided so a larger-heating candidate is not + ruled out mid-heating. + """ + if not observed or not candidates: + return [] + cfg = config or {} + weights = {**_ROLE_WEIGHTS, **(cfg.get("role_weights") or {})} + dur_scale = float(cfg.get("dur_scale", _DUR_SCALE)) + en_scale = float(cfg.get("en_scale", _EN_SCALE)) + occ_pen = float(cfg.get("occ_penalty", _OCC_PENALTY)) + + totals = _role_totals(observed) + open_role = next((s.role for s in observed if s.open), None) + is_partial = open_role is not None + + results: list[PhaseMatchResult] = [] + for cand in candidates: + all_roles = set(totals) | set(cand.roles) + num = 0.0 + den = 0.0 + for role in all_roles: + w = weights.get(role, 0.1) + if w <= 0: + continue + obs = totals.get(role, {"dur": 0.0, "en": 0.0}) + stat = cand.roles.get(role) + if stat is None: + # Observed a role the candidate never exhibits: structural miss. + if obs["dur"] > 0: + num += w * occ_pen + den += w + continue + if role not in totals: + # Candidate expects a role not yet observed. Future phase on a + # partial cycle → neutral (skip). On a completed cycle → + # structural miss (candidate does a phase this cycle never had). + if not is_partial: + num += w * occ_pen + den += w + continue + if role == open_role: + # One-sided: only penalise if the in-progress duration/energy + # already EXCEEDS what this candidate expects for the whole role + # (a larger-budget candidate must not be ruled out mid-phase). + # Energy is the cleanest temperature discriminator, so score it + # one-sided too rather than dropping it (weaker narrowing). + da = 1.0 if obs["dur"] <= stat.dur_mean else _agree(obs["dur"], stat.dur_mean, dur_scale) + ea = 1.0 if obs["en"] <= stat.en_mean else _agree(obs["en"], stat.en_mean, en_scale) + agree = math.sqrt(da * ea) + else: + da = _agree(obs["dur"], stat.dur_mean, dur_scale) + ea = _agree(obs["en"], stat.en_mean, en_scale) + agree = math.sqrt(da * ea) + num += w * agree + den += w + score = (num / den) if den > 0 else 0.0 + results.append(PhaseMatchResult(name=cand.name, score=float(score))) + + results.sort(key=lambda r: r.score, reverse=True) + return results + + +def phase_eta( + observed: list[PhaseSegment], + profile: PhaseProfile, +) -> float | None: + """Project remaining seconds as a per-role budget. Never raises. + + Classifies each profile role against the observed-so-far segments (design §8): + + * **current** (the open, in-progress role): contribute + ``max(0, dur_mean − consumed)`` using the CONDITIONAL mean (the role is + known present, so no occurrence discount); + * **completed** (a role already observed but not the open one): contribute + **0** — it is done, even if it ran shorter than the profile mean (fixing + the "phantom remaining" over-count); + * **future** (a profile role not yet observed): contribute the + occurrence-weighted prior ``dur_mean × occurrence`` (we do not yet know + whether it will occur). + + Returns ``None`` when no phase priors are available (caller falls back to the + current estimator). + """ + if profile is None or not profile.roles: + return None + consumed = _role_totals(observed) if observed else {} + # The open (in-progress) role. If the caller did not mark one (e.g. a + # completed trace), treat the last observed role as current so completed + # roles still contribute 0 rather than a spurious prior. + open_role = next((s.role for s in (observed or []) if s.open), None) + if open_role is None and observed: + open_role = observed[-1].role + remaining = 0.0 + for role, stat in profile.roles.items(): + done = consumed.get(role, {}).get("dur", 0.0) + if role == open_role: + # current: conditional mean (occurrence -> 1, role is present) + remaining += max(0.0, stat.dur_mean - done) + elif role in consumed: + # completed: nothing more to spend on it + continue + else: + # future: unconditional prior (may or may not occur) + remaining += stat.dur_mean * max(0.0, min(1.0, stat.occurrence)) + return float(max(0.0, remaining)) diff --git a/custom_components/ha_washdata/phase_segmenter.py b/custom_components/ha_washdata/phase_segmenter.py new file mode 100644 index 0000000..c4bd617 --- /dev/null +++ b/custom_components/ha_washdata/phase_segmenter.py @@ -0,0 +1,312 @@ +# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs. +# Copyright (C) 2026 Lukas Bandura +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Unsupervised power-trace phase segmentation (Phase 0 prototype). + +Turns a cycle's power trace into an ordered list of :class:`PhaseSegment` +(role, start/end offset, duration, energy, mean/peak power) via a hysteresis +regime classifier (idle / active / high) + minimum-run merge + role assignment +driven by a per-device-type :class:`PhaseModel`. + +Constraint: NumPy only, no Home Assistant imports. Pure and executor-safe; +``segment_cycle`` never raises - it returns ``[]`` on malformed input. + +Consumed live (behind the default-off ``enable_phase_matching`` per-device gate) +by `profile_store.ProfileStore` (phase-profile cache + `phase_remaining`) and, +offline, by the side-by-side harness (`devtools/eta_phase_eval.py`). See +`docs/superpowers/specs/2026-07-17-phase-segmented-matching-design.md`. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Mapping, Optional + +import numpy as np + +from .const import CONF_ENABLE_PHASE_MATCHING +from .signal_processing import energy_gap_threshold_s, integrate_wh + +# Regime tokens (internal, not user-facing). +_IDLE = 0 +_ACTIVE = 1 +_HIGH = 2 + +# Role tokens. Physically detectable roles; mapping to display phase names +# (phase_catalog.DEFAULT_PHASES_BY_DEVICE) is a later (Phase 6) concern. +ROLE_HEATING = "heating" +ROLE_WASH = "wash" +ROLE_SPIN = "spin" +ROLE_IDLE = "idle" + + +@dataclass(frozen=True) +class PhaseSegment: + """One contiguous phase of a cycle.""" + + role: str + t_start: float # seconds from cycle start + t_end: float + duration_s: float + energy_wh: float + mean_w: float + peak_w: float + open: bool = False # True when this is the final segment of a partial cycle + + +@dataclass(frozen=True) +class PhaseModel: + """Per-device-type segmentation parameters and role vocabulary. + + ``high`` threshold = ``max(high_w_floor, high_frac * robust_peak)`` where the + robust peak is the 95th percentile of the trace, so a heating element is + detected relative to the device's own scale without a single spike inflating + it. ``active_w`` separates motor/fill activity from idle. A terminal + ``active`` block whose mean power >= ``spin_min_w`` and which begins in the + last ``spin_tail_frac`` of the (completed) cycle is labelled ``spin``. + """ + + device_type: str + high_w_floor: float + high_frac: float + active_w: float + spin_min_w: float + spin_tail_frac: float + min_run_s: float + roles: tuple[str, ...] = field(default=(ROLE_HEATING, ROLE_WASH, ROLE_SPIN, ROLE_IDLE)) + + +# Only washing_machine is intended for live rollout first; dishwasher and +# washer-dryer models exist for OFFLINE evaluation in the Phase-0 harness. +_MODELS: dict[str, PhaseModel] = { + "washing_machine": PhaseModel( + device_type="washing_machine", + high_w_floor=800.0, high_frac=0.5, + active_w=30.0, spin_min_w=200.0, spin_tail_frac=0.30, + min_run_s=90.0, + ), + "washer_dryer": PhaseModel( + device_type="washer_dryer", + high_w_floor=800.0, high_frac=0.5, + active_w=30.0, spin_min_w=200.0, spin_tail_frac=0.30, + min_run_s=90.0, + ), + # Dishwasher: heating element is the dominant HIGH regime; the long passive + # dry tail lands in IDLE. Deferred for LIVE rollout (delicate end-of-cycle + # logic) - present here only for offline measurement. + "dishwasher": PhaseModel( + device_type="dishwasher", + high_w_floor=800.0, high_frac=0.5, + active_w=25.0, spin_min_w=250.0, spin_tail_frac=0.25, + min_run_s=120.0, + ), +} + + +# Device types phase matching is rolled out for LIVE (validated by the Phase-0 +# ETA gate). Dishwasher has a model for OFFLINE evaluation only (its ETA gate was +# a no-go and its end-of-cycle logic is delicate), so it is deliberately excluded +# here even though ``phase_model_for("dishwasher")`` returns a model. +LIVE_PHASE_DEVICE_TYPES: tuple[str, ...] = ("washing_machine", "washer_dryer") + + +def phase_model_for(device_type: str | None) -> PhaseModel | None: + """Return the :class:`PhaseModel` for a device type, or ``None`` to fall back. + + ``None`` means the device type has no validated phase model and must use the + existing whole-cycle pipeline (zero-regression fallback). Returns a model for + every type with one defined (incl. dishwasher, for the offline harness). + """ + if not device_type: + return None + return _MODELS.get(str(device_type)) + + +def phase_matching_live_supported(device_type: str | None) -> bool: + """True when phase matching is enabled for LIVE use on this device type. + + Stricter than ``phase_model_for`` - only the Phase-0-validated rollout types + (washing machine, washer-dryer). Used to gate phase-profile caching and the + live ETA path; the offline harness uses ``phase_model_for`` directly. + """ + return ( + device_type in LIVE_PHASE_DEVICE_TYPES + and phase_model_for(device_type) is not None + ) + + +def phase_matching_enabled(options: "Mapping[str, object] | None", device_type: str | None) -> bool: + """True when the user opted in AND the device type is live-supported. + + The opt-in gate for the live phase-resolved ETA blend, mirroring + ``ml.engine.ml_models_enabled``. Both conditions are required: the per-device + ``enable_phase_matching`` option and a validated live phase model. + """ + if not options: + return False + if not bool(options.get(CONF_ENABLE_PHASE_MATCHING, False)): + return False + return phase_matching_live_supported(device_type) + + +def _classify(power: np.ndarray, model: PhaseModel) -> tuple[np.ndarray, float]: + """Per-sample regime tokens + the robust peak used for the HIGH threshold.""" + robust_peak = float(np.percentile(power, 95)) if power.size else 0.0 + high_thr = max(model.high_w_floor, model.high_frac * robust_peak) + reg = np.full(power.shape, _IDLE, dtype=int) + reg[power >= model.active_w] = _ACTIVE + reg[power >= high_thr] = _HIGH + return reg, robust_peak + + +def _runs(reg: np.ndarray) -> list[list[int]]: + """Contiguous same-regime runs as ``[regime, start_idx, end_idx]`` (inclusive).""" + runs: list[list[int]] = [] + n = len(reg) + i = 0 + while i < n: + j = i + while j + 1 < n and reg[j + 1] == reg[i]: + j += 1 + runs.append([int(reg[i]), i, j]) + i = j + 1 + return runs + + +def _merge_short(runs: list[list[int]], t: np.ndarray, min_run_s: float) -> list[list[int]]: + """Absorb runs shorter than ``min_run_s`` into their neighbour. + + A short leading run merges forward; every other short run merges into the + preceding (already-accepted) run. This prevents brief motor spikes during + tumbling from fragmenting a wash phase, and brief dips from splitting a + heating block, without erasing genuine phases. + """ + if not runs: + return runs + merged: list[list[int]] = [] + for reg, a, b in runs: + dur = float(t[b] - t[a]) + if merged and dur < min_run_s: + merged[-1][2] = b # extend previous run to absorb this short one + else: + merged.append([reg, a, b]) + # A short LEADING run has no preceding run to absorb it, so it would survive + # as its own segment (e.g. a startup/inrush HIGH spike becoming a phantom + # heating block that pollutes the temperature prior). Fold it FORWARD into + # the following run, adopting that run's regime. + while len(merged) > 1 and float(t[merged[0][2]] - t[merged[0][1]]) < min_run_s: + merged[1][1] = merged[0][1] # extend 2nd run's start back over the lead + merged.pop(0) + return merged + + +def segment_cycle( + timestamps: "np.ndarray | list[float]", + power: "np.ndarray | list[float]", + model: PhaseModel, + *, + partial: bool = False, +) -> list[PhaseSegment]: + """Segment a power trace into ordered phases. Never raises. + + Args: + timestamps: seconds from cycle start (ascending). + power: watts, same length as ``timestamps``. + model: the device-type :class:`PhaseModel`. + partial: when True the trace is an observed-so-far prefix of a running + cycle; the final segment is marked ``open=True``. + + Returns: + List of :class:`PhaseSegment` in time order, or ``[]`` when the input is + too short/degenerate to segment. Any internal error also yields ``[]``. + """ + try: + return _segment_impl(timestamps, power, model, partial=partial) + except Exception: # noqa: BLE001 - segmentation must never break a live cycle + return [] + + +def _segment_impl( + timestamps: "np.ndarray | list[float]", + power: "np.ndarray | list[float]", + model: PhaseModel, + *, + partial: bool = False, +) -> list[PhaseSegment]: + t = np.asarray(timestamps, dtype=float) + w = np.asarray(power, dtype=float) + if t.size < 4 or w.size != t.size: + return [] + if not (np.all(np.isfinite(t)) and np.all(np.isfinite(w))): + finite = np.isfinite(t) & np.isfinite(w) + t, w = t[finite], w[finite] + if t.size < 4: + return [] + # Enforce ascending time. + order = np.argsort(t, kind="stable") + t, w = t[order], w[order] + + reg, _peak = _classify(w, model) + runs = _merge_short(_runs(reg), t, model.min_run_s) + if not runs: + return [] + + gap_s = energy_gap_threshold_s(t) + total = float(t[-1] - t[0]) + spin_zone_start = t[0] + (1.0 - model.spin_tail_frac) * total if total > 0 else t[-1] + + # First pass: build raw segments with stats. + raw: list[dict] = [] + for reg_tok, a, b in runs: + seg_t = t[a:b + 1] + seg_w = w[a:b + 1] + dur = float(seg_t[-1] - seg_t[0]) if seg_t.size > 1 else 0.0 + energy = integrate_wh(seg_t, seg_w, max_gap_s=gap_s) if seg_t.size > 1 else 0.0 + raw.append({ + "reg": reg_tok, "a": a, "b": b, + "t0": float(seg_t[0]), "t1": float(seg_t[-1]), + "dur": dur, "energy": float(energy), + "mean": float(np.mean(seg_w)), "peak": float(np.max(seg_w)), + }) + + # Determine which ACTIVE run (if any) is the spin: the last non-idle run, + # elevated and starting in the terminal zone. Only on a completed cycle - + # a partial cycle can't know its terminal segment yet. + spin_idx: Optional[int] = None + if not partial: + for idx in range(len(raw) - 1, -1, -1): + seg = raw[idx] + if seg["reg"] == _IDLE: + continue + if (seg["reg"] == _ACTIVE and seg["mean"] >= model.spin_min_w + and seg["t0"] >= spin_zone_start): + spin_idx = idx + break # only inspect the last non-idle run + + out: list[PhaseSegment] = [] + for idx, seg in enumerate(raw): + if seg["reg"] == _HIGH: + role = ROLE_HEATING + elif seg["reg"] == _IDLE: + role = ROLE_IDLE + else: # _ACTIVE + role = ROLE_SPIN if idx == spin_idx else ROLE_WASH + out.append(PhaseSegment( + role=role, t_start=seg["t0"], t_end=seg["t1"], + duration_s=seg["dur"], energy_wh=seg["energy"], + mean_w=seg["mean"], peak_w=seg["peak"], + open=(partial and idx == len(raw) - 1), + )) + return out diff --git a/custom_components/ha_washdata/playground.py b/custom_components/ha_washdata/playground.py index 3e4876c..a2e1cf0 100644 --- a/custom_components/ha_washdata/playground.py +++ b/custom_components/ha_washdata/playground.py @@ -20,22 +20,17 @@ Pure, executor-safe logic behind the panel's Playground tab. Nothing here touches Home Assistant, fires events, or does I/O; the WebSocket handlers in ``ws_api.py`` call these helpers inside ``hass.async_add_executor_job``. -Two entry points: - -- :func:`run_playground_batch` - replays stored cycles through a *fresh* - headless :class:`CycleDetector` (with the device's live settings, optionally - overridden) and returns a structured per-cycle event log + outcome plus an - aggregate summary. The detector is driven exactly as in production - (``process_reading`` fed the cycle's own trace), and a synchronous profile - matcher (the real Stage 1-4 pipeline via ``analysis.compute_matches_worker``) - is wired in so match/ambiguous/unmatched events are captured. No live HA - events are fired - transitions are collected into an in-memory buffer. +Main entry points: +- :func:`simulate_cycle_detail` - faithful single-cycle replay with per-step + progress/remaining-time/phase/energy series and typed event log. +- :func:`run_playground_history` - per-cycle rows + optional before/after diff. +- :func:`run_playground_sweep` - objective 1D/2D grid sweep. - :func:`dtw_debug_payload` - the score breakdown (Stage 2 / DTW / Stage 4), the two resampled traces on a shared grid, and the DTW warping path for one cycle vs one profile (the DTW visualizer). -Both top-level entry points are defensive: they never raise, returning an +All top-level entry points are defensive: they never raise, returning an ``{"error": ...}`` marker instead so the WS handlers can relay it. """ from __future__ import annotations @@ -52,8 +47,8 @@ from homeassistant.util import dt as dt_util from . import analysis from . import notification_rules as notif_rules from . import progress as progress_mod +from .phase_segmenter import phase_matching_enabled from .const import ( - CONF_ABRUPT_DROP_WATTS, CONF_COMPLETION_MIN_SECONDS, CONF_END_REPEAT_COUNT, CONF_INTERRUPTED_MIN_SECONDS, @@ -126,19 +121,35 @@ _OVERRIDE_FIELD_MAP: dict[str, tuple[str, Callable[[Any], Any]]] = { CONF_STOP_THRESHOLD_W: ("stop_threshold_w", float), CONF_RUNNING_DEAD_ZONE: ("running_dead_zone", int), CONF_START_DURATION_THRESHOLD: ("start_duration_threshold", float), - CONF_ABRUPT_DROP_WATTS: ("abrupt_drop_watts", float), CONF_INTERRUPTED_MIN_SECONDS: ("interrupted_min_seconds", int), } # Matching options the Playground honours, mapped to the ``match_config`` key -# (Stage-1 duration gate) they drive. Only options the user can ACTUALLY set in -# Settings are here, so a value found in the Playground can be applied for real - -# the scoring weights (corr/duration/energy) and dtw_bandwidth are ML-tuned, not -# user-settable, so exposing them would be pointless. Anything else in -# ``settings_override`` is ignored. +# they drive. The two duration ratios are real user settings (a good value found +# here can be applied for real). The remaining keys are the Stage 2-4 scoring +# weights / DTW knobs: they are NOT persistent settings (they are ML-tuned +# defaults), but they ARE exposed here as SANDBOX-ONLY overrides so power users +# can experiment with how each stage of the matcher scores their own cycles in +# the Playground. They never persist - a match config built from them lives only +# for the simulation. Anything else in ``settings_override`` is ignored. _MATCH_OVERRIDE_KEYS: dict[str, tuple[str, Callable[[Any], Any]]] = { + # Stage 1 - duration gate (real settings) CONF_PROFILE_MATCH_MIN_DURATION_RATIO: ("min_duration_ratio", float), CONF_PROFILE_MATCH_MAX_DURATION_RATIO: ("max_duration_ratio", float), + # Stage 2 - core similarity (sandbox-only) + "corr_weight": ("corr_weight", float), + "keep_min_score": ("keep_min_score", float), + # Stage 3 - DTW refinement (sandbox-only) + "dtw_bandwidth": ("dtw_bandwidth", float), + "dtw_blend": ("dtw_blend", float), + "dtw_ensemble_w": ("dtw_ensemble_w", float), + "dtw_ddtw_scale": ("dtw_ddtw_scale", float), + "dtw_refine_top_n": ("dtw_refine_top_n", int), + # Stage 4 - duration/energy agreement (sandbox-only) + "duration_weight": ("duration_weight", float), + "energy_weight": ("energy_weight", float), + "duration_scale": ("duration_scale", float), + "energy_scale": ("energy_scale", float), } @@ -229,7 +240,7 @@ def _build_match_snapshots( :meth:`ProfileStore._grouped_snapshots`: returns the collapsed snapshot list (where each cohesive group is represented by a single ``__group__*`` aggregate candidate), plus ``group_members`` and ``member_snaps`` for the - Stage-5 member-resolution step in :func:`_simulate_one`. + Stage-5 member-resolution step in the faithful history runner. Returns ``(grouped_snapshots, match_config, group_members, member_snaps)``. When no cohesive groups exist ``group_members`` and ``member_snaps`` are both @@ -340,314 +351,6 @@ def _readings_from_cycle( return readings, points, base -def _simulate_one( - cycle: dict[str, Any], - sim_config: CycleDetectorConfig, - snapshots: list[dict[str, Any]], - match_config: dict[str, Any], - store: Any = None, - group_members: dict[str, list[str]] | None = None, - member_snaps: dict[str, Any] | None = None, -) -> dict[str, Any]: - """Replay one stored cycle through a fresh headless detector. - - Returns ``{cycle_id, profile_name, events, outcome}``. Never raises. - - When ``group_members`` is non-empty the matcher applies Stage-5 group - resolution: a winning ``__group__*`` aggregate candidate is resolved to its - best-fitting member profile so ``outcome["match_profile"]`` always contains - a real profile name, never a group key. - """ - cycle_id = cycle.get("id") - label = _cycle_label(cycle) - events: list[dict[str, Any]] = [] - outcome: dict[str, Any] = { - "detected": False, - "detected_duration_s": None, - "stored_duration_s": _safe_float(cycle.get("duration")), - "match_profile": None, - "match_correct": None, - "ambiguous": False, - "termination_reason": None, - "status": None, - "detected_count": 0, - } - - try: - readings, _points, base = _readings_from_cycle(cycle) - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.debug("Playground: bad cycle %s: %s", cycle_id, exc) - return { - "cycle_id": cycle_id, - "profile_name": label, - "events": events, - "outcome": outcome, - } - - if len(readings) < 5: - return { - "cycle_id": cycle_id, - "profile_name": label, - "events": events, - "outcome": outcome, - } - - # Shared mutable state for the callbacks (offset seconds from cycle start). - cursor = {"t": 0.0} - captured: list[dict[str, Any]] = [] - last_match: dict[str, Any] = {"name": None, "conf": 0.0, "ambiguous": False} - # Dedupe consecutive identical match outcomes so the log stays readable. - last_logged_match: dict[str, Any] = {"kind": None, "name": None} - - def _emit(etype: str, detail: str) -> None: - if len(events) < MAX_EVENTS_PER_CYCLE: - events.append({"t": round(cursor["t"], 1), "type": etype, "detail": detail}) - - def _on_state_change(old_state: str, new_state: str) -> None: - _emit("state", f"{old_state}->{new_state}") - - def _on_cycle_end(cycle_data: dict[str, Any]) -> None: - captured.append(cycle_data) - _emit( - "end", - "reason={reason} status={status} dur={dur:.0f}s".format( - reason=cycle_data.get("termination_reason"), - status=cycle_data.get("status"), - dur=float(cycle_data.get("duration") or 0.0), - ), - ) - - def _matcher( - det_readings: list[tuple[datetime, float]], - ) -> tuple[str | None, float, float, str | None, bool, bool]: - if len(det_readings) < 5 or not snapshots: - return (None, 0.0, 0.0, None, False, False) - powers = [p for _, p in det_readings] - duration = (det_readings[-1][0] - det_readings[0][0]).total_seconds() - try: - candidates = analysis.compute_matches_worker( - powers, duration, snapshots, match_config - ) - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.debug("Playground match failed: %s", exc) - candidates = [] - - if not candidates: - if last_logged_match["kind"] != "unmatched": - _emit("unmatched", "no candidate") - last_logged_match["kind"] = "unmatched" - last_logged_match["name"] = None - last_match["name"] = None - last_match["conf"] = 0.0 - last_match["ambiguous"] = False - return (None, 0.0, 0.0, None, False, False) - - # Stage-5: resolve a winning group aggregate to its best-fitting member. - # This mirrors profile_store._stage5_pick_member used in production. - # Note: dtw_debug_payload always works on an individual profile, so - # Stage-5 resolution is only needed here in the batch matcher. - if group_members and candidates: - top = candidates[0] - gkey = top.get("name", "") - if gkey.startswith("__group__") and store is not None: - members = group_members.get(gkey, []) - if members: - try: - powers_list = list(powers) - member_name, _, _ = store._stage5_pick_member( # pylint: disable=protected-access - powers_list, duration, members, member_snaps or {} - ) - candidates[0] = dict(top, name=member_name) - _emit("group_resolved", f"{gkey} -> {member_name}") - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.debug( - "Playground stage5 pick_member failed: %s", exc - ) - - best = candidates[0] - margin, is_ambiguous = _ambiguity_from_candidates(candidates) - name = best.get("name") - conf = float(best.get("score") or 0.0) - expected = float(best.get("profile_duration") or 0.0) - last_match["name"] = name - last_match["conf"] = conf - last_match["ambiguous"] = bool(is_ambiguous) - - if is_ambiguous: - runner = candidates[1].get("name") if len(candidates) > 1 else None - if ( - last_logged_match["kind"] != "ambiguous" - or last_logged_match["name"] != name - ): - _emit("ambiguous", f"{name} vs {runner} (margin={margin:.3f})") - last_logged_match["kind"] = "ambiguous" - last_logged_match["name"] = name - elif ( - last_logged_match["kind"] != "matched" - or last_logged_match["name"] != name - ): - _emit("matched", f"{name} (conf={conf:.2f})") - last_logged_match["kind"] = "matched" - last_logged_match["name"] = name - - return (name, conf, expected, None, False, bool(is_ambiguous)) - - detector = CycleDetector( - sim_config, - _on_state_change, - _on_cycle_end, - profile_matcher=_matcher, - device_name="playground", - ) - - try: - for ts, power in readings: - cursor["t"] = (ts - base).total_seconds() - detector.process_reading(power, ts) - - # Feed a synthetic quiet tail so a natural end (timeout / min-off-gap) - # can fire, exactly as it would in production once the appliance goes - # idle. Sized to comfortably exceed both the off-delay and the - # soak-bridging min_off_gap. - last_ts = readings[-1][0] - tail_span = max( - float(sim_config.off_delay or 0.0), - float(sim_config.min_off_gap or 0.0), - ) * 1.5 + 300.0 - step = 30.0 - n_steps = min(int(tail_span / step) + 1, 400) - for i in range(1, n_steps + 1): - ts = last_ts + timedelta(seconds=step * i) - cursor["t"] = (ts - base).total_seconds() - detector.process_reading(0.0, ts) - if detector.state in (STATE_OFF, STATE_FINISHED) and captured: - break - - # If a cycle started but never finalized (unusual), flush it so the - # outcome is well-defined; it lands in the log as force-stopped. - if not captured and detector.state != STATE_OFF: - flush_ts = last_ts + timedelta(seconds=step * (n_steps + 2)) - cursor["t"] = (flush_ts - base).total_seconds() - detector.force_end(flush_ts) - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.debug("Playground simulate failed for %s: %s", cycle_id, exc) - - outcome["detected_count"] = len(captured) - if captured: - primary = max(captured, key=lambda c: float(c.get("duration") or 0.0)) - outcome["detected"] = True - outcome["detected_duration_s"] = _safe_float(primary.get("duration")) - outcome["termination_reason"] = primary.get("termination_reason") - outcome["status"] = primary.get("status") - - outcome["match_profile"] = last_match["name"] - outcome["ambiguous"] = bool(last_match["ambiguous"]) - if outcome["detected"] and last_match["name"] and label: - outcome["match_correct"] = last_match["name"].strip() == label.strip() - else: - outcome["match_correct"] = None - - return { - "cycle_id": cycle_id, - "profile_name": label, - "events": events, - "outcome": outcome, - } - - -def run_playground_batch( - store: Any, - cycle_ids: list[str] | None, - base_config: CycleDetectorConfig, - settings_override: dict[str, Any] | None, - concurrency: int, -) -> dict[str, Any]: - """Replay a set of cycles headlessly; return {results, summary}. - - ``concurrency`` caps how many of the selected cycles are simulated in this - batch (batch size), clamped 1..MAX_BATCH_CYCLES. Executor-safe; never raises. - """ - try: - concurrency = max(1, min(MAX_BATCH_CYCLES, int(concurrency))) - except (TypeError, ValueError): - concurrency = 1 - - summary: dict[str, Any] = { - "cycles": 0, - "requested": 0, - "concurrency": concurrency, - "detected": 0, - "missed": 0, - "false_end": 0, - "match_correct": 0, - "match_wrong": 0, - "unmatched": 0, - "skipped_ids": [], - } - - try: - past = list(store.get_past_cycles() or []) - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.debug("Playground: get_past_cycles failed: %s", exc) - return {"results": [], "summary": summary} - - by_id = {c.get("id"): c for c in past if isinstance(c, dict)} - - selected: list[dict[str, Any]] = [] - skipped: list[str] = [] - if cycle_ids: - for cid in cycle_ids: - cycle = by_id.get(cid) - if cycle is None: - skipped.append(cid) - else: - selected.append(cycle) - else: - selected = past[-DEFAULT_RECENT_CYCLES:] - - summary["requested"] = len(cycle_ids) if cycle_ids else len(selected) - - # Batch-size cap: simulate up to ``concurrency`` of the selected cycles - # (the runner is sequential). Any selected cycles beyond the cap are reported - # as skipped rather than silently dropped, so ``requested`` always reconciles - # with ``len(results) + len(skipped_ids)``. - to_run = selected[:concurrency] - if len(selected) > concurrency: - # Account for every capped cycle (even one lacking an id) so that - # requested == len(results) + len(skipped_ids) always reconciles. - skipped.extend(str(c.get("id") or "") for c in selected[concurrency:]) - summary["skipped_ids"] = skipped - - config = build_sim_config(base_config, settings_override) - snapshots, match_config, group_members, member_snaps = _build_match_snapshots(store) - match_config = apply_match_overrides(match_config, settings_override) - - results: list[dict[str, Any]] = [] - for cycle in to_run: - res = _simulate_one( - cycle, config, snapshots, match_config, - store=store, group_members=group_members, member_snaps=member_snaps, - ) - results.append(res) - oc = res["outcome"] - summary["cycles"] += 1 - if oc.get("detected"): - summary["detected"] += 1 - if int(oc.get("detected_count") or 0) > 1: - summary["false_end"] += 1 - correct = oc.get("match_correct") - if oc.get("match_profile") is None: - summary["unmatched"] += 1 - elif correct is True: - summary["match_correct"] += 1 - elif correct is False: - summary["match_wrong"] += 1 - else: - summary["missed"] += 1 - - return {"results": results, "summary": summary} - - # ─── Single-cycle faithful simulation (Simulate mode) ─────────────────────────── @@ -721,6 +424,40 @@ def simulate_cycle_detail_by_id( ) +def build_cycle_detail_sim_by_id( + store: Any, + cycle_id: str, + base_config: CycleDetectorConfig, + settings_override: dict[str, Any] | None, + options: dict[str, Any] | None, + price: float | None = None, +) -> "_DetailSim | dict[str, Any]": + """Look up a stored cycle by id and build a resumable :class:`_DetailSim`. + + Used by the chunked background-task driver in ``ws_api`` so the heavy replay + can be stepped across many small executor jobs (issue #311). Returns a + ``{"error": ...}`` marker (not a sim) when the id is unknown or setup fails, + so the caller can surface it. The store lookup + build run together so the WS + handler can offload the whole thing to an executor thread. Never raises.""" + options = options or {} + try: + cycle = next( + (c for c in store.get_past_cycles() if c.get("id") == cycle_id), None + ) + except Exception as exc: # pylint: disable=broad-exception-caught + _LOGGER.debug("Playground detail lookup failed for %s: %s", cycle_id, exc) + return {"error": str(exc), "cycle_id": cycle_id} + if cycle is None: + return {"error": "not_found", "cycle_id": cycle_id} + try: + return _DetailSim( + cycle, base_config, settings_override, store, options, price, + ) + except Exception as exc: # pylint: disable=broad-exception-caught + _LOGGER.debug("Playground detail sim build failed for %s: %s", cycle_id, exc) + return {"error": str(exc), "cycle_id": cycle_id} + + def _simulate_cycle_detail_inner( cycle: dict[str, Any], base_config: CycleDetectorConfig, @@ -731,161 +468,231 @@ def _simulate_cycle_detail_inner( compute_series: bool = True, prebuilt: tuple[Any, Any, Any, Any] | None = None, ) -> dict[str, Any]: - config = build_sim_config(base_config, settings_override) - device_type = _device_type_of(config) - label = _cycle_label(cycle) - readings, _points, base = _readings_from_cycle(cycle) - stored_duration = _safe_float(cycle.get("duration")) + """One-shot faithful replay: build the resumable sim and run it to completion. - outcome: dict[str, Any] = { - "detected": False, - "detected_count": 0, - "termination_reason": None, - "status": None, - "final_duration_s": None, - "matched_profile": None, - "match_correct": None, - "confidence": None, - "expected_s": None, - "overrun_ratio": None, - "projected_energy_wh": None, - "projected_cost": None, - } - if prebuilt is not None: - snapshots, match_config, group_members, member_snaps = prebuilt - else: - snapshots, match_config, group_members, member_snaps = _build_match_snapshots(store) - # Overlay any matcher-knob overrides. Because history/sweep run through this - # same function, a swept matching value flows in via settings_override too; - # applying to a copy keeps the shared prebuilt match_config untouched. - match_config = apply_match_overrides(match_config, settings_override) + The chunked (background-task) driver in ``ws_api`` builds the same + :class:`_DetailSim` and calls ``step``/``run_tail``/``finalize`` across many + small executor jobs so the event loop breathes on very long cycles (issue + #311). Because both paths drive the identical object in the identical order, + the timeline is byte-for-byte the same (golden test in test_playground_detail). + """ + sim = _DetailSim( + cycle, base_config, settings_override, store, options, price, + compute_series, prebuilt, + ) + if not sim.ready: + return sim.empty_payload() + sim.step(0, sim.n_readings) + sim.run_tail() + return sim.finalize() - empty = { - "cycle_id": cycle.get("id"), - "label": label, - "duration_s": stored_duration, - "config_summary": _sim_config_summary(config), - "series": [], - "events": [], - "alerts": [], - "outcome": outcome, - } - if len(readings) < 5: - return empty - # Per-sim end-expectation cache, threaded through the shared progress helpers - # exactly like the manager threads self._ml_end_expectation_cache. - endexp_cache: list[Any] = [None] +class _DetailSim: + """Resumable single-cycle Playground "Simulate" replay. - def _end_exp_fn(name: str, dur: float) -> Any: - exp, endexp_cache[0] = progress_mod.profile_end_expectation( - store, name, dur, endexp_cache[0] + Drives the REAL :class:`CycleDetector` + real Stage 1-4 matcher over the + cycle's own trace and, at production's 5s cadence, calls the SAME + :mod:`progress` and :mod:`notification_rules` functions the live integration + uses - so the returned timeline is byte-for-byte what would happen live. No + detection/progress/notification math is implemented here; this only + orchestrates the shared code. Read-only: nothing is persisted and no + notifications are sent. + + The replay is split into :meth:`step` (a slice of the real readings), + :meth:`run_tail` (the synthetic quiet tail + flush) and :meth:`finalize` + (outcome + alerts) so a long cycle can be replayed chunk-by-chunk across + executor jobs without holding the GIL for the whole run. + """ + + def __init__( + self, + cycle: dict[str, Any], + base_config: CycleDetectorConfig, + settings_override: dict[str, Any] | None, + store: Any, + options: dict[str, Any], + price: float | None, + compute_series: bool = True, + prebuilt: tuple[Any, Any, Any, Any] | None = None, + ) -> None: + self.cycle = cycle + self.store = store + self.options = options or {} + self.price = price + self.compute_series = compute_series + self.config = build_sim_config(base_config, settings_override) + self.device_type = _device_type_of(self.config) + self.label = _cycle_label(cycle) + self.readings, _points, self.base = _readings_from_cycle(cycle) + self.stored_duration = _safe_float(cycle.get("duration")) + + self.outcome: dict[str, Any] = { + "detected": False, + "detected_count": 0, + "termination_reason": None, + "status": None, + "final_duration_s": None, + "matched_profile": None, + "match_correct": None, + "confidence": None, + "expected_s": None, + "overrun_ratio": None, + "projected_energy_wh": None, + "projected_cost": None, + } + if prebuilt is not None: + snapshots, match_config, group_members, member_snaps = prebuilt + else: + snapshots, match_config, group_members, member_snaps = _build_match_snapshots(store) + # Overlay any matcher-knob overrides. Because history/sweep run through this + # same class, a swept matching value flows in via settings_override too; + # applying to a copy keeps the shared prebuilt match_config untouched. + self.snapshots = snapshots + self.match_config = apply_match_overrides(match_config, settings_override) + self.group_members = group_members + self.member_snaps = member_snaps + + self.ready = len(self.readings) >= 5 + # Per-sim end-expectation cache, threaded through the shared progress helpers + # exactly like the manager threads self._ml_end_expectation_cache. + self.endexp_cache: list[Any] = [None] + + self.events: list[dict[str, Any]] = [] + self.series: list[dict[str, Any]] = [] + self.captured: list[dict[str, Any]] = [] + self.cursor = {"t": 0.0} + self.last_match: dict[str, Any] = { + "name": None, "conf": 0.0, "ambiguous": False, "expected": 0.0, + } + self.last_logged = {"kind": None, "name": None} + # Persistence-gated commit mirroring the manager: a candidate must be top-1 + # for `match_persistence` consecutive matches (and not ambiguous) before it + # is committed, and the committed match is HELD (a one-off wobble doesn't + # switch it). The detector still receives the raw top-1 (detection + # unchanged); only the reported series/events use the committed match, so the + # Playground shows what the live integration would show - not raw churn. + self.match_persistence = max(1, int( + self.options.get(CONF_MATCH_PERSISTENCE, DEFAULT_MATCH_PERSISTENCE) + )) + self.commit_state: dict[str, Any] = {"candidate": None, "count": 0, "name": None} + self.smoothed = {"v": 0.0} + self.flags = {"detected": False, "pre_complete": False, "start": False} + + # --- notification config (decisions reuse notification_rules) --- + self.start_configured = bool( + self.options.get(CONF_NOTIFY_START_SERVICES) or self.options.get(CONF_NOTIFY_ACTIONS) + ) + self.finish_configured = bool( + self.options.get(CONF_NOTIFY_FINISH_SERVICES) or self.options.get(CONF_NOTIFY_ACTIONS) + ) + self.before_end = float( + self.options.get(CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES) + or 0.0 + ) + self.quiet_bounds = notif_rules.quiet_hours_bounds(self.options) + + self.last_sample_t = -1e9 + self._aborted = False + + if self.ready: + self.detector = CycleDetector( + self.config, self._on_state_change, self._on_cycle_end, + profile_matcher=self._matcher, device_name="playground-detail", + ) + + @property + def n_readings(self) -> int: + return len(self.readings) + + def empty_payload(self) -> dict[str, Any]: + return { + "cycle_id": self.cycle.get("id"), + "label": self.label, + "duration_s": self.stored_duration, + "config_summary": _sim_config_summary(self.config), + "series": [], + "events": [], + "alerts": [], + "outcome": self.outcome, + } + + def _end_exp_fn(self, name: str, dur: float) -> Any: + exp, self.endexp_cache[0] = progress_mod.profile_end_expectation( + self.store, name, dur, self.endexp_cache[0] ) return exp - events: list[dict[str, Any]] = [] - series: list[dict[str, Any]] = [] - captured: list[dict[str, Any]] = [] - cursor = {"t": 0.0} - last_match: dict[str, Any] = { - "name": None, "conf": 0.0, "ambiguous": False, "expected": 0.0, - } - last_logged = {"kind": None, "name": None} - # Persistence-gated commit mirroring the manager: a candidate must be top-1 for - # `match_persistence` consecutive matches (and not ambiguous) before it is - # committed, and the committed match is HELD (a one-off wobble doesn't switch - # it). The detector still receives the raw top-1 (detection unchanged); only the - # reported series/events use the committed match, so the Playground shows what - # the live integration would show - not raw per-interval churn. - match_persistence = max(1, int( - (options or {}).get(CONF_MATCH_PERSISTENCE, DEFAULT_MATCH_PERSISTENCE) - )) - commit_state: dict[str, Any] = {"candidate": None, "count": 0, "name": None} - smoothed = {"v": 0.0} - flags = {"detected": False, "pre_complete": False, "start": False} - - def _emit(etype: str, detail: str, severity: str = "info") -> None: - if len(events) < MAX_EVENTS_PER_CYCLE: - events.append( - {"t": round(cursor["t"], 1), "type": etype, "detail": detail, + def _emit(self, etype: str, detail: str, severity: str = "info") -> None: + if len(self.events) < MAX_EVENTS_PER_CYCLE: + self.events.append( + {"t": round(self.cursor["t"], 1), "type": etype, "detail": detail, "severity": severity} ) - # --- notification config (decisions reuse notification_rules) --- - start_configured = bool( - options.get(CONF_NOTIFY_START_SERVICES) or options.get(CONF_NOTIFY_ACTIONS) - ) - finish_configured = bool( - options.get(CONF_NOTIFY_FINISH_SERVICES) or options.get(CONF_NOTIFY_ACTIONS) - ) - before_end = float( - options.get(CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES) - or 0.0 - ) - quiet_bounds = notif_rules.quiet_hours_bounds(options) + def _held(self, offset: float) -> bool: + return notif_rules.in_quiet_hours( + self.quiet_bounds, self.base + timedelta(seconds=offset) + ) - def _held(offset: float) -> bool: - return notif_rules.in_quiet_hours(quiet_bounds, base + timedelta(seconds=offset)) - - def _on_state_change(old_state: str, new_state: str) -> None: - _emit("state", f"{old_state}->{new_state}") + def _on_state_change(self, old_state: str, new_state: str) -> None: + self._emit("state", f"{old_state}->{new_state}") # A new cycle is starting after a previous one ended: clear the inherited # match-persistence streak so this cycle matches fresh (see _on_cycle_end). # PAUSED->RUNNING resumes don't arm pending_reset, so they are unaffected. - if new_state == STATE_RUNNING and flags.get("pending_reset"): - flags["pending_reset"] = False - commit_state.update(candidate=None, count=0, name=None) - last_match.update(name=None, conf=0.0, expected=0.0, ambiguous=False) - last_logged.update(kind=None, name=None) + if new_state == STATE_RUNNING and self.flags.get("pending_reset"): + self.flags["pending_reset"] = False + self.commit_state.update(candidate=None, count=0, name=None) + self.last_match.update(name=None, conf=0.0, expected=0.0, ambiguous=False) + self.last_logged.update(kind=None, name=None) if ( - not flags["detected"] + not self.flags["detected"] and new_state == STATE_RUNNING and old_state in (STATE_OFF, STATE_UNKNOWN, STATE_STARTING, STATE_IDLE) ): - flags["detected"] = True - _emit("detected", "cycle detected (running)") - if start_configured and not flags["start"]: - flags["start"] = True - # Start notifications are never delayed by quiet hours (live contract), - # so the sim always emits them immediately. - _emit("notify_start", "start notification") + self.flags["detected"] = True + self._emit("detected", "cycle detected (running)") + if self.start_configured and not self.flags["start"]: + self.flags["start"] = True + # Start notifications are never delayed by quiet hours (live + # contract), so the sim always emits them immediately. + self._emit("notify_start", "start notification") - def _on_cycle_end(cycle_data: dict[str, Any]) -> None: - captured.append(cycle_data) + def _on_cycle_end(self, cycle_data: dict[str, Any]) -> None: + self.captured.append(cycle_data) reason = cycle_data.get("termination_reason") - _emit("finished", f"reason={reason} status={cycle_data.get('status')}", "info") + self._emit("finished", f"reason={reason} status={cycle_data.get('status')}", "info") # Arm a match-state reset for the NEXT cycle. We reset at the next cycle's # start (not here) so the final cycle's committed match survives to be read # into `outcome` after the loop; a second sub-cycle then starts a fresh # match-persistence streak, mirroring the live manager (per-cycle reset). - flags["pending_reset"] = True + self.flags["pending_reset"] = True - def _matcher(det_readings: list[tuple[datetime, float]]): - if len(det_readings) < 5 or not snapshots: + def _matcher(self, det_readings: list[tuple[datetime, float]]): + if len(det_readings) < 5 or not self.snapshots: return (None, 0.0, 0.0, None, False, False) powers = [p for _, p in det_readings] duration = (det_readings[-1][0] - det_readings[0][0]).total_seconds() try: candidates = analysis.compute_matches_worker( - powers, duration, snapshots, match_config + powers, duration, self.snapshots, self.match_config ) except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground detail match failed: %s", exc) candidates = [] if not candidates: - if last_logged["kind"] != "unmatched": - _emit("unmatched", "no candidate") - last_logged["kind"] = "unmatched" + if self.last_logged["kind"] != "unmatched": + self._emit("unmatched", "no candidate") + self.last_logged["kind"] = "unmatched" # Hold any committed match on a transient miss (as the manager does). - last_match.update(ambiguous=False) + self.last_match.update(ambiguous=False) return (None, 0.0, 0.0, None, False, False) - if group_members and candidates[0].get("name", "").startswith("__group__"): + if self.group_members and candidates[0].get("name", "").startswith("__group__"): gkey = candidates[0]["name"] - members = group_members.get(gkey, []) - if members and store is not None: + members = self.group_members.get(gkey, []) + if members and self.store is not None: try: - member_name, _, _ = store._stage5_pick_member( # noqa: SLF001 - list(powers), duration, members, member_snaps or {} + member_name, _, _ = self.store._stage5_pick_member( # noqa: SLF001 + list(powers), duration, members, self.member_snaps or {} ) candidates[0] = dict(candidates[0], name=member_name) except Exception: # pylint: disable=broad-exception-caught @@ -898,56 +705,50 @@ def _simulate_cycle_detail_inner( # Persistence-gated commit (mirror of the manager's core rule), extracted # into decide_commit() for unit-testability. - commit_event = decide_commit(raw_name, is_ambiguous, commit_state, match_persistence) + commit_event = decide_commit( + raw_name, is_ambiguous, self.commit_state, self.match_persistence + ) if commit_event: - _emit(commit_event, f"{raw_name} (conf={raw_conf:.2f})") - last_logged.update(kind="matched", name=raw_name) - elif is_ambiguous and not commit_state["name"]: + self._emit(commit_event, f"{raw_name} (conf={raw_conf:.2f})") + self.last_logged.update(kind="matched", name=raw_name) + elif is_ambiguous and not self.commit_state["name"]: # Ambiguous before any commit: stay 'detecting', surface it once. - if last_logged["kind"] != "ambiguous" or last_logged["name"] != raw_name: + if self.last_logged["kind"] != "ambiguous" or self.last_logged["name"] != raw_name: runner = candidates[1].get("name") if len(candidates) > 1 else None - _emit("match_ambiguous", f"{raw_name} vs {runner} (margin={margin:.3f})", "warn") - last_logged.update(kind="ambiguous", name=raw_name) + self._emit("match_ambiguous", f"{raw_name} vs {runner} (margin={margin:.3f})", "warn") + self.last_logged.update(kind="ambiguous", name=raw_name) # Reported state = the COMMITTED match (held); its confidence/expected are # that profile's own values this interval (looked up among the candidates). - cname = commit_state["name"] + cname = self.commit_state["name"] if cname: cc = next((c for c in candidates if c.get("name") == cname), None) - last_match.update( + self.last_match.update( name=cname, - conf=float(cc.get("score") or 0.0) if cc else (last_match.get("conf") or 0.0), - expected=float(cc.get("profile_duration") or 0.0) if cc else (last_match.get("expected") or 0.0), + conf=float(cc.get("score") or 0.0) if cc else (self.last_match.get("conf") or 0.0), + expected=float(cc.get("profile_duration") or 0.0) if cc else (self.last_match.get("expected") or 0.0), ambiguous=False, ) else: - last_match.update(name=None, conf=0.0, expected=0.0, ambiguous=bool(is_ambiguous)) + self.last_match.update(name=None, conf=0.0, expected=0.0, ambiguous=bool(is_ambiguous)) # The DETECTOR still receives the RAW top-1, so detection / smart-termination # behaviour is byte-identical to before this reporting change. return (raw_name, raw_conf, raw_expected, None, False, bool(is_ambiguous)) - detector = CycleDetector( - config, _on_state_change, _on_cycle_end, - profile_matcher=_matcher, device_name="playground-detail", - ) - - last_sample_t = -1e9 - - def _sample(ts: datetime) -> None: - nonlocal last_sample_t - if not compute_series: + def _sample(self, ts: datetime) -> None: + if not self.compute_series: return # batch/sweep rows only need the outcome, not the per-step series - offset = (ts - base).total_seconds() - if offset - last_sample_t < _SIM_SERIES_THROTTLE_S: + offset = (ts - self.base).total_seconds() + if offset - self.last_sample_t < _SIM_SERIES_THROTTLE_S: return - last_sample_t = offset - state = detector.state + self.last_sample_t = offset + state = self.detector.state power = 0.0 - trace = detector.get_power_trace() + trace = self.detector.get_power_trace() if trace: power = float(trace[-1][1]) - energy_wh = float(getattr(detector, "_energy_since_idle_wh", 0.0) or 0.0) + energy_wh = float(getattr(self.detector, "_energy_since_idle_wh", 0.0) or 0.0) pt: dict[str, Any] = { "t": round(offset, 1), "power": round(power, 1), @@ -956,182 +757,213 @@ def _simulate_cycle_detail_inner( "progress": None, "remaining_s": None, "phase": None, - "confidence": round(last_match["conf"], 3) if last_match["name"] else None, - "matched_profile": last_match["name"], + "confidence": round(self.last_match["conf"], 3) if self.last_match["name"] else None, + "matched_profile": self.last_match["name"], } - matched_dur = float(last_match["expected"] or 0.0) - program = last_match["name"] + matched_dur = float(self.last_match["expected"] or 0.0) + program = self.last_match["name"] if state not in _DEAD_STATES and program and matched_dur > 0: phase_result = None if len(trace) >= 10 and program != "detecting...": phase_result = progress_mod.estimate_phase_progress( - store, trace, offset, program + self.store, trace, offset, program ) ml_pct = progress_mod.ml_progress_percent( - store, options, matched_dur, trace, program, _end_exp_fn + self.store, self.options, matched_dur, trace, program, self._end_exp_fn ) + # Opt-in phase-resolved ETA blend - identical gating + call as the live + # manager, so the Playground stays a faithful mirror of the estimator. + phase_remaining_s = None + if ( + self.store is not None + and len(trace) >= 10 + and program not in ("detecting...", "off", None) + and phase_matching_enabled(self.options, self.device_type) + ): + pr = self.store.phase_remaining(trace, self.device_type, program) + if pr is not None: + phase_remaining_s = pr.get("remaining_s") result = progress_mod.compute_progress( - device_type, matched_dur, offset, smoothed["v"], phase_result, ml_pct + self.device_type, matched_dur, offset, self.smoothed["v"], phase_result, ml_pct, + phase_remaining_s=phase_remaining_s, ) if result is not None: - smoothed["v"] = result.smoothed + self.smoothed["v"] = result.smoothed pt["progress"] = round(result.progress, 1) pt["remaining_s"] = round(result.remaining, 0) pt["phase"] = progress_mod.current_phase( - store, state, program, result.progress + self.store, state, program, result.progress ) wh, cost = progress_mod.projected_energy( - store, options, matched_dur, trace, program, result.progress, - energy_wh, price, _end_exp_fn, + self.store, self.options, matched_dur, trace, program, result.progress, + energy_wh, self.price, self._end_exp_fn, ) pt["projected_energy_wh"] = round(wh, 1) if wh is not None else None pt["projected_cost"] = round(cost, 4) if cost is not None else None # One-time pre-completion marker (reuses the production predicate). - if not flags["pre_complete"] and notif_rules.should_notify_pre_completion( - before_end, flags["pre_complete"], result.remaining, - result.progress, last_match["ambiguous"], + if not self.flags["pre_complete"] and notif_rules.should_notify_pre_completion( + self.before_end, self.flags["pre_complete"], result.remaining, + result.progress, self.last_match["ambiguous"], ): - flags["pre_complete"] = True - held = _held(offset) - _emit( + self.flags["pre_complete"] = True + held = self._held(offset) + self._emit( "notify_held" if held else "notify_pre_complete", "pre-completion notification" + (" (held: quiet hours)" if held else ""), ) - series.append(pt) + self.series.append(pt) - try: - for ts, power in readings: - cursor["t"] = (ts - base).total_seconds() - detector.process_reading(power, ts) - _sample(ts) - # Synthetic quiet tail so a natural end can fire, as in _simulate_one. - last_ts = readings[-1][0] - tail_span = max( - float(config.off_delay or 0.0), float(config.min_off_gap or 0.0) - ) * 1.5 + 300.0 - step = 30.0 - n_steps = min(int(tail_span / step) + 1, 400) - for i in range(1, n_steps + 1): - ts = last_ts + timedelta(seconds=step * i) - cursor["t"] = (ts - base).total_seconds() - detector.process_reading(0.0, ts) - _sample(ts) - if detector.state in (STATE_OFF, STATE_FINISHED) and captured: - break - if not captured and detector.state != STATE_OFF: - flush_ts = last_ts + timedelta(seconds=step * (n_steps + 2)) - cursor["t"] = (flush_ts - base).total_seconds() - detector.force_end(flush_ts) - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.debug("Playground detail replay failed for %s: %s", cycle.get("id"), exc) - - # --- outcome --- - primary = None - if captured: - primary = max(captured, key=lambda c: float(c.get("duration") or 0.0)) - outcome["detected"] = True - outcome["detected_count"] = len(captured) - outcome["termination_reason"] = primary.get("termination_reason") - outcome["status"] = primary.get("status") - outcome["final_duration_s"] = _safe_float(primary.get("duration")) - outcome["matched_profile"] = last_match["name"] - outcome["confidence"] = ( - round(float(last_match["conf"]), 3) if last_match["name"] else None - ) - outcome["expected_s"] = ( - round(float(last_match["expected"] or 0.0), 1) or None - ) - if outcome["detected"] and last_match["name"] and label: - outcome["match_correct"] = last_match["name"].strip() == label.strip() - # Projected energy/cost: the last LIVE estimate (the post-finish tail resets - # the detector's accumulated energy, so series[-1] would read None). - for pt in reversed(series): - if pt.get("projected_energy_wh") is not None: - outcome["projected_energy_wh"] = pt.get("projected_energy_wh") - outcome["projected_cost"] = pt.get("projected_cost") - break - - # --- finish + milestone markers (reuse production predicates) --- - if captured and finish_configured: - held = _held(cursor["t"]) - _emit( - "notify_held" if held else "notify_finish", - "finish notification" + (" (held: quiet hours)" if held else ""), - ) + def step(self, i0: int, i1: int) -> None: + """Replay readings[i0:i1] through the detector (a chunk of the cycle).""" + if self._aborted or not self.ready: + return try: - prev_life = int(store.get_lifetime_cycle_count()) - except Exception: # pylint: disable=broad-exception-caught - prev_life = 0 - crossed = notif_rules.milestone_crossed( - prev_life, prev_life + 1, - options.get(CONF_NOTIFY_MILESTONES, DEFAULT_NOTIFY_MILESTONES), - ) - if crossed is not None: - # Milestone notifications are held during quiet hours (live contract). - m_held = _held(cursor["t"]) - _emit( - "notify_held" if m_held else "notify_milestone", - f"milestone {crossed} cycles" + (" (held: quiet hours)" if m_held else ""), + for ts, power in self.readings[i0:i1]: + self.cursor["t"] = (ts - self.base).total_seconds() + self.detector.process_reading(power, ts) + self._sample(ts) + except Exception as exc: # pylint: disable=broad-exception-caught + self._aborted = True + _LOGGER.debug( + "Playground detail replay failed for %s: %s", self.cycle.get("id"), exc ) - # --- alerts --- - alerts: list[dict[str, Any]] = [] - expected_dur = float(last_match["expected"] or 0.0) - final_dur = outcome["final_duration_s"] or 0.0 - if not outcome["detected"]: - alerts.append({"code": "did_not_finish", "severity": "error", - "detail": "Cycle never reached a terminal state in the replay."}) - if outcome["detected"] and (outcome["detected_count"] or 0) > 1: - alerts.append({"code": "false_end", "severity": "error", - "detail": f"Split into {outcome['detected_count']} cycles."}) - if outcome["matched_profile"] is None: - alerts.append({"code": "unmatched", "severity": "warn", - "detail": "No profile matched this cycle."}) - if last_match["ambiguous"]: - alerts.append({"code": "ambiguous", "severity": "warn", - "detail": "Match was ambiguous (two programs scored close)."}) - # How the cycle ended: predictive (smart / terminal-drop) vs the static - # low-power fallback. Under auto-detect an unmatched cycle cannot use smart - # end-prediction, so it only stops once power stays low for the off-delay - - # or, if it never goes quiet, not at all. Surface which happened. - term = str(outcome.get("termination_reason") or "") - if outcome["detected"] and term == str(TerminationReason.FORCE_STOPPED): - alerts.append({"code": "would_run_indefinitely", "severity": "error", - "detail": ("The cycle never ended on its own - only the safety " - "force-stop finalized it in simulation. In real use it " - "would keep counting as running until power stays low.")}) - elif outcome["detected"] and term == str(TerminationReason.TIMEOUT): - off_min = max(1, round(float(getattr(config, "off_delay", 0) or 0) / 60)) - if outcome["matched_profile"] is None: - alerts.append({"code": "timeout_end", "severity": "warn", - "detail": (f"Ended only by the low-power timeout: no profile matched, " - f"so smart end-prediction could not run and it waited out " - f"the {off_min} min off-delay after power dropped.")}) - else: - alerts.append({"code": "timeout_end", "severity": "info", - "detail": (f"Ended by the low-power timeout, not smart prediction: it " - f"waited out the {off_min} min off-delay after power dropped.")}) - if expected_dur > 0 and final_dur > 0: - ratio = final_dur / expected_dur - outcome["overrun_ratio"] = round(ratio, 3) - if ratio >= CYCLE_OVERRUN_ANOMALY_RATIO: - alerts.append({"code": "overrun", "severity": "warn", - "detail": f"Ran {ratio:.0%} of the profile's typical duration."}) - elif ratio <= CYCLE_UNDERRUN_ANOMALY_RATIO: - alerts.append({"code": "underrun", "severity": "warn", - "detail": f"Finished at {ratio:.0%} of typical duration."}) + def run_tail(self) -> None: + """Synthetic quiet tail so a natural end can fire.""" + if self._aborted or not self.ready: + return + try: + last_ts = self.readings[-1][0] + tail_span = max( + float(self.config.off_delay or 0.0), float(self.config.min_off_gap or 0.0) + ) * 1.5 + 300.0 + step = 30.0 + n_steps = min(int(tail_span / step) + 1, 400) + for i in range(1, n_steps + 1): + ts = last_ts + timedelta(seconds=step * i) + self.cursor["t"] = (ts - self.base).total_seconds() + self.detector.process_reading(0.0, ts) + self._sample(ts) + if self.detector.state in (STATE_OFF, STATE_FINISHED) and self.captured: + break + if not self.captured and self.detector.state != STATE_OFF: + flush_ts = last_ts + timedelta(seconds=step * (n_steps + 2)) + self.cursor["t"] = (flush_ts - self.base).total_seconds() + self.detector.force_end(flush_ts) + except Exception as exc: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Playground detail replay failed for %s: %s", self.cycle.get("id"), exc + ) - return { - "cycle_id": cycle.get("id"), - "label": label, - "duration_s": stored_duration, - "config_summary": _sim_config_summary(config), - "series": series, - "events": events, - "alerts": alerts, - "outcome": outcome, - } + def finalize(self) -> dict[str, Any]: + outcome = self.outcome + last_match = self.last_match + # --- outcome --- + if self.captured: + primary = max(self.captured, key=lambda c: float(c.get("duration") or 0.0)) + outcome["detected"] = True + outcome["detected_count"] = len(self.captured) + outcome["termination_reason"] = primary.get("termination_reason") + outcome["status"] = primary.get("status") + outcome["final_duration_s"] = _safe_float(primary.get("duration")) + outcome["matched_profile"] = last_match["name"] + outcome["confidence"] = ( + round(float(last_match["conf"]), 3) if last_match["name"] else None + ) + outcome["expected_s"] = ( + round(float(last_match["expected"] or 0.0), 1) or None + ) + if outcome["detected"] and last_match["name"] and self.label: + outcome["match_correct"] = last_match["name"].strip() == self.label.strip() + # Projected energy/cost: the last LIVE estimate (the post-finish tail resets + # the detector's accumulated energy, so series[-1] would read None). + for pt in reversed(self.series): + if pt.get("projected_energy_wh") is not None: + outcome["projected_energy_wh"] = pt.get("projected_energy_wh") + outcome["projected_cost"] = pt.get("projected_cost") + break + + # --- finish + milestone markers (reuse production predicates) --- + if self.captured and self.finish_configured: + held = self._held(self.cursor["t"]) + self._emit( + "notify_held" if held else "notify_finish", + "finish notification" + (" (held: quiet hours)" if held else ""), + ) + try: + prev_life = int(self.store.get_lifetime_cycle_count()) + except Exception: # pylint: disable=broad-exception-caught + prev_life = 0 + crossed = notif_rules.milestone_crossed( + prev_life, prev_life + 1, + self.options.get(CONF_NOTIFY_MILESTONES, DEFAULT_NOTIFY_MILESTONES), + ) + if crossed is not None: + # Milestone notifications are held during quiet hours (live contract). + m_held = self._held(self.cursor["t"]) + self._emit( + "notify_held" if m_held else "notify_milestone", + f"milestone {crossed} cycles" + (" (held: quiet hours)" if m_held else ""), + ) + + # --- alerts --- + alerts: list[dict[str, Any]] = [] + expected_dur = float(last_match["expected"] or 0.0) + final_dur = outcome["final_duration_s"] or 0.0 + if not outcome["detected"]: + alerts.append({"code": "did_not_finish", "severity": "error", + "detail": "Cycle never reached a terminal state in the replay."}) + if outcome["detected"] and (outcome["detected_count"] or 0) > 1: + alerts.append({"code": "false_end", "severity": "error", + "detail": f"Split into {outcome['detected_count']} cycles."}) + if outcome["matched_profile"] is None: + alerts.append({"code": "unmatched", "severity": "warn", + "detail": "No profile matched this cycle."}) + if last_match["ambiguous"]: + alerts.append({"code": "ambiguous", "severity": "warn", + "detail": "Match was ambiguous (two programs scored close)."}) + # How the cycle ended: predictive (smart / terminal-drop) vs the static + # low-power fallback. Under auto-detect an unmatched cycle cannot use smart + # end-prediction, so it only stops once power stays low for the off-delay - + # or, if it never goes quiet, not at all. Surface which happened. + term = str(outcome.get("termination_reason") or "") + if outcome["detected"] and term == str(TerminationReason.FORCE_STOPPED): + alerts.append({"code": "would_run_indefinitely", "severity": "error", + "detail": ("The cycle never ended on its own - only the safety " + "force-stop finalized it in simulation. In real use it " + "would keep counting as running until power stays low.")}) + elif outcome["detected"] and term == str(TerminationReason.TIMEOUT): + off_min = max(1, round(float(getattr(self.config, "off_delay", 0) or 0) / 60)) + if outcome["matched_profile"] is None: + alerts.append({"code": "timeout_end", "severity": "warn", + "detail": (f"Ended only by the low-power timeout: no profile matched, " + f"so smart end-prediction could not run and it waited out " + f"the {off_min} min off-delay after power dropped.")}) + else: + alerts.append({"code": "timeout_end", "severity": "info", + "detail": (f"Ended by the low-power timeout, not smart prediction: it " + f"waited out the {off_min} min off-delay after power dropped.")}) + if expected_dur > 0 and final_dur > 0: + ratio = final_dur / expected_dur + outcome["overrun_ratio"] = round(ratio, 3) + if ratio >= CYCLE_OVERRUN_ANOMALY_RATIO: + alerts.append({"code": "overrun", "severity": "warn", + "detail": f"Ran {ratio:.0%} of the profile's typical duration."}) + elif ratio <= CYCLE_UNDERRUN_ANOMALY_RATIO: + alerts.append({"code": "underrun", "severity": "warn", + "detail": f"Finished at {ratio:.0%} of typical duration."}) + + return { + "cycle_id": self.cycle.get("id"), + "label": self.label, + "duration_s": self.stored_duration, + "config_summary": _sim_config_summary(self.config), + "series": self.series, + "events": self.events, + "alerts": alerts, + "outcome": outcome, + } def _sim_config_summary(config: CycleDetectorConfig) -> dict[str, Any]: @@ -1339,6 +1171,7 @@ def finalize_sweep_1d( "current_value": current_value, "best_value": best["value"] if best else None, "best_metric": best["metric"] if best else None, + "lower_is_better": objective in _SWEEP_LOWER_IS_BETTER, } @@ -1359,6 +1192,7 @@ def finalize_sweep_2d( "param_x": param_x, "param_y": param_y, "objective": objective, "x_values": x_values, "y_values": y_values, "grid": grid, "best": best, "current": current, + "lower_is_better": objective in _SWEEP_LOWER_IS_BETTER, } diff --git a/custom_components/ha_washdata/profile_store.py b/custom_components/ha_washdata/profile_store.py index 4a4250e..6a7e7f4 100644 --- a/custom_components/ha_washdata/profile_store.py +++ b/custom_components/ha_washdata/profile_store.py @@ -42,6 +42,11 @@ from .const import ( MAINTENANCE_EVENT_TYPES, MAINTENANCE_RECENT_SUPPRESS_DAYS, MATCH_AMBIGUITY_MARGIN, + PHASE_CONSISTENCY_MIN_CYCLES, + PHASE_PROFILE_MIN_CYCLES, + PHASE_HEAT_CV_WARN, + PHASE_HEAT_OCC_MIXED_LO, + PHASE_HEAT_OCC_MIXED_HI, REFERENCE_PROFILE_CURVE_POINTS, SHAPE_DRIFT_MIN_CYCLES, SHAPE_DRIFT_RESAMPLE_N, @@ -69,6 +74,14 @@ from .phase_catalog import ( merge_phase_catalog, normalize_phase_name, ) +from .phase_segmenter import phase_matching_live_supported, phase_model_for, segment_cycle +from .phase_match import ( + build_phase_profile, + match_phase_profiles, + phase_eta, + phase_profile_from_dict, + phase_profile_to_dict, +) from .log_utils import DeviceLoggerAdapter _LOGGER = logging.getLogger(__name__) @@ -461,6 +474,24 @@ def device_active_peak_range( return (min(peaks), max(peaks)) +def terminal_drop_baseline( + cycles: list[CycleDict], + stop_threshold_w: float, + min_quiet_span_s: float, + min_clean_cycles: int, +) -> tuple[float | None, tuple[float, float] | None]: + """Combined ``(earliest_quiet_offset, peak_range)`` baseline. + + Pure (no store / no I/O beyond decompressing the passed-in cycle traces), so + the manager can offload the whole per-cycle scan to an executor thread in one + hop instead of decompressing every trace on the event loop (issue #311).""" + earliest = earliest_sustained_quiet_offset( + cycles, stop_threshold_w, min_quiet_span_s, min_clean_cycles + ) + peak_range = device_active_peak_range(cycles, min_clean_cycles) + return earliest, peak_range + + def is_terminal_drop( points: list[tuple[float, float]], earliest_quiet: float | None, @@ -783,57 +814,19 @@ class WashDataStore(Store[JSONDict]): _LOGGER.info("Migrating storage from v%s to v10", old_major_version) old_data.setdefault("reference_cycles", []) + if old_major_version < 11: + # Marker-only bump. Per-phase profiles (envelope["phase_profile"], used + # by phase-segmented matching / phase-resolved ETA) are DERIVED CACHE + # built by async_rebuild_envelope, not stored data - so there is nothing + # to migrate. They self-populate on the next envelope rebuild (which + # runs on every cycle end / label change); until then consumers fall + # back to the existing estimator via lazy absent-key handling. No data + # is added, removed, or altered here. + _LOGGER.info("Migrating storage from v%s to v11 (phase-profile cache marker)", + old_major_version) + return old_data - async def get_storage_stats(self) -> dict[str, Any]: - """Get storage usage statistics.""" - data = self._data # pylint: disable=protected-access - if not data: - data = await self.async_load() or {} - - # Rough file size estimation if possible, else 0 - file_size_kb = 0 - try: - path = self.path # pylint: disable=no-member - if os.path.exists(path): - file_size_kb = os.path.getsize(path) / 1024 - except Exception: # pylint: disable=broad-exception-caught - pass - - cycles = data.get("past_cycles", []) - profiles = data.get("profiles", {}) - - debug_traces_count = sum(1 for c in cycles if c.get("debug_data")) - - return { - "file_size_kb": round(file_size_kb, 1), - "total_cycles": len(cycles), - "total_profiles": len(profiles), - "debug_traces_count": debug_traces_count, - } - - async def async_clear_debug_data(self) -> int: - """Clear granular debug data from all cycles to free space.""" - if not self._data: - await self.async_load() - - if self._data is None: - return 0 - - cycles = self._data.get("past_cycles", []) - count = 0 - for cycle in cycles: - if "debug_data" in cycle: - del cycle["debug_data"] - count += 1 - - if count > 0: - await self.async_save(self._data) - _LOGGER.info("Cleared debug data from %s cycles", count) - - return count - - def _ambiguity_from_candidates(candidates: list[dict]) -> tuple[float, bool]: """Top1-vs-top2 score margin and whether the match is ambiguous. @@ -2245,6 +2238,16 @@ class ProfileStore: except Exception: # noqa: BLE001 continue + # Most-recent unmatched cycle id, so the setup advisor's phase-2 + # "unmatched" nudge can deep-link straight to it (open_cycle:) + # instead of always falling back to the whole unlabelled list. + last_unmatched_cycle_id = None + for c in reversed(unmatched): + cid = c.get("id") + if cid: + last_unmatched_cycle_id = cid + break + return { "unmatched_count": n_unmatched, "low_confidence_count": len(low_conf), @@ -2252,6 +2255,7 @@ class ProfileStore: "suggest_create": bool(n_unmatched >= min_unmatched and rate >= min_unmatched_rate), "duration_clusters": clusters, "profile_suggestions": profile_suggestions, # NEW (A3) + "last_unmatched_cycle_id": last_unmatched_cycle_id, } except Exception: # noqa: BLE001 return {} @@ -2438,6 +2442,47 @@ class ProfileStore: "message_params": {"name": name, "pct": f"{pct:.0f}"}, }) + # Phase-structure consistency (phase-matching device types only). Uses + # the cached per-role phase profile: a profile whose member cycles heat + # for wildly different times, or where only some cycles heat at all, + # most likely mixes different programs/temperatures under one label - + # which hurts both matching and the phase-resolved ETA. Advisory only + # (Profiles tab); no relabeling (phase matching does not label better). + _sd = getattr(self, "_data", None) + _envs = _sd.get("envelopes") if isinstance(_sd, dict) else None + for pname, penv in (_envs if isinstance(_envs, dict) else {}).items(): + if health.get(pname, {}).get("health_status") == "poor": + continue # avoid double advice + pp = penv.get("phase_profile") if isinstance(penv, dict) else None + if not isinstance(pp, dict): + continue + try: + if int(pp.get("n_cycles") or 0) < PHASE_CONSISTENCY_MIN_CYCLES: + continue + heat = (pp.get("roles") or {}).get("heating") or {} + heat_mean = float(heat.get("dur_mean") or 0.0) + heat_std = float(heat.get("dur_std") or 0.0) + heat_occ = float(heat.get("occurrence") or 0.0) + heat_cv = (heat_std / heat_mean) if heat_mean > 60.0 else 0.0 + mixed_temp = heat_cv > PHASE_HEAT_CV_WARN + mixed_prog = PHASE_HEAT_OCC_MIXED_LO <= heat_occ <= PHASE_HEAT_OCC_MIXED_HI + if not (mixed_temp or mixed_prog): + continue + advisories.append({ + "profile": pname, "severity": "warning", + "code": "phase_inconsistent", + "message": ( + f"'{pname}' looks like it mixes different programs or " + "temperatures - its cycles heat for very different lengths " + "of time. Splitting it into separate profiles (e.g. per " + "temperature) will improve matching and time estimates." + ), + "message_key": "msg.advisory_phase_inconsistent", + "message_params": {"name": pname}, + }) + except (TypeError, ValueError): + continue + # E1: suppress the "needs maintenance" nag (duration-trending-longer / # shape-drift/poor-fit) when the user recently logged a descale, filter # clean, or drum clean — any recent maintenance clears the reminder. @@ -3270,6 +3315,16 @@ class ProfileStore: if c.get("id") in pending_feedback_ids: continue + # EXEMPTION: Never strip power data from user-pinned "golden" cycles. + # The matcher uses a golden trace as the sharp single-cycle template + # (has_golden in the snapshot builder), and golden_profiles membership + # is gated on the trace still being present — trimming it would flip + # has_golden false and silently drop the profile back to the smeared + # envelope average. + rev = c.get("ml_review") + if isinstance(rev, dict) and rev.get("golden"): + continue + if c.get("power_data"): c.pop("power_data", None) c.pop("sampling_interval", None) @@ -3895,12 +3950,163 @@ class ProfileStore: "updated": dt_util.now().isoformat(), } + # Derived cache: per-phase profile (per-role duration/energy priors) used by + # phase-segmented matching / phase-resolved ETA. Built only for device types + # phase matching is live-supported for; absent otherwise (consumers fall back + # to the whole-cycle pipeline). Pure/cheap - segmentation is O(samples). + device_type = str( + self._data.get("profiles", {}).get(profile_name, {}).get("device_type") or "" + ) + # Offload the per-cycle segmentation to the executor (it can be tens of ms + # for very long traces; keep it off the event loop, like the envelope DTW). + phase_profile = await self.hass.async_add_executor_job( + self._compute_phase_profile, profile_name, shape_cycles, device_type + ) + if phase_profile is not None: + envelope_data["phase_profile"] = phase_profile + if "envelopes" not in self._data: self._data["envelopes"] = {} self._data["envelopes"][profile_name] = envelope_data return True + def _compute_phase_profile( + self, profile_name: str, cycles: list[CycleDict], device_type: str + ) -> dict[str, Any] | None: + """Segment each member cycle and aggregate a per-role phase profile. + + Returns a JSON-safe dict for ``envelope["phase_profile"]`` or ``None`` when + phase matching is not live-supported for this device type or no cycle could + be segmented. Never raises (phase support must never break envelope rebuild). + """ + try: + if not phase_matching_live_supported(device_type): + return None + model = phase_model_for(device_type) + if model is None: + return None + segmented: list = [] + for cycle in cycles: + offsets = power_data_to_offsets(cycle.get("power_data") or []) + if len(offsets) < 4: + continue + t = [float(o) for o, _ in offsets] + w = [float(p) for _, p in offsets] + segs = segment_cycle(t, w, model) + if segs: + segmented.append(segs) + if not segmented: + return None + profile = build_phase_profile(profile_name, segmented) + return phase_profile_to_dict(profile) if profile is not None else None + except Exception: # noqa: BLE001 - phase caching must never break rebuild + self._logger.debug("phase-profile build failed for %s", profile_name, exc_info=True) + return None + + def _group_scope(self, program: str) -> set[str] | None: + """Phase-narrowing scope for the matched ``program``: + + * If ``program`` is in a group with >= 2 members, return that group's + members - narrow WITHIN the family (design §9). This is both coherent + (same program family as the displayed program) and accurate (picks the + right temperature/spin variant among siblings). + * Otherwise return ``None`` = no scope filter (consider ALL of the + device's phase profiles). The Phase-0 gate showed that constraining an + UNGROUPED cycle to only the whole-cycle-matched program regresses the + ETA whenever that match is wrong (common on mislabeled data): the best + ETA comes from letting the phase matcher pick the best-fitting profile, + bounded by the ambiguity gate + cold-start floor. Grouping variants is + the recommended workflow and restores full coherence. + """ + try: + for grp in self.get_profile_groups().values(): + members = grp.get("members") if isinstance(grp, dict) else None + if isinstance(members, list) and program in members: + sib = {m for m in members if isinstance(m, str)} + if len(sib) >= 2: + return sib + except Exception: # noqa: BLE001 + self._logger.debug("_group_scope failed for %r", program, exc_info=True) + return None + + def _candidate_phase_profiles(self, scope: set[str] | None = None) -> list: + """Cached per-profile PhaseProfiles (from envelope['phase_profile']). + + Restricted to ``scope`` (profile names) when given, and always filtered to + profiles with >= ``PHASE_PROFILE_MIN_CYCLES`` member cycles so a noisy + single-cycle prior can't drive the ETA (cold-start floor). + """ + out = [] + for name, env in (self._data.get("envelopes") or {}).items(): + if scope is not None and name not in scope: + continue + if isinstance(env, dict): + pp = phase_profile_from_dict(env.get("phase_profile")) + if pp is not None and pp.n_cycles >= PHASE_PROFILE_MIN_CYCLES: + out.append(pp) + return out + + def phase_remaining( + self, + power_data: list, + device_type: str, + program: str | None = None, + ) -> dict[str, Any] | None: + """Phase-resolved remaining-time for a running cycle. Never raises. + + Segments the observed-so-far trace and matches it against the matched + ``program``'s phase profile (and its group siblings, design §9), returning + the winning member's per-role budget remaining. Returns ``None`` (caller + keeps the current estimate) when: phase matching is not live-supported for + the device type; ``program`` is unknown / has no cached phase profile + (or too few cycles - cold-start floor); segmentation is degenerate; or the + top two candidates are within ``MATCH_AMBIGUITY_MARGIN`` (ambiguous - do + not commit a variant, design §7). + + This is the *phase* half of the blended ETA; the blend with the current + estimator lives in ``progress.compute_progress`` (single source of truth). + Pure and cheap (segmentation + per-role agreement, no DTW) - safe to call + inline from the async matching path. + """ + try: + if not phase_matching_live_supported(device_type): + return None + model = phase_model_for(device_type) + if model is None or not program: + return None + candidates = self._candidate_phase_profiles(self._group_scope(program)) + if not candidates: + return None + offsets = power_data_to_offsets(power_data or []) + if len(offsets) < 4: + return None + t = [float(o) for o, _ in offsets] + w = [float(p) for _, p in offsets] + segs = segment_cycle(t, w, model, partial=True) + if not segs: + return None + ranked = match_phase_profiles(segs, candidates, {}) + if not ranked: + return None + # Ambiguity gate: a near-tie among group members is not a confident + # variant call - fall back rather than swing the ETA between budgets. + if (len(ranked) >= 2 + and (ranked[0].score - ranked[1].score) < MATCH_AMBIGUITY_MARGIN): + return None + best = next((c for c in candidates if c.name == ranked[0].name), None) + remaining = phase_eta(segs, best) if best is not None else None + if remaining is None: + return None + return { + "remaining_s": float(remaining), + "matched": ranked[0].name, + "score": float(ranked[0].score), + } + except Exception: # noqa: BLE001 - phase ETA must never break the estimate + self._logger.debug("phase_remaining failed", exc_info=True) + return None + @@ -3977,6 +4183,66 @@ class ProfileStore: except Exception: # pragma: no cover - defensive; never break the sensor return None + def get_profile_power_profile( + self, profile_name: str, interval_s: float = 900.0 + ) -> list[float]: + """Average power (W) per fixed interval across a profile's learned shape. + + Resamples the profile envelope's average power-over-time curve into + consecutive ``interval_s`` buckets (default 15 min) and returns the mean + watts in each, e.g. ``[2200, 2200, 800, 800, 1500, 500, 400, 200]`` - the + flat per-slot array external planners such as tibber_prices' + ``power_profile`` consume to pick the cheapest window to run the appliance + (issue #272). Unlike :meth:`reference_curve` (a downsampled time/watt shape + surfaced on the running-program sensor), this is a fixed-interval average + exposed per profile so it can be read for planning before a cycle starts. + + The final bucket is averaged only over the part of the cycle that actually + falls inside it. Pure statistics; never raises. Returns an empty list when + the profile has no learned envelope yet. + """ + try: + if interval_s <= 0: + return [] + env = self.get_envelope(profile_name) + if not isinstance(env, dict): + return [] + avg = env.get("avg") + if ( + not isinstance(avg, list) + or len(avg) < 2 + or not isinstance(avg[0], (list, tuple)) + or len(avg[0]) < 2 + ): + return [] + ts = np.asarray([float(p[0]) for p in avg], dtype=float) + ws = np.asarray([float(p[1]) for p in avg], dtype=float) + if ts.size < 2 or not (np.all(np.isfinite(ts)) and np.all(np.isfinite(ws))): + return [] + if ts[-1] <= ts[0]: + return [] + total = float(env.get("target_duration") or 0.0) + if total <= 0: + total = float(ts[-1]) + if total <= 0: + return [] + n_buckets = int(math.ceil(total / interval_s)) + out: list[float] = [] + for k in range(n_buckets): + lo = k * interval_s + hi = min(lo + interval_s, total) + if hi <= lo: + break + # Time-average the curve over the half-open slot [lo, hi) on a fine + # interpolated grid, so the result is independent of the envelope's + # grid spacing. endpoint=False keeps a slot boundary from being + # double-counted into the adjacent slot. + fine = np.linspace(lo, hi, 16, endpoint=False) + out.append(round(float(np.mean(np.interp(fine, ts, ws))), 1)) + return out + except Exception: # pragma: no cover - defensive; never break the sensor + return [] + def compute_envelope_conformance( self, profile_name: str, @@ -4315,39 +4581,48 @@ class ProfileStore: # Prepare Snapshots. Imported reference cycles are eligible as matching # templates alongside real cycles (so an import-only profile can match). all_cycles = list(self._data["past_cycles"]) + list(self._data.get("reference_cycles", [])) + # Precompute per-profile lookups ONCE so the loop below is O(profiles), + # not O(profiles x cycles). Rescanning all_cycles with next()/any() for + # every profile made matching quadratic and stalled low-power hosts on + # auto-label (many matches x many cycles) - issue #311. Selections are + # byte-identical: cycles_by_id keeps the FIRST occurrence (== next()), + # labeled_by_profile keeps the first eligible cycle in all_cycles order, + # and golden_profiles mirrors the any(...) golden test. + cycles_by_id: dict[str, CycleDict] = {} + labeled_by_profile: dict[str, CycleDict] = {} + golden_profiles: set[str] = set() + for c in all_cycles: + cid = c.get("id") + if cid is not None and cid not in cycles_by_id: + cycles_by_id[cid] = c + pname = c.get("profile_name") + if not pname or not c.get("power_data"): + continue + if ( + pname not in labeled_by_profile + and c.get("status") in ("completed", "force_stopped") + ): + labeled_by_profile[pname] = c + rev = c.get("ml_review") + if isinstance(rev, dict) and rev.get("golden"): + golden_profiles.add(pname) + snapshots: list[dict[str, Any]] = [] skipped_profiles: list[str] = [] for name, profile in self._data["profiles"].items(): # Try sample_cycle_id first, fall back to any labeled cycle sample_id = profile.get("sample_cycle_id") - sample_cycle = None - if sample_id: - sample_cycle = next( - (c for c in all_cycles if c["id"] == sample_id), - None - ) + sample_cycle = cycles_by_id.get(sample_id) if sample_id else None # Fallback: find ANY completed cycle labeled with this profile if not sample_cycle: - sample_cycle = next( - (c for c in all_cycles - if c.get("profile_name") == name - and c.get("status") in ("completed", "force_stopped") - and c.get("power_data")), - None - ) + sample_cycle = labeled_by_profile.get(name) # Boost user-pinned "golden" cycles: when a profile has one, use # its sharp single-cycle trace as the matching template instead # of the envelope average. The envelope average smears the # wash-phase peaks (each cycle's spikes land at slightly # different times), which hurts correlation for sharply-shaped # programs; a trusted golden cycle preserves that shape. - has_golden = any( - c.get("profile_name") == name - and isinstance(c.get("ml_review"), dict) - and c["ml_review"].get("golden") - and c.get("power_data") - for c in all_cycles - ) + has_golden = name in golden_profiles # Prefer envelope avg curve when ≥2 labeled cycles have been # confirmed - it gives a more representative reference signal @@ -4717,6 +4992,34 @@ class ProfileStore: # Save to persist the label await self.async_save() + @property + def has_real_profiles(self) -> bool: + """True if at least one stored profile is backed by a real cycle. + + A profile counts as "real" when it has a labelled cycle in ``past_cycles`` + OR an imported ``reference_cycle`` (store-adopted templates that the matcher + treats as eligible snapshots — see the snapshot builder in async_match). An + import-only install has zero past_cycles but is fully matchable, so it must + pass this gate too, otherwise matching and the setup notifications are + skipped for it entirely. + """ + profile_names = self._data.get("profiles", {}).keys() + if not profile_names: + return False + assigned = { + c.get("profile_name") + for c in self._data.get("past_cycles", []) + if c.get("profile_name") + } + if assigned.intersection(profile_names): + return True + ref_assigned = { + c.get("profile_name") + for c in self._data.get("reference_cycles", []) + if c.get("profile_name") + } + return bool(ref_assigned.intersection(profile_names)) + def list_profiles(self) -> list[dict[str, Any]]: """List all profiles with metadata.""" profiles: list[JSONDict] = [] @@ -5768,8 +6071,21 @@ class ProfileStore: if p_data and p_data.get("sample_cycle_id") == original_sample_id: p_data["sample_cycle_id"] = best_replacement_id - # Rebuild envelope because dataset changed - await self.async_rebuild_envelope(original_profile) + # Rebuild envelopes ONLY for the profiles whose dataset actually changed: + # the original profile (it lost the parent cycle) plus any profile a labeled + # segment was assigned to. This replaces a blanket rebuild-all-envelopes in + # the caller, which re-scanned every profile serially and stalled low-power + # hosts on a split (issue #311 follow-up). + touched: set[str] = set() + if original_profile: + touched.add(original_profile) + for seg in segments: + if isinstance(seg, dict): + seg_prof = seg.get("profile") + if seg_prof: + touched.add(seg_prof) + for name in touched: + await self.async_rebuild_envelope(name) await self.async_save() self._logger.info("Interactive Split Applied to %s -> %s", cycle_id, new_ids) diff --git a/custom_components/ha_washdata/progress.py b/custom_components/ha_washdata/progress.py index 2433300..dd5f707 100644 --- a/custom_components/ha_washdata/progress.py +++ b/custom_components/ha_washdata/progress.py @@ -448,7 +448,7 @@ def estimate_phase_progress( return (best_progress, best_variance) -def compute_progress( +def _compute_progress_base( device_type: str, matched_duration: float, duration_so_far: float, @@ -549,6 +549,76 @@ def compute_progress( return ProgressResult(progress, smoothed, remaining, total, None, "linear") +def compute_progress( + device_type: str, + matched_duration: float, + duration_so_far: float, + prev_smoothed: float, + phase_result: tuple[float, float] | None, + ml_pct: float | None, + logger: logging.Logger | None = None, + phase_remaining_s: float | None = None, +) -> ProgressResult | None: + """Progress/remaining estimate, optionally blended with a phase-resolved ETA. + + When ``phase_remaining_s`` is provided (opt-in phase matching for a supported + device type), the phase-budget remaining is converted to a completion PERCENT + and blended into the phase-progress signal **before** delegating to + :func:`_compute_progress_base` - so the blend rides the proven, golden-locked + EMA + monotonicity + back-calculation guards (design §8, "one smoothing + implementation"), rather than re-deriving a raw, unsmoothed progress. The + blend leans on the phase budget early (low base progress) and on the proven + phase estimate late:: + + phase_pct = duration_so_far / (duration_so_far + phase_remaining_s) * 100 + f = base_phase_progress / 100 + blended = (1 - f) * phase_pct + f * base_phase_progress + + Because this feeds the percent-domain smoothing, the displayed progress stays + monotone/smoothed (no tick-to-tick jitter or collapse-to-99%), and remaining + is re-derived by the base from ``matched_duration``. + + Behaviour is BYTE-IDENTICAL to before when ``phase_remaining_s is None`` (the + default) - the golden progress snapshot and every existing caller are + unaffected. This is the single implementation of the blend; the manager and + the Playground SimRunner both go through it. + """ + blended = False + if phase_remaining_s is not None and matched_duration and matched_duration > 0: + try: + pr = float(phase_remaining_s) + except (TypeError, ValueError): + pr = float("nan") + if math.isfinite(pr) and pr >= 0.0: + denom = duration_so_far + pr + phase_pct = (duration_so_far / denom * 100.0) if denom > 0 else 0.0 + phase_pct = max(0.0, min(100.0, phase_pct)) + if phase_result is not None: + base_pp, variance = phase_result + f = max(0.0, min(1.0, float(base_pp) / 100.0)) + phase_result = ((1.0 - f) * phase_pct + f * float(base_pp), variance) + else: + # No envelope phase-progress: blend the phase budget's implied + # percent with the linear (elapsed/matched) percent, still leaning + # on the phase budget early and the linear estimate late. + lin_pct = max(0.0, min(100.0, duration_so_far / matched_duration * 100.0)) + f = lin_pct / 100.0 + phase_result = ((1.0 - f) * phase_pct + f * lin_pct, 0.0) + blended = True + + base = _compute_progress_base( + device_type, matched_duration, duration_so_far, prev_smoothed, + phase_result, ml_pct, logger, + ) + if base is None or not blended: + return base + # Relabel the source for diagnostics; values already reflect the blend. + return ProgressResult( + base.progress, base.smoothed, base.remaining, base.total, + base.phase_progress, "phase_blend", + ) + + def current_phase( store: Any, state: str, diff --git a/custom_components/ha_washdata/sensor.py b/custom_components/ha_washdata/sensor.py index 04463ca..ccd377f 100644 --- a/custom_components/ha_washdata/sensor.py +++ b/custom_components/ha_washdata/sensor.py @@ -745,6 +745,16 @@ class WasherProfileCountSensor(WasherBaseSensor): "min_length_min": _to_min(profile.get("min_duration", 0)), "max_length_min": _to_min(profile.get("max_duration", 0)), "consistency_min": consistency_min, + # Average power (W) per 15-min slot of this profile's learned shape, + # e.g. [2200, 2200, 800, ...] - the flat array external planners such + # as tibber_prices' `power_profile` consume to pick the cheapest run + # window (issue #272). Empty until the profile has a learned envelope. + # Refreshes with the profile: the envelope is rebuilt on each new + # labelled cycle, which bumps this sensor's cycle_count state. + "power_profile": self._manager.profile_store.get_profile_power_profile( + self._profile_name + ), + "power_profile_interval_min": 15, } diff --git a/custom_components/ha_washdata/services.yaml b/custom_components/ha_washdata/services.yaml index 87e7de8..75f2993 100644 --- a/custom_components/ha_washdata/services.yaml +++ b/custom_components/ha_washdata/services.yaml @@ -86,7 +86,7 @@ auto_label_cycles: name: Confidence Threshold description: Minimum match confidence (0.50-0.95) to apply labels. required: false - default: 0.70 + default: 0.75 selector: number: min: 0.50 diff --git a/custom_components/ha_washdata/setup_advisor.py b/custom_components/ha_washdata/setup_advisor.py new file mode 100644 index 0000000..50306cc --- /dev/null +++ b/custom_components/ha_washdata/setup_advisor.py @@ -0,0 +1,237 @@ +"""Pure phase computation for the adoption guidance system. + +No HA imports. No side effects. Testable in isolation. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +@dataclass +class SetupPhaseResult: + phase: str # phase0 | phase1a | phase1b | phase1c | phase2 | phase3 | phase4 + message_key: str + message_params: dict = field(default_factory=dict) + cta_label_key: str = "setup.cta.start_recording" + cta_action: str = "open_recorder" + secondary_label_key: str | None = None + secondary_action: str | None = None + skippable: bool = False + dismissible: bool = False + step_key: str | None = None # key used in skipped_steps dict + + +def compute_setup_phase( + device_type: str, + profile_names: list[str], + past_cycles: list[dict], + ref_profile_names: set[str], + coverage_gap: dict | None, + suggestions: list[dict], + profile_groups: list[dict], + skipped_steps: dict[str, str | None], + now: datetime, +) -> SetupPhaseResult: + """Compute the current adoption phase for a device. + + Args: + device_type: HA device type string (washing_machine, dishwasher, ...). + profile_names: All profile names stored for this device. + past_cycles: All past cycles (each may have profile_name and meta.source). + ref_profile_names: Profile names that have reference cycles (store-adopted). + coverage_gap: Result of profile_store.suggest_coverage_gaps(), or None. + suggestions: Actionable suggestions from SuggestionEngine (empty list = none). + profile_groups: Profile groups list from store (empty list = none pending). + skipped_steps: Dict of step_key -> "never" | ISO timestamp | None. + now: Current aware datetime for snooze comparisons. + """ + real = _real_profile_names(profile_names, past_cycles) + has_real = bool(real) + has_recorded = _has_recorded_cycles(past_cycles, real) + has_store = bool(ref_profile_names) + has_self_cycles = bool(real) # any cycle assigned to a real profile + + # ── Phase 0 ────────────────────────────────────────────────────────────── + if not has_real and not has_store: + msg_key = { + "washing_machine": "setup.phase0.washer", + "dryer": "setup.phase0.washer", + "washer_dryer": "setup.phase0.washer", + "dishwasher": "setup.phase0.dishwasher", + }.get(device_type, "setup.phase0.generic") + return SetupPhaseResult( + phase="phase0", + message_key=msg_key, + cta_label_key="setup.cta.start_recording", + cta_action="open_recorder", + secondary_label_key="setup.cta.label_detected_cycle", + secondary_action="open_cycles_unlabeled", + skippable=False, + dismissible=False, + ) + + # ── Phase 1c — store download, no self cycles yet ───────────────────────── + if has_store and not has_self_cycles: + return SetupPhaseResult( + phase="phase1c", + message_key="setup.phase1c.verify", + message_params={"count": len(ref_profile_names)}, + cta_label_key="setup.cta.view_profiles", + cta_action="open_profiles", + skippable=False, + dismissible=False, + ) + + # ── Phase 2 — coverage gaps / unmatched nudges ─────────────────────────── + if _phase2_active(coverage_gap, skipped_steps, now): + cg = coverage_gap or {} + clusters = cg.get("profile_suggestions") or [] + if clusters: + first = clusters[0] + return SetupPhaseResult( + phase="phase2", + message_key="setup.phase2.cluster", + message_params={"count": cg.get("unmatched_count", 0), + "cycle_ids": first.get("cycle_ids", []), + "name": first.get("suggested_name", "")}, + cta_label_key="setup.cta.create_from_cluster", + cta_action="create_profile_from_cluster", + skippable=True, + dismissible=False, + step_key="setup_skip_phase2", + ) + last_id = cg.get("last_unmatched_cycle_id") + return SetupPhaseResult( + phase="phase2", + message_key="setup.phase2.unmatched", + message_params={"cycle_id": last_id or ""}, + cta_label_key="setup.cta.create_profile", + cta_action=f"open_cycle:{last_id}" if last_id else "open_cycles_unlabeled", + skippable=True, + dismissible=False, + step_key="setup_skip_phase2", + ) + + # ── Phase 3 — tuning items ──────────────────────────────────────────────── + item = _phase3_pending_item(suggestions, profile_groups, skipped_steps, now) + if item: + return item + + # ── Phase 1a / 1b — early guidance (device has not yet seen coverage stats) ─ + # Only shown while the gap analyser has not yet flagged a coverage gap: + # suggest_coverage_gaps() returns {} (empty) when there are no cycles or not + # enough unmatched cycles, and a dict with suggest_create=True once the device + # has accumulated enough cycles for the gap to be actionable. We treat any + # non-empty dict where suggest_create is True as "past the early stage". + # None is also accepted (function signature allows it) and treated as no gap. + # Also skipped once the user has permanently dismissed ("never") or snoozed the + # Phase 1 guidance via setup_skip_phase1. + if has_real and not (coverage_gap and coverage_gap.get("suggest_create")) and not _is_step_suppressed("setup_skip_phase1", skipped_steps, now): + if has_recorded: + first_recorded_profile = _first_recorded_profile_name(past_cycles, real) + return SetupPhaseResult( + phase="phase1b", + message_key="setup.phase1b.recorded", + message_params={"profile_name": first_recorded_profile or ""}, + cta_label_key="setup.cta.start_recording", + cta_action="open_recorder", + secondary_label_key="setup.cta.browse_cycles", + secondary_action="open_cycles", + skippable=True, + dismissible=False, + step_key="setup_skip_phase1", + ) + return SetupPhaseResult( + phase="phase1a", + message_key="setup.phase1a.labelled", + cta_label_key="setup.cta.start_recording", + cta_action="open_recorder", + secondary_label_key="setup.cta.browse_cycles", + secondary_action="open_cycles", + skippable=True, + dismissible=False, + step_key="setup_skip_phase1", + ) + + # ── Phase 4 — healthy ───────────────────────────────────────────────────── + return SetupPhaseResult( + phase="phase4", + message_key="setup.phase4.healthy", + message_params={"profile_count": len(real)}, + skippable=False, + dismissible=True, + ) + + +# ── Private helpers ─────────────────────────────────────────────────────────── + +def _real_profile_names(profile_names: list[str], past_cycles: list[dict]) -> set[str]: + named = {c.get("profile_name") for c in past_cycles if c.get("profile_name")} + return {n for n in profile_names if n in named} + + +def _has_recorded_cycles(past_cycles: list[dict], real: set[str]) -> bool: + return any( + (c.get("meta") or {}).get("source") == "recorder" + for c in past_cycles + if c.get("profile_name") in real + ) + + +def _first_recorded_profile_name(past_cycles: list[dict], real: set[str]) -> str | None: + for c in past_cycles: + if (c.get("profile_name") in real + and (c.get("meta") or {}).get("source") == "recorder"): + return c["profile_name"] + return None + + +def _is_step_suppressed(step_key: str, skipped_steps: dict, now: datetime) -> bool: + val = skipped_steps.get(step_key) + if not val: + return False + if val == "never": + return True + try: + until = datetime.fromisoformat(val) + if until.tzinfo is None: + until = until.replace(tzinfo=timezone.utc) + return now < until + except (ValueError, TypeError): + return False + + +def _phase2_active(coverage_gap: dict | None, skipped_steps: dict, now: datetime) -> bool: + if not coverage_gap or not coverage_gap.get("suggest_create"): + return False + return not _is_step_suppressed("setup_skip_phase2", skipped_steps, now) + + +def _phase3_pending_item( + suggestions: list[dict], + profile_groups: list[dict], + skipped_steps: dict, + now: datetime, +) -> SetupPhaseResult | None: + if suggestions and not _is_step_suppressed("setup_skip_phase3_suggestions", skipped_steps, now): + return SetupPhaseResult( + phase="phase3", + message_key="setup.phase3.suggestions", + cta_label_key="setup.cta.review_suggestions", + cta_action="open_suggestions", + skippable=True, + dismissible=True, + step_key="setup_skip_phase3_suggestions", + ) + if profile_groups and not _is_step_suppressed("setup_skip_phase3_groups", skipped_steps, now): + return SetupPhaseResult( + phase="phase3", + message_key="setup.phase3.groups", + cta_label_key="setup.cta.organise_profiles", + cta_action="open_profiles_groups", + skippable=True, + dismissible=True, + step_key="setup_skip_phase3_groups", + ) + return None diff --git a/custom_components/ha_washdata/store.py b/custom_components/ha_washdata/store.py index b6ab6d7..5e537eb 100644 --- a/custom_components/ha_washdata/store.py +++ b/custom_components/ha_washdata/store.py @@ -160,6 +160,21 @@ class StoreBridge: self._ps = profile_store self._client = StoreClient(hass) + def _fire_download_telemetry(self, cycle_ids: list[str]) -> None: + """Fire best-effort download/adoption telemetry as a detached background task. + + Awaiting these store round-trips inline would add their HTTP latency (up to the + 15s per-request timeout when the store is slow/unreachable) to the user-facing + adopt response. Their outcome does not affect the result, and both bump_* calls + swallow their own errors, so a detached task can never surface an exception. + """ + async def _bump() -> None: + if cycle_ids: + await self._client.bump_downloads(cycle_ids) + await self._client.bump_analytics("downloads", 1) + + self._hass.async_create_background_task(_bump(), "washdata_store_telemetry") + # ── account / status (global) ─────────────────────────────────────────────── def status(self) -> dict[str, Any]: @@ -243,6 +258,10 @@ class StoreBridge: }) if not local_id: # trace failed validation in add_reference_cycle return {"error": "invalid_trace"} + # Credit the download on the source store cycle + record one community-wide + # adoption for the store's usage dashboard (the real "someone used it" metric). + # Fired in the background so store latency never delays the adopt response. + self._fire_download_telemetry([cyc["id"]] if cyc.get("id") else []) return {"profile": profile, "cycle_id": local_id} async def share_cycle( @@ -378,6 +397,7 @@ class StoreBridge: profiles_adopted = 0 cycles_imported = 0 phases_applied = 0 + imported_store_ids: list[str] = [] for prof in bundle.get("profiles", []) or []: program = str(prof.get("program") or prof.get("program_lc") or "").strip() if not program: @@ -398,6 +418,8 @@ class StoreBridge: if local_id: cycles_imported += 1 adopted_any = True + if store_cid: + imported_store_ids.append(store_cid) if adopted_any: profiles_adopted += 1 # Stage 2: apply the bundled phase map (replace) + reconcile labels. Never @@ -406,6 +428,14 @@ class StoreBridge: # re-download with no new cycles still reconciles updated phase ranges. if prof.get("phases") and await self._apply_phases(program, prof.get("phases"), device_type): phases_applied += 1 + # Credit a download on every reference cycle actually adopted this run (each + # underlying object, not just one). Skipped/duplicate cycles aren't re-counted, + # so re-downloading the same device doesn't inflate the counters. Also record one + # community-wide "download" (adoption) for the store's usage dashboard -- the real + # metric of how many people actually pulled this into their integration. Fired in + # the background so store latency never delays the adopt-bundle response. + if imported_store_ids: + self._fire_download_telemetry(imported_store_ids) settings = bundle.get("settings") if isinstance(bundle.get("settings"), dict) else {} return { "profiles_adopted": profiles_adopted, diff --git a/custom_components/ha_washdata/store_client.py b/custom_components/ha_washdata/store_client.py index fb707ba..aea4bb7 100644 --- a/custom_components/ha_washdata/store_client.py +++ b/custom_components/ha_washdata/store_client.py @@ -36,6 +36,7 @@ from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util from .const import ( SHAREABLE_SETTING_KEYS, @@ -334,7 +335,9 @@ class StoreClient: "structuredQuery": {"from": [{"collectionId": "ratings"}]}, "aggregations": [ {"alias": "cnt", "count": {}}, - {"alias": "avg", "average": {"field": {"fieldPath": "rating"}}}, + # NB: the Firestore aggregation operator is `avg`, NOT `average` -- + # the wrong name 400s the whole query and silently zeroes ratings. + {"alias": "avg", "avg": {"field": {"fieldPath": "rating"}}}, ], }} try: @@ -800,3 +803,59 @@ class StoreClient: if not ok: _LOGGER.warning("Store rate_device failed: %s", body[:200]) return ok + + async def bump_downloads(self, cycle_ids: list[str]) -> None: + """Best-effort +1 to each cycle's public ``downloads`` counter. The store rule + allows an anonymous ``downloads++`` (unauthenticated), so this needs no token -- + it mirrors the website's per-download bump. Chunked to stay under Firestore's + 500-writes-per-commit limit. Never raises.""" + # Deduplicate (order-preserving): Firestore :commit rejects a batch (HTTP 400) + # if it contains two writes to the same document, which a bundle referencing + # the same shared cycle across profiles can produce. + ids = list(dict.fromkeys(c for c in (cycle_ids or []) if c)) + for start in range(0, len(ids), 400): + writes = [ + {"transform": { + "document": self._doc_path(f"cycles/{cid}"), + "fieldTransforms": [{"fieldPath": "downloads", "increment": _encode(1)}], + }} + for cid in ids[start:start + 400] + ] + try: + async with self._sess().post( + f"{self._base}:commit", json={"writes": writes}, timeout=15, + ) as resp: + if resp.status != 200: + _LOGGER.debug("Store bump_downloads HTTP %s: %s", resp.status, (await resp.text())[:200]) + except Exception as exc: # noqa: BLE001 + _LOGGER.debug("Store bump_downloads error: %s", exc) + + async def bump_analytics(self, field: str, n: int = 1) -> None: + """Best-effort +n to an aggregate usage counter the store's admin dashboard reads + (``analytics/totals`` + ``analytics/daily_YYYYMMDD``). This is how integration + downloads become a real, community-wide usage metric -- the website has no + download action, so the integration is the source of truth for "someone adopted + this". Unauthenticated (the analytics rule allows anonymous counter writes) and + never raises. The daily doc id is UTC to line up with the website's own writes. + """ + if not field or n <= 0: + return + day = dt_util.utcnow().strftime("%Y%m%d") # UTC to match the website's daily docs + writes = [ + {"transform": { + "document": self._doc_path(f"analytics/daily_{day}"), + "fieldTransforms": [{"fieldPath": field, "increment": _encode(n)}], + }}, + {"transform": { + "document": self._doc_path("analytics/totals"), + "fieldTransforms": [{"fieldPath": field, "increment": _encode(n)}], + }}, + ] + try: + async with self._sess().post( + f"{self._base}:commit", json={"writes": writes}, timeout=15, + ) as resp: + if resp.status != 200: + _LOGGER.debug("Store bump_analytics HTTP %s: %s", resp.status, (await resp.text())[:200]) + except Exception as exc: # noqa: BLE001 + _LOGGER.debug("Store bump_analytics error: %s", exc) diff --git a/custom_components/ha_washdata/strings.json b/custom_components/ha_washdata/strings.json index e0a9b6d..ed07247 100644 --- a/custom_components/ha_washdata/strings.json +++ b/custom_components/ha_washdata/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "WashData Setup", - "description": "Configure your washing machine or other appliance.\n\nPower sensor is required.\n\n**Next, you will be asked if you want to create your first profile.**", + "description": "Configure your washing machine or other appliance.\n\nPower sensor is required.", "data": { "name": "Device Name", "device_type": "Device Type", @@ -17,14 +17,6 @@ "min_power": "Power readings above this threshold (in watts) indicate the appliance is running. Start with 2W for most devices." } }, - "first_profile": { - "title": "Create First Profile", - "description": "A **Cycle** is a complete run of your appliance (e.g., a load of laundry). A **Profile** groups these cycles by type (e.g., 'Cotton', 'Quick Wash'). Creating a profile now helps the integration estimate duration immediately.\n\nYou can manually select this profile in the device controls if it's not detected automatically.\n\n**Leave 'Profile Name' empty to skip this step.**", - "data": { - "profile_name": "Profile Name (Optional)", - "manual_duration": "Estimated Duration (minutes)" - } - }, "reconfigure": { "title": "Reconfigure WashData", "description": "Update the device name, appliance type, or power sensor.", diff --git a/custom_components/ha_washdata/translations/en.json b/custom_components/ha_washdata/translations/en.json index e0a9b6d..ed07247 100644 --- a/custom_components/ha_washdata/translations/en.json +++ b/custom_components/ha_washdata/translations/en.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "WashData Setup", - "description": "Configure your washing machine or other appliance.\n\nPower sensor is required.\n\n**Next, you will be asked if you want to create your first profile.**", + "description": "Configure your washing machine or other appliance.\n\nPower sensor is required.", "data": { "name": "Device Name", "device_type": "Device Type", @@ -17,14 +17,6 @@ "min_power": "Power readings above this threshold (in watts) indicate the appliance is running. Start with 2W for most devices." } }, - "first_profile": { - "title": "Create First Profile", - "description": "A **Cycle** is a complete run of your appliance (e.g., a load of laundry). A **Profile** groups these cycles by type (e.g., 'Cotton', 'Quick Wash'). Creating a profile now helps the integration estimate duration immediately.\n\nYou can manually select this profile in the device controls if it's not detected automatically.\n\n**Leave 'Profile Name' empty to skip this step.**", - "data": { - "profile_name": "Profile Name (Optional)", - "manual_duration": "Estimated Duration (minutes)" - } - }, "reconfigure": { "title": "Reconfigure WashData", "description": "Update the device name, appliance type, or power sensor.", diff --git a/custom_components/ha_washdata/translations/panel/bg.json b/custom_components/ha_washdata/translations/panel/bg.json index 84fc032..91c9895 100644 --- a/custom_components/ha_washdata/translations/panel/bg.json +++ b/custom_components/ha_washdata/translations/panel/bg.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "{n} открити аномалии (напр. отворена врата по средата на цикъла) — отворете, за да ги видите на графиката", + "artifact_tip": "{n} открити аномалии (напр. отворена врата по средата на цикъла) – отворете, за да ги видите на графиката", "auto": "(автоматично разпознато)", "built_in_tag": "вградена", "declining": "↘ намалява", "energy_low": "По-ниска енергия от обичайното", "energy_spike": "По-висока енергия от обичайното", "fair_fit": "приемливо качество", - "fair_fit_tip": "Умерена последователност на съвпадението — някои цикли, присвоени на този профил, имат по-ниски резултати за доверие. Маркирайте повече цикли или запишете отново профила, за да подобрите точността.", + "fair_fit_tip": "Умерена последователност на съвпадението – някои цикли, присвоени на този профил, имат по-ниски резултати за доверие. Маркирайте повече цикли или запишете отново профила, за да подобрите точността.", "feedback_requested": "Иска се обратна връзка", "golden_cycle": "Записан референтен цикъл", "improving": "↗ подобряване", @@ -16,7 +16,7 @@ "overrun": "Бяга по-дълго от обикновено", "underrun": "Завърши по-бързо от обикновено", "poor_fit": "лошо качество", - "poor_fit_tip": "Непоследователна история на съвпаденията — помислете за възстановяване на този профил", + "poor_fit_tip": "Непоследователна история на съвпаденията – помислете за възстановяване на този профил", "restart_gap_tip": "{n} HA пропуск при рестартиране: графиката на мощността има дупка", "reviewed": "Прегледано", "steady": "→ стабилен", @@ -87,7 +87,7 @@ "on_cycle_finished": "При завършен цикъл", "on_cycle_started": "При стартиране на цикъла", "pause_cycle": "Пауза", - "pause_cycle_tip": "Постави на пауза текущия цикъл — уредът ще продължи от там, където е спрял", + "pause_cycle_tip": "Постави на пауза текущия цикъл – уредът ще продължи от там, където е спрял", "pg_apply_device": "Приложи към устройството", "pg_autofill": "Автоматично попълване", "play": "Възпроизведи", @@ -97,8 +97,8 @@ "process_tip": "Запази записаната следа като нов или съществуващ профил", "rebuild": "Възстановете пликовете", "rebuild_envelope": "Възстановяване на плика", - "rebuild_tip": "Преизчисли очаквания плик на мощността (мин./макс. лента) за всички профили от техните маркирани цикли — стартирай след маркиране на нови цикли или коригиране на стари", - "rec_start_tip": "Начни запис на следата на мощността на уреда — стартирай малко преди да пуснеш цикъл", + "rebuild_tip": "Преизчисли очаквания плик на мощността (мин./макс. лента) за всички профили от техните маркирани цикли – стартирай след маркиране на нови цикли или коригиране на стари", + "rec_start_tip": "Начни запис на следата на мощността на уреда – стартирай малко преди да пуснеш цикъл", "rec_stop": "Спрете", "rec_stop_tip": "Спри записа и задръж записаната следа за преглед", "record": "Започнете запис", @@ -182,7 +182,7 @@ }, "attn_sub": "Поправете конфликтите преди запазване", "attn_title": "{n} конфликт{s} в настройките", - "settings_banner": "{n} конфликт{s} в настройките — проверете маркираните раздели и поправете преди запазване.", + "settings_banner": "{n} конфликт{s} в настройките – проверете маркираните раздели и поправете преди запазване.", "settings_banner_btn": "Към първия", "confidence": { "auto": "Трябва да е на или над Праг на Съвпадение ({match})", @@ -451,9 +451,9 @@ "show_expected": "Показване на очакваното наслагване на крива (съвпадащ профил, оранжево)", "show_raw": "Показване на превключвателя за необработени данни от контакта в живия графикон на мощността", "sparkline": "Тенденция на продължителността на последните цикли", - "stage2": "Етап 2 — основно сходство", - "stage3": "Етап 3 — DTW", - "stage4": "Етап 4 — съответствие", + "stage2": "Етап 2 – основно сходство", + "stage3": "Етап 3 – DTW", + "stage4": "Етап 4 – съответствие", "status": "Статус", "tail_trim": "Подстригване на опашката", "timer_auto_pause": "Автоматична пауза", @@ -561,7 +561,12 @@ "flags": "Маркери", "include_phase_map": "Включи карта на фазите", "include_settings": "Включи настройките", - "show_contributor": "Покажи сътрудника" + "show_contributor": "Покажи сътрудника", + "task_pg_detail": "Симулиране на цикъл", + "task_split": "Разделяне на цикъл", + "task_trim": "Изрязване на цикъл", + "task_merge": "Обединяване на цикли", + "task_rebuild": "Преизчисляване на обвивките" }, "log": { "all_levels": "Всички нива", @@ -645,19 +650,19 @@ "artifact_dip_detail": "Падна под обичайния диапазон на мощността за ~{n} сек.", "artifact_footer": "Подчертано на графиката по-горе. Това са преходни артефакти (напр. вратата се отвори по средата на цикъла), а не непременно проблеми.", "artifact_header": "{n} аномалии, открити по време на този цикъл", - "artifact_pause_detail": "Мощността падна до почти нула за ~{n} сек. и след това се възобнови — вероятно вратата е отворена по средата на цикъла или цикълът е бил поставен на пауза.", + "artifact_pause_detail": "Мощността падна до почти нула за ~{n} сек. и след това се възобнови – вероятно вратата е отворена по средата на цикъла или цикълът е бил поставен на пауза.", "artifact_spike_detail": "Надвиши обичайния диапазон на мощността за ~{n} сек.", "auto_label_intro": "Присвояване на профили към немаркирани цикли, чиято степен на достоверност на съвпадението надвишава прага.", "automations_intro": "WashData задейства събития {start} / {end} и предоставя обекти, затова известията и действията най-добре се изграждат като обикновени автоматизации на Home Assistant. По-долу са показани автоматизациите, използващи това устройство.", "cleanup_intro": "Всеки етикетиран цикъл е насложен. Отбележете отклоненията и изтрийте, за да почистите профила.", "clear_debug_hint": "Премахнете съхранените данни за отстраняване на грешки, за да освободите място.", - "collecting_data": "Събиране на данни — нужни са още {need} цикъл{plural} преди да може да започне прецизната настройка ({current}/{min}).", + "collecting_data": "Събиране на данни – нужни са още {need} цикъл{plural} преди да може да започне прецизната настройка ({current}/{min}).", "compare_overlay_profiles": "Профили на наслагване (бледи)", "compare_profiles_tip": "Наслагнете други профилни пликове върху диаграмата по-горе, за да видите кой най-добре пасва на този цикъл.", - "compare_selected_cycles": "Избрани цикли (плътни) — показване / скриване", + "compare_selected_cycles": "Избрани цикли (плътни) – показване / скриване", "consider_new_profile": "Помислете за създаване на нов профил.", "coverage_gap": "последните цикли нямат съответстващ профил ({pct}% от последните 30).", - "coverage_gap_similar_cycles": "{count} сходни немаркирани цикъла намерени — създайте профил, за да започнете тяхното съпоставяне.", + "coverage_gap_similar_cycles": "{count} сходни немаркирани цикъла намерени – създайте профил, за да започнете тяхното съпоставяне.", "cycles_deleted": "Изтрити цикли: {count}", "enough_data": "Достатъчно данни за обучение ({current}/{min} цикъла).", "export_description": "Експортирайте всички профили и цикли в JSON или възстановете от предишен експорт.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Модели, прецизно настроени към тази машина.", "ml_loading": "зареждане на ML...", "ml_settings_intro": "Два независими превключвателя: единият прилага моделите, докато цикълът работи, другият позволява на WashData да ги прецизира към вашата машина с течение на времето.", - "name_first_program": "Имате достатъчно цикли — наименувайте първата си програма, за да започне съвпадението.", + "name_first_program": "Имате достатъчно цикли – наименувайте първата си програма, за да започне съвпадението.", "near_duplicate_cluster": "открит почти дублиращ се профилен клъстер. Групирането позволява съпоставянето надеждно да избира между сходни програми (напр. същата програма при различна температура/центрофуга).", "no_cycles_match": "Няма цикли, съответстващи на текущия филтър.", "no_cycles_profile": "Няма цикли за този профил.", @@ -696,7 +701,7 @@ "no_devices": "Все още няма конфигурирани устройства WashData.", "no_envelope": "Все още няма плик - изградете отново след цикли на етикетиране.", "no_envelope_overlay": "Няма наличен плик за наслагване.", - "no_fine_tuned": "Все още нищо фино настроено — WashData използва своите вградени модели.", + "no_fine_tuned": "Все още нищо фино настроено – WashData използва своите вградени модели.", "no_logs": "Все още няма буферирани записи в журнала.", "no_maintenance": "Все още няма записана поддръжка.", "no_match_yet": "Все още няма опит за съвпадение - това се попълва по време на цикъл на изпълнение.", @@ -713,7 +718,7 @@ "notify_services_hint": "Използвайте ID на услугите {entity} (разделени със запетая за повече). Променливи на шаблона: {vars}.", "old_actions_warning": "Конфигуриран със стария редактор на действия (вече премахнат). Те все още се задействат при събития от цикъл, но вече не могат да се редактират тук. Преобразувайте ги в нормална автоматизация или ги премахнете.", "onboarding_progress": "{n} / 3 наблюдавани цикъла", - "onboarding_watching": "Оставете уреда да работи нормално — WashData наблюдава. След 3 цикъла ще започне съвпадението на програми.", + "onboarding_watching": "Оставете уреда да работи нормално – WashData наблюдава. След 3 цикъла ще започне съвпадението на програми.", "pending_feedback": "Очаква се обратна връзка за откриване", "performance_trend": "Тенденция на ефективността ({n} цикъла)", "pg_analysis_empty": "Заредете цикъл, за да видите анализ на съвпадението.", @@ -763,7 +768,7 @@ "setting_changed": "Променено от {old} на {new} на {date}", "settings_basic_note": "Показват се основните настройки. Превключете на Разширени за пълния списък.", "shape_drift_advisory": "⚠ Дрейф на формата", - "shape_drift_detail": "Моделът на мощността за този профил се е изместил с времето — вероятно износване на уреда или необходима поддръжка (напр. отстраняване на котлен камък, почистване на филтъра).", + "shape_drift_detail": "Моделът на мощността за този профил се е изместил с времето – вероятно износване на уреда или необходима поддръжка (напр. отстраняване на котлен камък, почистване на филтъра).", "show_all_settings": "Показване на всички настройки", "showing_suggestions": "Показване на {count} настройка с предложения.", "split_intro": "Щракнете върху графиката, за да добавите или премахнете точка на разделяне или автоматично откриване по неактивни празнини. Всеки получен сегмент може да получи свой собствен профил.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Отмяната е неуспешна: {error}", "toast_reverted": "{key} е върнато", "toast_save_failed": "Запазването е неуспешно: {error}", - "trend_duration_longer": "Продължителност с по-голяма тенденция ({pct}%/цикъл) — скорошно средно {avg}", - "trend_duration_shorter": "Продължителността се понижава ({pct}%/цикъл) — скорошно средно {avg}", + "trend_duration_longer": "Продължителност с по-голяма тенденция ({pct}%/цикъл) – скорошно средно {avg}", + "trend_duration_shorter": "Продължителността се понижава ({pct}%/цикъл) – скорошно средно {avg}", "trend_energy_down": "Енергията намалява ({pct}%/цикъл)", - "trend_energy_up": "Тенденция към нарастване на енергията ({pct}%/цикъл) — скорошно средно {avg}", + "trend_energy_up": "Тенденция към нарастване на енергията ({pct}%/цикъл) – скорошно средно {avg}", "trim_intro": "Плъзнете червените манипулатори или въведете стойности. Всичко извън прозореца се премахва.", "tuning_suggestions_available": "{count} предложение за настройка налично от наблюдавани цикли. Те се появяват до съответните полета.", "unsure_detected_prefix": "WashData не е сигурен, че е открит", @@ -857,7 +862,9 @@ "share_guidelines_title": "Преди споделяне", "store_download_device_intro": "Изтеглете настройките на устройство от общността и ги приложете към ново или съществуващо устройство", "store_share_device_intro": "Споделете програмите на вашето устройство (профили + референтни цикли) с общността. Настройките са незадължителни.", - "share_profile_no_cycles": "Профилът '{p}' няма ⭐ референтни цикли -- ще бъде пропуснат, освен ако нямате {n}+ потвърдени стартирания" + "share_profile_no_cycles": "Профилът '{p}' няма ⭐ референтни цикли -- ще бъде пропуснат, освен ако нямате {n}+ потвърдени стартирания", + "advisory_phase_inconsistent": "Изглежда, че '{name}' смесва различни програми или температури - циклите му загряват за много различно време. Разделянето му на отделни профили (напр. по температура) ще подобри съпоставянето и оценките на времето.", + "advisory_phase_inconsistent_title": "⚠ Възможно смесени програми" }, "phase_desc": { "anti_crease": "Случайни кратки движения след завършване за намаляване на бръчките.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Незадължителни външни сигнали: задействане на края, сензор за врата, превключвател за пауза и напомнянето за разтоварване.", "label": "Задействания и врати" + }, + "phase_eta": { + "label": "Оставащо време", + "intro": "Оценка на оставащото време, съобразена с фазите, за машини, чиято продължителност на цикъла зависи от температурата или центрофугирането." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Фиксирана цена за kWh, използвана за стойности на разходите, когато по-горе не е зададен обект на реална цена.", "label": "Статична цена на енергия (на kWh)" }, + "energy_sensor": { + "doc": "Незадължителен натрупващ брояч на енергия (total_increasing kWh/Wh, напр. собственият брояч за общото потребление на контакта). Когато е зададен, енергията, отчитана за всеки цикъл, се взема от разликата в показанията на този брояч между началото и края, което избягва подценяването, което се получава при интегриране на бавно докладващ сензор за мощност. Връща се към интегрираната стойност, ако показанието липсва, мерната му единица е неизвестна или разликата не е положителна. Оставете празно, за да продължите да интегрирате сензора за мощност.", + "label": "Обект на измервателен уред за енергия" + }, "expose_debug_entities": { "doc": "Публикувайте допълнителни диагностични HA обекти (доверие на съвпадението, неяснота, вътрешно състояние). Off поддържа списъка с обекти чист за нормална употреба.", "label": "Изложи обекти за отстраняване на грешки" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Покажи името на сътрудника в профилите, изтеглени от магазина на общността" + }, + "enable_phase_matching": { + "label": "Оставащо време съобразено с фазите", + "doc": "Разделя всеки активен цикъл на фази (нагряване, пране, центрофугиране) и разпределя оставащото време по фази, съчетано с класическата оценка - като разчита на разпределението по фази в началото на цикъла и на класическата оценка към края. Това персонализира обратното броене спрямо това колко всъщност загрява и работи вашата машина, което е най-осезаемо през първата половина на цикъла. Изключено = само класическата оценка. Засяга се само показването на оставащото време; съпоставянето на програми и откриването на цикли остават непроменени." + }, + "keep_min_score": { + "label": "Мин. резултат на съвпадение", + "doc": "Минимален резултат на сходство, необходим на кандидата, за да остане в надпреварата. Стойността по подразбиране 0,1 е умишлено щедра - допуска дори слаби кандидати и разчита на по-късните етапи за намиране на най-доброто съвпадение. Увеличете за по-ранно отхвърляне на малко вероятни профили; намалете за разширяване на началния набор от кандидати." + }, + "dtw_blend": { + "label": "Смесване на деформация", + "doc": "Колко DTW резултатът от изравняването замества основния резултат от Етап 2. 0 = използвай само резултата от Етап 2, 1 = използвай само DTW резултата, 0,5 (по подразбиране) = равно смесване. Увеличете за по-голямо разчитане на warp изравняване, когато програмите имат подобни нива на мощност, но различни времеви модели." + }, + "dtw_ensemble_w": { + "label": "Микс на комбинация DTW", + "doc": "В режим на ансамбъл DTW (по подразбиране), тежест на мащабиран L1 срещу производен DTW (DDTW). 1,0 = само мащабиран L1, 0 = само DDTW, 0,7 = по подразбиране. Мащабираният вариант L1 сравнява нивата на мощност; производният вариант реагира на промените на мощността във времето. Ансамблът обединява и двете." + }, + "dtw_ddtw_scale": { + "label": "Скала на производна деформация", + "doc": "Разстояние на полунасищане за оценяване с производен DTW. Резултатът се намалява наполовина, когато разстоянието DDTW е равно на тази стойност. По-малко = по-чувствително към разлики в формата. Стойността по подразбиране 30 е калибрирана за типични следи на мощност на домакински уреди. Засяга само режимите ensemble и ddtw на DTW." + }, + "dtw_refine_top_n": { + "label": "Брой прецизирани кандидати", + "doc": "Колко от водещите кандидати от Етап 2 DTW преоценява. DTW е по-скъп, затова се прилага само за N-те най-добри кандидати. По подразбиране 5. Увеличете (до 7-9), ако правилният профил понякога достига само 4-то или 5-то място след Етап 2 - DTW може да го спаси. Намаляването леко ускорява съпоставянето." + }, + "duration_scale": { + "label": "Острота на продължителност", + "doc": "Острота на санкцията за несъответствие на продължителността в Етап 4. Това е лог-съотношение, при което резултатът на съответствие се намалява наполовина. По-малко = по-строго: несъответствието на продължителността снижава резултата повече. Стойността по подразбиране 0,175 съответства на толеранс на продължителността от около 18% при половинна тежест. Комбинирайте с Тежестта на продължителността." + }, + "energy_scale": { + "label": "Острота на енергия", + "doc": "Острота на санкцията за несъответствие на енергията в Етап 4. По-малко = по-строго: несъответствието на енергията снижава резултата повече. Стойността по подразбиране 0,25 е умишлено по-щедра от Остротата на продължителността, тъй като енергията варира с натоварването. Комбинирайте с Тежестта на енергията." } }, "setting_group": { @@ -1553,24 +1600,24 @@ "lower": "Означава повече цикли автоматично; някои може да са грешно идентифицирани" }, "duration_tolerance": { - "higher": "Приема по-широк диапазон на продължителност — по-гъвкаво съвпадение", - "lower": "Изисква по-точно съвпадение на продължителността — по-строго, но може да отхвърли необичайни цикли" + "higher": "Приема по-широк диапазон на продължителност – по-гъвкаво съвпадение", + "lower": "Изисква по-точно съвпадение на продължителността – по-строго, но може да отхвърли необичайни цикли" }, "end_energy_threshold": { "higher": "Затваря само цикли, използвали значителна енергия", "lower": "Затваря и по-кратки или по-малко енергийни цикли" }, "end_repeat_count": { - "higher": "Изисква сигналът за край да се повтори повече пъти — по-надеждно, малко по-бавно", - "lower": "Завършва след по-малко повторения — по-бързо откриване, по-висок риск от фалшиво завършване" + "higher": "Изисква сигналът за край да се повтори повече пъти – по-надеждно, малко по-бавно", + "lower": "Завършва след по-малко повторения – по-бързо откриване, по-висок риск от фалшиво завършване" }, "learning_confidence": { - "higher": "Учи само от много уверени съвпадения — по-бавно, но по-надеждно", + "higher": "Учи само от много уверени съвпадения – по-бавно, но по-надеждно", "lower": "Учи от повече цикли; моделът може да е по-зашумен" }, "min_off_gap": { "higher": "Необходим е по-дълъг период на неактивност преди нов цикъл", - "lower": "Кратък период на неактивност стартира нов цикъл — последователните работи може да се разделят" + "lower": "Кратък период на неактивност стартира нов цикъл – последователните работи може да се разделят" }, "min_power": { "higher": "По-малко чувствителен към фоновата консумация; може да пропусне тихи програми", @@ -1581,24 +1628,24 @@ "lower": "По-бързо принудително спиране на заседнал цикъл" }, "off_delay": { - "higher": "Повече време за паузи на уреда — справя се с дълги накисвания или сушене", - "lower": "По-бързо засичане на края — подходящо за уреди с ясно вкл./изкл." + "higher": "Повече време за паузи на уреда – справя се с дълги накисвания или сушене", + "lower": "По-бързо засичане на края – подходящо за уреди с ясно вкл./изкл." }, "profile_match_threshold": { "higher": "Потвърждава само съвпадения с висока увереност; повече цикли остават без етикет", "lower": "Съпоставя повече цикли; някои може да са леко грешни" }, "running_dead_zone": { - "higher": "Игнорира кратките скокове на мощност в режим на готовност — по-малко чувствителен към шум", - "lower": "Реагира на по-кратки изблици на мощност — улавя бързи преходни активности" + "higher": "Игнорира кратките скокове на мощност в режим на готовност – по-малко чувствителен към шум", + "lower": "Реагира на по-кратки изблици на мощност – улавя бързи преходни активности" }, "start_threshold_w": { "higher": "Избягва фалшиви стартове от кратковременни скокове на мощност", "lower": "Улавя програми с ниска мощност; може да се задейства от смущения" }, "stop_threshold_w": { - "higher": "Завършва цикъла по-бързо — може да затвори при пауза", - "lower": "Изчаква по-дълго за завършване — по-малко преждевременни завършвания" + "higher": "Завършва цикъла по-бързо – може да затвори при пауза", + "lower": "Изчаква по-дълго за завършване – по-малко преждевременни завършвания" }, "watchdog_interval": { "higher": "Разрешени по-дълги тихи фази; по-малко фалшиви принудни спирания", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Секунди над прага за потвърждаване на старта", "start_threshold_w": "Минимални ватове, за да се счита за стартирал", "stop_threshold_w": "Под тази стойност машината се счита за изключена", - "dtw_bandwidth": "Колко разтягане във времето допуска съпоставянето по форма", - "corr_weight": "Тегло на формата на кривата спрямо нивото на мощността при съпоставянето", - "duration_weight": "Колко съвпадението по продължителност влияе на съпоставянето", - "energy_weight": "Колко съвпадението по енергия влияе на съпоставянето", - "profile_match_min_duration_ratio": "Най-краткото изпълнение (спрямо профила), което все още може да съвпадне", - "profile_match_max_duration_ratio": "Най-дългото изпълнение (спрямо профила), което все още може да съвпадне" + "dtw_bandwidth": "Етап 3: лента за деформация на Sakoe-Chiba (0 = DTW изключено; по подразбиране 0,2 = 20% от дължината на цикъла)", + "corr_weight": "Етап 2: баланс между формата на кривата (корелация) и нивото на мощността (MAE); по подразбиране 0,45", + "duration_weight": "Етап 4: колко силно съответствието на продължителността влияе върху крайния резултат (по подразбиране 0,22)", + "energy_weight": "Етап 4: колко силно съответствието на енергията влияе върху крайния резултат (по подразбиране 0,22)", + "profile_match_min_duration_ratio": "Етап 1: минимална дължина на цикъла като дял от продължителността на профила; по подразбиране 0,1 (10%)", + "profile_match_max_duration_ratio": "Етап 1: максимална дължина на цикъла като дял от продължителността на профила; по подразбиране 1,5 (150%)", + "keep_min_score": "Етап 2: минимален резултат за оставане в надпреварата; по подразбиране 0,1 допуска слаби съвпадения, по-късните етапи избират най-доброто", + "dtw_blend": "Етап 3: 0 = само основен резултат, 1 = само DTW резултат, 0,5 = равно смесване (по подразбиране)", + "dtw_ensemble_w": "Етап 3 (режим на ансамбъл): тежест на мащабиран L1 срещу производен DTW; по подразбиране 0,7 дава предимство на нивото", + "dtw_ddtw_scale": "Етап 3: разстояние на полунасищане на DDTW; по-малко = по-чувствително към форма (по подразбиране 30)", + "dtw_refine_top_n": "Етап 3: кандидати, преоценени от DTW; увеличете до 7-9, ако правилният профил заема 4.-5. място (по подразбиране 5)", + "duration_scale": "Етап 4: лог-съотношение, при което съответствието на продължителността се намалява наполовина; по-малко = по-строга санкция (по подразбиране 0,175)", + "energy_scale": "Етап 4: лог-съотношение, при което съответствието на енергията се намалява наполовина; по-малко = по-строга санкция (по подразбиране 0,25)" }, "col": { "profile_tip": "Име на съпоставената програма. „Без етикет“ означава, че в края на цикъла не е съвпаднал нито един профил.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Оптимизиране: {param}" + }, + "pg_detail": { + "simulate": "Симулиране на цикъл" + }, + "split": { + "apply": "Разделяне на цикъл" + }, + "trim": { + "apply": "Изрязване на цикъл" + }, + "merge": { + "apply": "Обединяване на цикли" + }, + "rebuild": { + "envelopes": "Преизчисляване на обвивките" + }, + "cancelling": "Отмяна..." + }, + "setup": { + "hdr": { + "card": "Настройка на устройството", + "healthy_chip": "Настройката е завършена" + }, + "phase0": { + "washer": "WashData вече открива вашите цикли. Запишете първия цикъл, за да активирате имена на програми и прогнози за времето.", + "dishwasher": "WashData наблюдава. Съдомиялните машини имат сложни цикли – записването на първия цикъл е настоятелно препоръчително. Ако открит цикъл тече прекалено дълго, използвайте редактора на цикли, за да го изрежете преди запазване като профил.", + "generic": "WashData наблюдава. Запишете или маркирайте открит цикъл, за да започнете да изграждате профили." + }, + "phase1a": { + "labelled": "Добро начало – вашата първа програма е запазена. За най-чисти данни обмислете записването на следващия цикъл с джаджата за запис." + }, + "phase1b": { + "recorded": "Вашият запис е запазен като {profile_name}. Сега запишете или маркирайте другите чести програми, за да разширите покритието." + }, + "phase1c": { + "verify": "Имате {count} програми от общността. Стартирайте цикъл, за да проверите дали WashData ги разпознава правилно – съвпадението ще се подобрява с натрупването на собствена история от устройството." + }, + "phase2": { + "cluster": "WashData е видял {count} цикъла, които не съответстват на нито една запазена програма – те си приличат. Искате ли да създадете нов профил за тях?", + "unmatched": "Последният ви цикъл не съответства на нито една запазена програма. Това нова програма ли е?" + }, + "phase3": { + "suggestions": "WashData има препоръки за настройки въз основа на историята на вашите цикли – прегледайте ги за подобряване на точността на разпознаване.", + "groups": "Някои от профилите ви изглеждат като една и съща програма при различни температури. Организирайте ги в група за по-добро съвпадение.", + "phases": "Добавете фази на програмата към {profile_name} за по-точни прогнози за оставащото време." + }, + "phase4": { + "healthy": "Това устройство е напълно настроено ({profile_count} профила)." + }, + "cta": { + "start_recording": "Стартирай запис", + "label_detected_cycle": "Вече имам открит цикъл – маркирай го вместо това", + "browse_cycles": "Преглед на цикли за маркиране на следващ", + "view_profiles": "Вижте вашите профили", + "create_from_cluster": "Създай профил от тези цикли", + "create_profile": "Създай за него профил", + "review_suggestions": "Прегледай препоръките", + "organise_profiles": "Организирай профилите", + "configure_phases": "Конфигурирай фазите", + "skip_step": "Пропусни тази стъпка", + "skip_forever": "Не показвай отново", + "hide_guidance": "Скрий насоките", + "show_guidance": "Покажи насоките" } } } diff --git a/custom_components/ha_washdata/translations/panel/bs.json b/custom_components/ha_washdata/translations/panel/bs.json index 919210d..c49b303 100644 --- a/custom_components/ha_washdata/translations/panel/bs.json +++ b/custom_components/ha_washdata/translations/panel/bs.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "Otkrivene su {n} anomalije (npr. vrata otvorena sredinom ciklusa) — otvorite da ih vidite na grafikonu", + "artifact_tip": "Otkrivene su {n} anomalije (npr. vrata otvorena sredinom ciklusa) – otvorite da ih vidite na grafikonu", "auto": "(automatski otkriveno)", "built_in_tag": "ugrađen", "declining": "↘ u opadanju", "energy_low": "Niža potrošnja energije nego inače", "energy_spike": "Viša potrošnja energije nego inače", "fair_fit": "prihvatljiva kvaliteta", - "fair_fit_tip": "Umjerena konzistentnost podudaranja — neki ciklusi dodijeljeni ovom profilu imaju niže rezultate pouzdanosti. Označite više ciklusa ili ponovo snimite profil da biste poboljšali preciznost.", + "fair_fit_tip": "Umjerena konzistentnost podudaranja – neki ciklusi dodijeljeni ovom profilu imaju niže rezultate pouzdanosti. Označite više ciklusa ili ponovo snimite profil da biste poboljšali preciznost.", "feedback_requested": "Zatražena je povratna informacija", "golden_cycle": "Snimljeni referentni ciklus", "improving": "↗ poboljšanje", @@ -87,7 +87,7 @@ "on_cycle_finished": "Završen ciklus", "on_cycle_started": "Započeo ciklus", "pause_cycle": "Pauza", - "pause_cycle_tip": "Pauzirajte aktivni ciklus — uređaj će se nastaviti od mjesta na kojem je stao", + "pause_cycle_tip": "Pauzirajte aktivni ciklus – uređaj će se nastaviti od mjesta na kojem je stao", "pg_apply_device": "Primijeni na uređaj", "pg_autofill": "Automatsko popunjavanje", "play": "Reproduciraj", @@ -97,8 +97,8 @@ "process_tip": "Sačuvajte snimljeni trag kao novi ili postojeći profil", "rebuild": "Obnovi koverte", "rebuild_envelope": "Obnovi kovertu", - "rebuild_tip": "Preračunajte očekivanu kovertu snage (min/maks pojas) za sve profile iz njihovih označenih ciklusa — pokrenite nakon označavanja novih ciklusa ili ispravljanja starih", - "rec_start_tip": "Počnite snimanje traga snage uređaja — pokrenite neposredno prije pokretanja ciklusa", + "rebuild_tip": "Preračunajte očekivanu kovertu snage (min/maks pojas) za sve profile iz njihovih označenih ciklusa – pokrenite nakon označavanja novih ciklusa ili ispravljanja starih", + "rec_start_tip": "Počnite snimanje traga snage uređaja – pokrenite neposredno prije pokretanja ciklusa", "rec_stop": "Zaustavi", "rec_stop_tip": "Zaustavite snimanje i zadržite snimljeni trag radi pregleda", "record": "Započni snimanje", @@ -231,7 +231,7 @@ "interval": "Treba biti najmanje 2× Interval uzorkovanja ({si} s)", "sampling": "Interval uzorkovanja treba biti najviše polovina Intervala čuvara ({wi} s)" }, - "settings_banner": "{n} konflikt{s} postavki — provjerite istaknute sekcije i ispravite prije pohrane.", + "settings_banner": "{n} konflikt{s} postavki – provjerite istaknute sekcije i ispravite prije pohrane.", "settings_banner_btn": "Idi na prvi" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Prikaži očekivani prekrivač krivulje (podudarni profil, narandžasta)", "show_raw": "Prikaži prekidač sirovih očitanja utičnice u živom grafu snage", "sparkline": "Trend trajanja posljednjih ciklusa", - "stage2": "Faza 2 — osnovna sličnost", - "stage3": "Faza 3 — DTW", - "stage4": "Faza 4 — slaganje", + "stage2": "Faza 2 – osnovna sličnost", + "stage3": "Faza 3 – DTW", + "stage4": "Faza 4 – slaganje", "status": "Status", "tail_trim": "Obrub repa (s)", "timer_auto_pause": "Automatska pauza", @@ -561,7 +561,12 @@ "flags": "Oznake", "include_phase_map": "Uključi mapu faza", "include_settings": "Uključi postavke", - "show_contributor": "Prikaži doprinositelja" + "show_contributor": "Prikaži doprinositelja", + "task_pg_detail": "Simuliraj ciklus", + "task_split": "Dijeljenje ciklusa", + "task_trim": "Obrezivanje ciklusa", + "task_merge": "Spajanje ciklusa", + "task_rebuild": "Ponovna izgradnja omotača" }, "log": { "all_levels": "Svi nivoi", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Pao ispod uobičajenog pojasa snage za ~{n}s.", "artifact_footer": "Istaknuto na grafikonu iznad. To su prolazni artefakti (npr. vrata se otvaraju usred ciklusa), ne nužno problemi.", "artifact_header": "{n} anomalija otkrivenih tokom ovog ciklusa", - "artifact_pause_detail": "Snaga je pala na gotovo nulu za ~{n}s, a zatim se nastavila — vjerovatno su vrata otvorena sredinom ciklusa ili je ciklus pauziran.", + "artifact_pause_detail": "Snaga je pala na gotovo nulu za ~{n}s, a zatim se nastavila – vjerovatno su vrata otvorena sredinom ciklusa ili je ciklus pauziran.", "artifact_spike_detail": "Prešao iznad uobičajenog pojasa snage za ~{n}s.", "auto_label_intro": "Dodijelite profile neoznačenim ciklusima čija pouzdanost podudaranja briše prag.", "automations_intro": "WashData pokreće događaje {start} / {end} i izlaže entitete, pa je obavijesti i radnje najbolje graditi kao normalne automatizacije Home Assistanta. Automatizacije koje koriste ovaj uređaj prikazane su ispod.", @@ -654,10 +659,10 @@ "collecting_data": "Prikupljanje podataka: još {need} ciklusa do početka finog podešavanja ({current}/{min}).", "compare_overlay_profiles": "Profili preklapanja (slabi)", "compare_profiles_tip": "Prekrijte druge koverte profila na gornjoj tabeli da vidite koja najbolje odgovara ovom ciklusu.", - "compare_selected_cycles": "Odabrani ciklusi (puni) — prikaži / sakrij", + "compare_selected_cycles": "Odabrani ciklusi (puni) – prikaži / sakrij", "consider_new_profile": "Razmislite o kreiranju novog profila.", "coverage_gap": "nedavni ciklusi nemaju odgovarajući profil ({pct}% od posljednjih 30).", - "coverage_gap_similar_cycles": "Pronađeno {count} sličnih neoznačenih ciklusa — kreirajte profil da počnete s njihovim uparivanjem.", + "coverage_gap_similar_cycles": "Pronađeno {count} sličnih neoznačenih ciklusa – kreirajte profil da počnete s njihovim uparivanjem.", "cycles_deleted": "Izbrisano ciklusa: {count}", "enough_data": "Dovoljno podataka za učenje ({current}/{min} ciklusa).", "export_description": "Izvezite sve profile i cikluse u JSON ili vratite iz prethodnog izvoza.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modeli fino prilagođeni ovoj mašini.", "ml_loading": "učitavanje ML-a…", "ml_settings_intro": "Dva nezavisna prekidača: jedan primenjuje modele dok ciklus traje, drugi omogućava WashData da ih fino podesi prema vašoj mašini tokom vremena.", - "name_first_program": "Imate dovoljno ciklusa — imenujte svoj prvi program da započnete podudaranje.", + "name_first_program": "Imate dovoljno ciklusa – imenujte svoj prvi program da započnete podudaranje.", "near_duplicate_cluster": "otkriven skoro duplirani klaster profila. Grupisanje omogućava pouzdano uparivanje biranja između sličnih (npr. isti program na različitoj temperaturi/centrifikaciji).", "no_cycles_match": "Nijedan ciklus ne odgovara trenutnom filteru.", "no_cycles_profile": "Nema ciklusa za ovaj profil.", @@ -696,7 +701,7 @@ "no_devices": "Još nema konfiguriranih uređaja WashData.", "no_envelope": "Još nema koverte - obnovite nakon ciklusa označavanja.", "no_envelope_overlay": "Nema dostupne koverte za preklapanje.", - "no_fine_tuned": "Još ništa fino podešeno — WashData koristi svoje ugrađene modele.", + "no_fine_tuned": "Još ništa fino podešeno – WashData koristi svoje ugrađene modele.", "no_logs": "Još uvijek nema memorisanih zapisa dnevnika.", "no_maintenance": "Održavanje još nije zabilježeno.", "no_match_yet": "Još nema pokušaja podudaranja - ovo se popunjava tokom ciklusa trčanja.", @@ -713,7 +718,7 @@ "notify_services_hint": "Koristite ID-ove servisa {entity} (odvojene zarezima za više). Varijable predloška: {vars}.", "old_actions_warning": "Konfigurisano sa starim uređivačem akcija (sada uklonjeno). I dalje se aktiviraju na događaje ciklusa, ali se više ne mogu uređivati ​​ovdje. Pretvorite ih u normalnu automatizaciju ili ih uklonite.", "onboarding_progress": "{n} / 3 ciklusa promatrano", - "onboarding_watching": "Pustite uređaj da radi normalno — WashData prati. Nakon 3 ciklusa počinje podudaranje programa.", + "onboarding_watching": "Pustite uređaj da radi normalno – WashData prati. Nakon 3 ciklusa počinje podudaranje programa.", "pending_feedback": "Čeka se povratna informacija o detekciji", "performance_trend": "Trend performansi ({n} ciklusa)", "pg_analysis_empty": "Učitajte ciklus da vidite analizu podudaranja.", @@ -763,7 +768,7 @@ "setting_changed": "Promijenjeno sa {old} na {new} dana {date}", "settings_basic_note": "Prikazuju se osnovne postavke. Prebacite na Napredno za potpunu listu.", "shape_drift_advisory": "⚠ Drift oblika", - "shape_drift_detail": "Obrazac snage za ovaj profil je promijenjen tokom vremena — mogući habanje uređaja ili potrebno održavanje (npr. uklanjanje kamenca, čišćenje filtera).", + "shape_drift_detail": "Obrazac snage za ovaj profil je promijenjen tokom vremena – mogući habanje uređaja ili potrebno održavanje (npr. uklanjanje kamenca, čišćenje filtera).", "show_all_settings": "Prikaži sve postavke", "showing_suggestions": "Prikazana je postavka {count} s prijedlozima.", "split_intro": "Kliknite na grafikon da biste dodali ili uklonili tačku razdvajanja, ili automatsko otkrivanje po prazninama u mirovanju. Svaki rezultujući segment može dobiti svoj profil.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Povrat neuspješan: {error}", "toast_reverted": "{key} vraćeno", "toast_save_failed": "Snimanje neuspješno: {error}", - "trend_duration_longer": "Trajanje u trendu duže ({pct}%/ciklus) — nedavno prosječno {avg}", - "trend_duration_shorter": "Trajanje trenda kraće ({pct}%/ciklus) — nedavno prosječno {avg}", + "trend_duration_longer": "Trajanje u trendu duže ({pct}%/ciklus) – nedavno prosječno {avg}", + "trend_duration_shorter": "Trajanje trenda kraće ({pct}%/ciklus) – nedavno prosječno {avg}", "trend_energy_down": "Opadanje energije ({pct}%/ciklus)", - "trend_energy_up": "Energetski trend porasta ({pct}%/ciklus) — nedavni prosjek {avg}", + "trend_energy_up": "Energetski trend porasta ({pct}%/ciklus) – nedavni prosjek {avg}", "trim_intro": "Prevucite crvene ručke ili unesite vrijednosti. Sve van prozora je uklonjeno.", "tuning_suggestions_available": "{count} prijedlog podešavanja dostupan je iz posmatranih ciklusa. Pojavljuju se pored relevantnih polja.", "unsure_detected_prefix": "WashData nije siguran da je otkriven", @@ -857,7 +862,9 @@ "share_guidelines_title": "Prije dijeljenja", "store_download_device_intro": "Preuzmite postavke uređaja iz zajednice i primijenite ih na novi ili postojeći uređaj", "store_share_device_intro": "Podijelite programe svog uređaja (profile + referentne cikluse) sa zajednicom. Postavke su opcione.", - "share_profile_no_cycles": "Profil '{p}' nema ⭐ referentnih ciklusa -- bit će preskočen osim ako nemate {n}+ potvrđenih pokretanja" + "share_profile_no_cycles": "Profil '{p}' nema ⭐ referentnih ciklusa -- bit će preskočen osim ako nemate {n}+ potvrđenih pokretanja", + "advisory_phase_inconsistent": "Čini se da '{name}' miješa različite programe ili temperature - njegovi ciklusi se griju vrlo različito dugo. Podjela na zasebne profile (npr. po temperaturi) poboljšat će podudaranje i procjene vremena.", + "advisory_phase_inconsistent_title": "⚠ Možda pomiješani programi" }, "phase_desc": { "anti_crease": "Povremena kratka spuštanja nakon završetka za smanjenje bora.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Opcioni vanjski signali: okidač kraja, senzor vrata, prekidač za pauzu i podsjetnik za istovar.", "label": "Okidači i vrata" + }, + "phase_eta": { + "label": "Preostalo vrijeme", + "intro": "Preostalo vrijeme koje uzima u obzir faze, za uređaje čija dužina ciklusa ovisi o temperaturi ili centrifugi." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Fiksna cijena po kWh koja se koristi za brojke troškova kada nije postavljen nijedan entitet cijene uživo iznad.", "label": "Statična cijena energije (po kWh)" }, + "energy_sensor": { + "doc": "Opcionalno kumulativno brojilo energije (total_increasing kWh/Wh, npr. vlastito brojilo ukupne potrošnje utičnice). Kada je postavljeno, energija prijavljena za svaki ciklus uzima se iz razlike očitanja ovog brojila između početka i kraja, čime se izbjegava podbrojavanje koje nastaje integriranjem sporo izvještavajućeg senzora snage. Vraća se na integriranu vrijednost ako očitanje nedostaje, njegova jedinica je nepoznata ili razlika nije pozitivna. Ostavite prazno da nastavite integrirati senzor snage.", + "label": "Entitet brojila energije" + }, "expose_debug_entities": { "doc": "Objavite dodatne dijagnostičke HA entitete (pouzdanost podudaranja, dvosmislenost, unutrašnje karakteristike stanja). Isključeno održava listu entiteta čistom za normalnu upotrebu.", "label": "Izložiti entitete za otklanjanje grešaka" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Prikaži ime doprinositelja na profilima preuzetim iz prodavnice zajednice" + }, + "enable_phase_matching": { + "label": "Preostalo vrijeme prema fazama", + "doc": "Svaki aktivni ciklus dijeli na faze (grijanje, pranje, centrifuga) i raspoređuje preostalo vrijeme po fazama, spojeno s klasičnom procjenom - na početku ciklusa oslanja se na budžet faza, a pri kraju na klasičnu procjenu. To prilagođava odbrojavanje tome koliko se vaš uređaj stvarno grije i radi, što je najuočljivije u prvoj polovini ciklusa. Isključeno = samo klasična procjena. Utječe samo na prikaz preostalog vremena; podudaranje programa i detekcija ciklusa ostaju nepromijenjeni." + }, + "keep_min_score": { + "label": "Min. ocjena podudaranja", + "doc": "Minimalna ocjena sličnosti potrebna kandidatu za ostanak u utrci podudaranja. Zadana vrijednost 0,1 je namjerno blaga - dopušta čak i slabe kandidate i oslanja se na kasnijе faze za pronalazak najboljeg podudaranja. Povećajte za ranije odbacivanje neprobabilnih profila; smanjite za proširenje početnog skupa kandidata." + }, + "dtw_blend": { + "label": "Miješanje deformacije", + "doc": "Koliko DTW ocjena poravnanja zamjenjuje osnovnu ocjenu Faze 2. 0 = koristi samo ocjenu Faze 2, 1 = koristi samo DTW ocjenu, 0,5 (zadano) = jednako miješanje. Povećajte za veće oslanjanje na warp poravnanje kada programi imaju slične nivoe snage ali različite obrasce tajminga." + }, + "dtw_ensemble_w": { + "label": "Miks kombinacije DTW", + "doc": "U kombiniranom DTW načinu (zadanom), težina skaliranog L1 nasuprot derivatnog DTW (DDTW). 1,0 = sve skalirano L1, 0 = sve DDTW, 0,7 = zadano. Skalirana L1 varijanta uspoređuje nivoe snage; derivatna varijanta reagira na promjene snage kroz vrijeme. Kombinirani način spaja oboje." + }, + "dtw_ddtw_scale": { + "label": "Skala derivatne deformacije", + "doc": "Udaljenost polu-zasićenja za ocjenjivanje derivatnim DTW. Ocjena se prepolovi kada je DDTW udaljenost jednaka ovoj vrijednosti. Manje = osjetljivije na razlike u obliku. Zadana vrijednost 30 je kalibrirana za tipične tragove snage kućanskih uređaja. Utječe samo na kombinirani i ddtw DTW način." + }, + "dtw_refine_top_n": { + "label": "Broj preciziranih kandidata", + "doc": "Koliko prvih kandidata Faze 2 DTW ponovo ocjenjuje. DTW je skuplji pa se primjenjuje samo na N najboljih kandidata. Zadano 5. Povećajte (na 7-9) ako ispravni profil ponekad dostigne samo 4. ili 5. mjesto nakon Faze 2 - DTW ga može spasiti. Smanjenje malo ubrzava podudaranje." + }, + "duration_scale": { + "label": "Ostrina trajanja", + "doc": "Oštrina kazne za nepodudaranje trajanja u Fazi 4. Ovo je omjer logaritma pri kojemu se ocjena slaganja prepolovi. Manje = strože: nepodudaranje trajanja više snižava ocjenu. Zadana vrijednost 0,175 odgovara toleranciji trajanja od oko 18% pri polu-težini. Koristite zajedno s Težinom trajanja." + }, + "energy_scale": { + "label": "Ostrina energije", + "doc": "Oštrina kazne za nepodudaranje energije u Fazi 4. Manje = strože: nepodudaranje energije više snižava ocjenu. Zadana vrijednost 0,25 je namjerno blaža od Oštrine trajanja jer energija varira s opterećenjem. Koristite zajedno s Težinom energije." } }, "setting_group": { @@ -1424,24 +1471,24 @@ "lower": "Više ciklusa se automatski označava; neki mogu biti pogrešno identificirani" }, "duration_tolerance": { - "higher": "Prihvata širi raspon trajanja — fleksibilnije podudaranje", - "lower": "Zahtijeva bliže podudaranje trajanja — strože, ali može odbiti neobične cikluse" + "higher": "Prihvata širi raspon trajanja – fleksibilnije podudaranje", + "lower": "Zahtijeva bliže podudaranje trajanja – strože, ali može odbiti neobične cikluse" }, "end_energy_threshold": { "higher": "Zatvara samo cikluse sa značajnom potrošnjom energije", "lower": "Zatvara i kraće ili manje energetske cikluse" }, "end_repeat_count": { - "higher": "Zahtijeva više ponavljanja signala kraja — pouzdanije, malo sporije", - "lower": "Završava nakon manje ponavljanja — brže otkrivanje, veći rizik lažnog završetka" + "higher": "Zahtijeva više ponavljanja signala kraja – pouzdanije, malo sporije", + "lower": "Završava nakon manje ponavljanja – brže otkrivanje, veći rizik lažnog završetka" }, "learning_confidence": { - "higher": "Uči samo iz visoko pouzdanih podudaranja — sporije, ali pouzdanije", + "higher": "Uči samo iz visoko pouzdanih podudaranja – sporije, ali pouzdanije", "lower": "Uči iz više ciklusa; model može biti manje precizan" }, "min_off_gap": { "higher": "Potreban je duži odmor prije početka novog ciklusa", - "lower": "Kratak odmor pokreće novi ciklus — uzastopni ciklusi se mogu razdijeliti" + "lower": "Kratak odmor pokreće novi ciklus – uzastopni ciklusi se mogu razdijeliti" }, "min_power": { "higher": "Manje osjetljiv na potrošnju u mirovanju; može propustiti tihe programe", @@ -1452,24 +1499,24 @@ "lower": "Zaglavljeni ciklus se prisilno zaustavlja ranije" }, "off_delay": { - "higher": "Više vremena za pauze uređaja — prikladno za duge faze namakanja ili sušenja", - "lower": "Brže otkrivanje kraja — dobro za uređaje s čistim uključivanjem/isključivanjem" + "higher": "Više vremena za pauze uređaja – prikladno za duge faze namakanja ili sušenja", + "lower": "Brže otkrivanje kraja – dobro za uređaje s čistim uključivanjem/isključivanjem" }, "profile_match_threshold": { "higher": "Potvrđuje samo visoko pouzdana podudaranja; više ciklusa ostaje neoznačeno", "lower": "Podudara više ciklusa; neki mogu biti blago pogrešni" }, "running_dead_zone": { - "higher": "Zanemaruje kratke skokove snage tokom mirovanja — manje osjetljiv na šum", - "lower": "Reagira na kraće impulse snage — otkriva brzu prolaznu aktivnost" + "higher": "Zanemaruje kratke skokove snage tokom mirovanja – manje osjetljiv na šum", + "lower": "Reagira na kraće impulse snage – otkriva brzu prolaznu aktivnost" }, "start_threshold_w": { "higher": "Sprečava lažne startove od kratkih skokova snage", "lower": "Otkriva programe s niskom snagom; može reagirati na šum" }, "stop_threshold_w": { - "higher": "Završava ciklus brže — može zatvoriti tokom pauze", - "lower": "Dulje čeka na završetak — manje prijevremenih završetaka" + "higher": "Završava ciklus brže – može zatvoriti tokom pauze", + "lower": "Dulje čeka na završetak – manje prijevremenih završetaka" }, "watchdog_interval": { "higher": "Dopušta dulje tihe faze; manje lažnih prisilnih zaustavljanja", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Sekundi iznad praga za potvrdu pokretanja", "start_threshold_w": "Minimum vati da bi se računalo kao pokrenuto", "stop_threshold_w": "Ispod ovoga mašina se računa kao isključena", - "dtw_bandwidth": "Koliko vremenskog izobličenja dopušta podudaranje oblika", - "corr_weight": "Težina oblika krive u odnosu na nivo snage pri podudaranju", - "duration_weight": "Koliko slaganje trajanja utječe na podudaranje", - "energy_weight": "Koliko slaganje potrošnje energije utječe na podudaranje", - "profile_match_min_duration_ratio": "Najkraće izvođenje (u odnosu na profil) koje se još može podudarati", - "profile_match_max_duration_ratio": "Najduže izvođenje (u odnosu na profil) koje se još može podudarati" + "dtw_bandwidth": "Faza 3: warp opseg Sakoe-Chiba (0 = DTW isključen; zadano 0,2 = 20% dužine ciklusa)", + "corr_weight": "Faza 2: ravnoteža između oblika krivulje (korelacija) i nivoa snage (MAE); zadano 0,45", + "duration_weight": "Faza 4: koliko jako slaganje trajanja utječe na konačnu ocjenu (zadano 0,22)", + "energy_weight": "Faza 4: koliko jako slaganje energije utječe na konačnu ocjenu (zadano 0,22)", + "profile_match_min_duration_ratio": "Faza 1: minimalna dužina ciklusa kao udio trajanja profila; zadano 0,1 (10%)", + "profile_match_max_duration_ratio": "Faza 1: maksimalna dužina ciklusa kao udio trajanja profila; zadano 1,5 (150%)", + "keep_min_score": "Faza 2: minimalna ocjena za ostanak u utrci; zadano 0,1 dopušta slabe podudarnosti, kasniji koraci biraju najbolje", + "dtw_blend": "Faza 3: 0 = samo osnovna ocjena, 1 = samo DTW ocjena, 0,5 = jednako miješanje (zadano)", + "dtw_ensemble_w": "Faza 3 (kombinirani način): težina skaliranog L1 nasuprot derivatnog DTW; zadano 0,7 favorizira nivo", + "dtw_ddtw_scale": "Faza 3: udaljenost polu-zasićenja DDTW; manja = osjetljivija na oblik (zadano 30)", + "dtw_refine_top_n": "Faza 3: kandidati koje DTW ponovo ocjenjuje; povećajte na 7-9 ako ispravni profil zauzme 4.-5. mjesto (zadano 5)", + "duration_scale": "Faza 4: omjer logaritma gdje se slaganje trajanja prepolovi; manje = stroža kazna (zadano 0,175)", + "energy_scale": "Faza 4: omjer logaritma gdje se slaganje energije prepolovi; manje = stroža kazna (zadano 0,25)" }, "col": { "profile_tip": "Naziv podudarnog programa. Bez oznake znači da se na kraju ciklusa nijedan profil nije podudarao.", @@ -1766,6 +1820,68 @@ }, "pg_sweep": { "optimize": "Optimizacija: {param}" + }, + "pg_detail": { + "simulate": "Simuliraj ciklus" + }, + "split": { + "apply": "Dijeljenje ciklusa" + }, + "trim": { + "apply": "Obrezivanje ciklusa" + }, + "merge": { + "apply": "Spajanje ciklusa" + }, + "rebuild": { + "envelopes": "Ponovna izgradnja omotača" + } + }, + "setup": { + "hdr": { + "card": "Postavljanje uređaja", + "healthy_chip": "Postavljanje dovršeno" + }, + "phase0": { + "washer": "WashData već otkriva vaše cikluse. Snimite prvi ciklus kako biste omogućili nazive programa i procjene vremena.", + "dishwasher": "WashData prati. Mašine za pranje posuđa imaju složene cikluse – snimanje prvog ciklusa je izrazito preporučeno. Ako otkriveni ciklus traje predugo, koristite uređivač ciklusa za rezanje prije pohrane kao profila.", + "generic": "WashData prati. Snimite ili označite otkriveni ciklus kako biste počeli graditi profile." + }, + "phase1a": { + "labelled": "Dobar početak – vaš prvi program je pohranjen. Za najčišće podatke razmislite o snimanju sljedećeg ciklusa pomoću widgeta snimača." + }, + "phase1b": { + "recorded": "Vaša snimka pohranjena je kao {profile_name}. Sada snimite ili označite ostale uobičajene programe za izgradnju pokrivenosti." + }, + "phase1c": { + "verify": "Imate {count} programa od zajednice. Pokrenite ciklus kako biste provjerili prepoznaje li ih WashData ispravno – podudaranje će se poboljšati kako vaš uređaj gradi vlastitu historiju." + }, + "phase2": { + "cluster": "WashData je vidio {count} ciklusa koji ne odgovaraju nijednom pohranjenome programu – nalikuju jedni drugima. Želite li za njih stvoriti novi profil?", + "unmatched": "Vaš zadnji ciklus nije odgovarao nijednom pohranjenome programu. Je li to novi program?" + }, + "phase3": { + "suggestions": "WashData ima preporuke postavki temeljene na vašoj historiji ciklusa – pregledajte ih kako biste poboljšali tačnost otkrivanja.", + "groups": "Neki vaši profili izgledaju kao isti program pri različitim temperaturama. Organizujte ih u grupu za bolje podudaranje.", + "phases": "Dodajte faze programa u {profile_name} za tačnije procjene preostalog vremena." + }, + "phase4": { + "healthy": "Ovaj uređaj je u potpunosti postavljen ({profile_count} profila)." + }, + "cta": { + "start_recording": "Pokreni snimanje", + "label_detected_cycle": "Već imam otkriven ciklus – umjesto toga ga označi", + "browse_cycles": "Pregledaj cikluse za označavanje sljedećeg", + "view_profiles": "Prikaži svoje profile", + "create_from_cluster": "Napravi profil od ovih ciklusa", + "create_profile": "Napravi za njega profil", + "review_suggestions": "Preglej prijedloge", + "organise_profiles": "Organizuj profile", + "configure_phases": "Konfiguriraj faze", + "skip_step": "Preskoči ovaj korak", + "skip_forever": "Ne prikazuj više", + "hide_guidance": "Sakrij upute", + "show_guidance": "Prikaži upute" } } } diff --git a/custom_components/ha_washdata/translations/panel/cs.json b/custom_components/ha_washdata/translations/panel/cs.json index 6d81469..215fd3b 100644 --- a/custom_components/ha_washdata/translations/panel/cs.json +++ b/custom_components/ha_washdata/translations/panel/cs.json @@ -87,7 +87,7 @@ "on_cycle_finished": "Po dokončení cyklu", "on_cycle_started": "Na začátku cyklu", "pause_cycle": "Pauza", - "pause_cycle_tip": "Pozastavit běžící cyklus — spotřebič pokračuje tam, kde skončil", + "pause_cycle_tip": "Pozastavit běžící cyklus – spotřebič pokračuje tam, kde skončil", "pg_apply_device": "Použít na zařízení", "pg_autofill": "Automaticky vyplnit", "play": "Přehrát", @@ -97,8 +97,8 @@ "process_tip": "Uložit zaznamenaný průběh jako nový nebo existující profil", "rebuild": "Obnovit obálky", "rebuild_envelope": "Znovu sestavit obálku", - "rebuild_tip": "Přepočítat očekávanou výkonovou obálku (pásmo min/max) pro všechny profily z jejich označených cyklů — spustit po označení nových cyklů nebo opravě starých", - "rec_start_tip": "Zahájit záznam průběhu výkonu spotřebiče — spustit těsně před spuštěním cyklu", + "rebuild_tip": "Přepočítat očekávanou výkonovou obálku (pásmo min/max) pro všechny profily z jejich označených cyklů – spustit po označení nových cyklů nebo opravě starých", + "rec_start_tip": "Zahájit záznam průběhu výkonu spotřebiče – spustit těsně před spuštěním cyklu", "rec_stop": "Zastavit", "rec_stop_tip": "Zastavit nahrávání a podržet zachycený průběh ke kontrole", "record": "Spustit nahrávání", @@ -231,7 +231,7 @@ "interval": "Měl by být alespoň 2× Interval vzorkování ({si} s)", "sampling": "Interval vzorkování by měl být nejvýše polovina Intervalu hlídače ({wi} s)" }, - "settings_banner": "{n} konflikt{s} nastavení — zkontrolujte zvýrazněné sekce a opravte před uložením.", + "settings_banner": "{n} konflikt{s} nastavení – zkontrolujte zvýrazněné sekce a opravte před uložením.", "settings_banner_btn": "Přejít na první" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Zobrazit očekávané překrytí křivky (shodný profil, oranžová)", "show_raw": "Zobrazit přepínač surových dat zásuvky v živém grafu výkonu", "sparkline": "Trend délky posledních cyklů", - "stage2": "Fáze 2 — jádrová podobnost", - "stage3": "Fáze 3 — DTW", - "stage4": "Fáze 4 — shoda", + "stage2": "Fáze 2 – jádrová podobnost", + "stage3": "Fáze 3 – DTW", + "stage4": "Fáze 4 – shoda", "status": "Stav", "tail_trim": "Trimování ocasu (y)", "timer_auto_pause": "Automatická pauza", @@ -561,7 +561,12 @@ "flags": "Příznaky", "include_phase_map": "Zahrnout mapu fází", "include_settings": "Zahrnout nastavení", - "show_contributor": "Zobrazit přispěvatele" + "show_contributor": "Zobrazit přispěvatele", + "task_pg_detail": "Simulovat cyklus", + "task_split": "Rozdělování cyklu", + "task_trim": "Ořezávání cyklu", + "task_merge": "Slučování cyklů", + "task_rebuild": "Přepočítávání obálek" }, "log": { "all_levels": "Všechny úrovně", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Klesl pod obvyklé pásmo výkonu po dobu ~{n} s.", "artifact_footer": "Zvýrazněno na grafu výše. Jedná se o přechodné artefakty (např. dveře otevřené uprostřed cyklu), ne nutně problémy.", "artifact_header": "Během tohoto cyklu bylo zjištěno {n} anomálií", - "artifact_pause_detail": "Výkon klesl téměř na nulu po dobu ~{n} s a poté se obnovil — pravděpodobně byla otevřena dvířka nebo byl cyklus pozastaven.", + "artifact_pause_detail": "Výkon klesl téměř na nulu po dobu ~{n} s a poté se obnovil – pravděpodobně byla otevřena dvířka nebo byl cyklus pozastaven.", "artifact_spike_detail": "Překročil obvyklé pásmo výkonu po dobu ~{n} s.", "auto_label_intro": "Přiřaďte profily neoznačeným cyklům, jejichž spolehlivost shody překročí prahovou hodnotu.", "automations_intro": "WashData vyvolává události {start} / {end} a zpřístupňuje entity. Oznámení a akce se nejlépe nastavují jako standardní automatizace Home Assistant. Automatizace používající toto zařízení se zobrazují níže.", @@ -654,10 +659,10 @@ "collecting_data": "Shromažďování dat: ještě {need} cyklů do zahájení jemného doladění ({current}/{min}).", "compare_overlay_profiles": "Překryvné profily (slabé)", "compare_profiles_tip": "Překryjte další obálky profilu na výše uvedené tabulce a zjistěte, která z nich nejlépe vyhovuje tomuto cyklu.", - "compare_selected_cycles": "Vybrané cykly (plné) — zobrazit / skrýt", + "compare_selected_cycles": "Vybrané cykly (plné) – zobrazit / skrýt", "consider_new_profile": "Zvažte vytvoření nového profilu.", "coverage_gap": "nedávné cykly nemají žádný odpovídající profil ({pct} % z posledních 30).", - "coverage_gap_similar_cycles": "Nalezeno {count} podobných neoznačených cyklů — vytvořte profil, abyste je začali párovat.", + "coverage_gap_similar_cycles": "Nalezeno {count} podobných neoznačených cyklů – vytvořte profil, abyste je začali párovat.", "cycles_deleted": "Smazáno cyklů: {count}", "enough_data": "Dostatek dat k učení ({current}/{min} cyklů).", "export_description": "Exportujte všechny profily a cykly do JSON nebo obnovte z předchozího exportu.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modely vyladěné na tento stroj.", "ml_loading": "načítání ML…", "ml_settings_intro": "Dva nezávislé přepínače: jeden použije modely během cyklu, druhý umožňuje programu WashData je v průběhu času jemně doladit na váš stroj.", - "name_first_program": "Máte dostatek cyklů — pojmenujte svůj první program a zahajte párování.", + "name_first_program": "Máte dostatek cyklů – pojmenujte svůj první program a zahajte párování.", "near_duplicate_cluster": "zjištěn téměř duplicitní shluk profilů. Seskupování umožňuje spárování spolehlivě vybrat mezi podobnými (např. stejný program při různé teplotě/odstřeďování).", "no_cycles_match": "Žádné cykly neodpovídají aktuálnímu filtru.", "no_cycles_profile": "Žádné cykly pro tento profil.", @@ -696,7 +701,7 @@ "no_devices": "Zatím nejsou nakonfigurována žádná zařízení WashData.", "no_envelope": "Zatím bez obálky - po cyklech štítkování znovu sestavit.", "no_envelope_overlay": "Není k dispozici žádná obálka k překrytí.", - "no_fine_tuned": "Zatím není nic doladěno — WashData využívá své vestavěné modely.", + "no_fine_tuned": "Zatím není nic doladěno – WashData využívá své vestavěné modely.", "no_logs": "Dosud nebyly uloženy žádné záznamy protokolu.", "no_maintenance": "Zatím není zaznamenána žádná údržba.", "no_match_yet": "Zatím žádný pokus o shodu – toto se naplní během běžícího cyklu.", @@ -713,7 +718,7 @@ "notify_services_hint": "Zadejte ID služeb {entity} (více oddělete čárkou). Proměnné šablony: {vars}.", "old_actions_warning": "Nakonfigurováno pomocí starého editoru akcí (nyní odstraněno). Stále se spouštějí při událostech cyklu, ale zde je již nelze upravovat. Převeďte je na normální automatizaci nebo je odstraňte.", "onboarding_progress": "{n} / 3 sledovaných cyklů", - "onboarding_watching": "Nechte spotřebič běžet normálně — WashData sleduje. Po 3 cyklech začne párování programů.", + "onboarding_watching": "Nechte spotřebič běžet normálně – WashData sleduje. Po 3 cyklech začne párování programů.", "pending_feedback": "Čeká na zpětnou vazbu detekce", "performance_trend": "Trend výkonu ({n} cyklů)", "pg_analysis_empty": "Načtěte cyklus pro zobrazení analýzy shody.", @@ -763,7 +768,7 @@ "setting_changed": "Změněno z {old} na {new} dne {date}", "settings_basic_note": "Zobrazují se základní nastavení. Přepněte na Pokročilé pro úplný seznam.", "shape_drift_advisory": "⚠ Drift tvaru", - "shape_drift_detail": "Vzor příkonu pro tento profil se časem posunul — možné opotřebení spotřebiče nebo potřebná údržba (např. odvápnění, čištění filtru).", + "shape_drift_detail": "Vzor příkonu pro tento profil se časem posunul – možné opotřebení spotřebiče nebo potřebná údržba (např. odvápnění, čištění filtru).", "show_all_settings": "Zobrazit všechna nastavení", "showing_suggestions": "Zobrazuje se nastavení {count} s návrhy.", "split_intro": "Kliknutím na graf přidáte nebo odstraníte dělicí bod nebo automaticky rozpoznáte mezery v nečinnosti. Každý výsledný segment může získat svůj vlastní profil.", @@ -857,7 +862,9 @@ "share_guidelines_title": "Před sdílením", "store_download_device_intro": "Stáhnout nastavení zařízení komunity a použít ho na nové nebo stávající zařízení", "store_share_device_intro": "Sdílet programy vašeho zařízení (profily + referenční cykly) s komunitou. Nastavení jsou volitelná.", - "share_profile_no_cycles": "Profil '{p}' nemá žádné ⭐ referenční cykly -- bude přeskočen, pokud nemáte {n}+ potvrzených spuštění" + "share_profile_no_cycles": "Profil '{p}' nemá žádné ⭐ referenční cykly -- bude přeskočen, pokud nemáte {n}+ potvrzených spuštění", + "advisory_phase_inconsistent": "Zdá se, že '{name}' míchá různé programy nebo teploty - jeho cykly se ohřívají po velmi různě dlouhou dobu. Rozdělení na samostatné profily (např. podle teploty) zlepší shodu i odhady času.", + "advisory_phase_inconsistent_title": "⚠ Možná smíšené programy" }, "phase_desc": { "anti_crease": "Občasné krátké otáčení bubnu po dokončení cyklu pro prevenci mačkání.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Volitelné externí signály: spouštěč konce, senzor dveří, přepínač pauzy a připomínka vyložení.", "label": "Spouštěče a dveře" + }, + "phase_eta": { + "label": "Zbývající čas", + "intro": "Zbývající čas zohledňující fáze, pro spotřebiče, jejichž délka cyklu závisí na teplotě nebo odstřeďování." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Pevná cena za kWh používaná pro ukazatele nákladů, pokud není nastavena živá entita ceny výše.", "label": "Statická cena energie (za kWh)" }, + "energy_sensor": { + "doc": "Volitelné kumulativní počítadlo energie (total_increasing kWh/Wh, např. vlastní celoživotní měřič zásuvky). Když je nastaveno, energie hlášená za každý cyklus se odvozuje z rozdílu tohoto počítadla mezi začátkem a koncem, což zabraňuje podhodnocení, které vzniká integrací pomalu hlásícího senzoru výkonu. Vrátí se k integrované hodnotě, pokud údaj chybí, jeho jednotka je neznámá nebo rozdíl není kladný. Ponechte prázdné, chcete-li dále integrovat senzor výkonu.", + "label": "Entita měřiče energie" + }, "expose_debug_entities": { "doc": "Publikujte extra diagnostické entity HA (spolehlivost shody, nejednoznačnost, interní stav). Vypnuto udržuje seznam entit čistý pro normální použití.", "label": "Zobrazit ladící entity" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Zobrazit jméno přispěvatele u profilů stažených z komunitního obchodu" + }, + "enable_phase_matching": { + "label": "Zbývající čas zohledňující fáze", + "doc": "Rozdělí každý běžící cyklus do fází (ohřev, praní, odstřeďování) a rozpočítá zbývající čas na jednotlivé fáze, smíchaný s klasickým odhadem - na začátku cyklu se opírá o rozpočet fází a ke konci o klasický odhad. Přizpůsobí odpočet tomu, jak dlouho se váš spotřebič skutečně ohřívá a běží, což je nejvíce patrné v první polovině cyklu. Vypnuto = pouze klasický odhad. Ovlivněno je jen zobrazení zbývajícího času; shoda programů a detekce cyklu se nemění." + }, + "keep_min_score": { + "label": "Min. skore shody", + "doc": "Minimální skóre podobnosti, které musí kandidát mít, aby zůstal v závodu o shodu. Výchozí hodnota 0,1 je záměrně benevolentní - připouští i slabé kandidáty a spoléhá na pozdější fáze při výběru nejlepší shody. Zvyšte ji pro dřívější vyřazení nepravděpodobných profilů; snižte ji pro rozšíření počáteční skupiny kandidátů." + }, + "dtw_blend": { + "label": "Mísení deformace", + "doc": "Jak moc skóre zarovnání DTW nahrazuje základní skóre Fáze 2. 0 = použij pouze skóre Fáze 2, 1 = použij pouze skóre DTW, 0,5 (výchozí) = rovnoměrné míchání. Zvyšte, chcete-li se více spoléhat na zarovnání, když mají programy podobné úrovně výkonu, ale různé časové vzory." + }, + "dtw_ensemble_w": { + "label": "Mix kombinace DTW", + "doc": "V režimu kombinace DTW (výchozím) je to váha škálovaného L1 oproti derivátnímu DTW (DDTW). 1,0 = vše škálované L1, 0 = vše DDTW, 0,7 = výchozí. Škálovaná varianta L1 porovnává úrovně výkonu; derivátní varianta reaguje na změny výkonu v čase. Kombinace spojuje obě." + }, + "dtw_ddtw_scale": { + "label": "Škála derivátní deformace", + "doc": "Vzdálenost polosaturace pro hodnocení derivátním DTW. Skóre se sníží na polovinu, když vzdálenost DDTW rovná se této hodnotě. Menší = citlivější na rozdíly ve tvaru. Výchozí hodnota 30 je zkalibrována pro typické průběhy výkonu spotřebičů. Ovlivňuje pouze kombinační a ddtw režimy DTW." + }, + "dtw_refine_top_n": { + "label": "Pocet zpresnenych kandidatu", + "doc": "Kolik nejlepších kandidátů z Fáze 2 je přehodnoceno DTW. DTW je nákladnější, takže se používá pouze pro N nejlepších kandidátů. Výchozí 5. Zvyšte (na 7-9), pokud správný profil po Fázi 2 někdy dosáhne pouze 4. nebo 5. místa - DTW ho může zachránit. Snížení mírně urychlí porovnávání." + }, + "duration_scale": { + "label": "Ostrost délky trvání", + "doc": "Ostrost penalizace za neshodu délky trvání ve Fázi 4. Jedná se o logaritmický poměr, při kterém se skóre shody sníží na polovinu. Menší = přísnější: neshoda délky trvání více snižuje skóre. Výchozí hodnota 0,175 odpovídá přibližně 18% toleranci délky trvání při poloviční váze. Kombinujte s Váhou délky trvání." + }, + "energy_scale": { + "label": "Ostrost energie", + "doc": "Ostrost penalizace za neshodu energie ve Fázi 4. Menší = přísnější: neshoda energie více snižuje skóre. Výchozí hodnota 0,25 je záměrně benevolentnější než Ostrost délky trvání, protože energie se liší podle zátěže. Kombinujte s Váhou energie." } }, "setting_group": { @@ -1424,24 +1471,24 @@ "lower": "Více cyklů je označeno automaticky; některé mohou být nesprávně určeny" }, "duration_tolerance": { - "higher": "Přijímá širší rozsah délky — flexibilnější shoda", - "lower": "Vyžaduje bližší shodu délky — přísnější, ale může odmítnout neobvyklé cykly" + "higher": "Přijímá širší rozsah délky – flexibilnější shoda", + "lower": "Vyžaduje bližší shodu délky – přísnější, ale může odmítnout neobvyklé cykly" }, "end_energy_threshold": { "higher": "Uzavírá pouze cykly se značnou spotřebou energie", "lower": "Uzavírá i kratší nebo méně energetické cykly" }, "end_repeat_count": { - "higher": "Vyžaduje více opakování signálu konce — spolehlivější, mírně pomalejší", - "lower": "Ukončí po méně opakováních — rychlejší detekce, větší riziko předčasného ukončení" + "higher": "Vyžaduje více opakování signálu konce – spolehlivější, mírně pomalejší", + "lower": "Ukončí po méně opakováních – rychlejší detekce, větší riziko předčasného ukončení" }, "learning_confidence": { - "higher": "Učí se pouze z velmi jistých shod — pomalejší, ale spolehlivější", + "higher": "Učí se pouze z velmi jistých shod – pomalejší, ale spolehlivější", "lower": "Učí se z více cyklů; model může být méně přesný" }, "min_off_gap": { "higher": "Potřebuje delší klid před začátkem nového cyklu", - "lower": "Krátký klid spustí nový cyklus — cykly za sebou se mohou rozdělit" + "lower": "Krátký klid spustí nový cyklus – cykly za sebou se mohou rozdělit" }, "min_power": { "higher": "Méně citlivý na klidový příkon; může přehlédnout velmi tichý program", @@ -1452,24 +1499,24 @@ "lower": "Zaseklý cyklus je ukončen dříve" }, "off_delay": { - "higher": "Více času pro pauzy spotřebiče — vhodné pro dlouhé máchání nebo sušení", - "lower": "Rychlejší detekce konce — vhodné pro spotřebiče s čistým zapínáním/vypínáním" + "higher": "Více času pro pauzy spotřebiče – vhodné pro dlouhé máchání nebo sušení", + "lower": "Rychlejší detekce konce – vhodné pro spotřebiče s čistým zapínáním/vypínáním" }, "profile_match_threshold": { "higher": "Potvrzuje pouze vysoce jisté shody; více cyklů zůstane neoznačených", "lower": "Páruje více cyklů; některé mohou být mírně nesprávné" }, "running_dead_zone": { - "higher": "Ignoruje krátké výkonové špičky během klidu — méně citlivý na šum", - "lower": "Reaguje na kratší výkonové impulzy — zachytí rychlou přechodnou aktivitu" + "higher": "Ignoruje krátké výkonové špičky během klidu – méně citlivý na šum", + "lower": "Reaguje na kratší výkonové impulzy – zachytí rychlou přechodnou aktivitu" }, "start_threshold_w": { "higher": "Zabraňuje falešným startům z krátkých výkonových špiček", "lower": "Zachytí programy s nízkým příkonem; může reagovat na rušení" }, "stop_threshold_w": { - "higher": "Ukončí cyklus rychleji — může uzavřít v průběhu pauzy", - "lower": "Čeká déle na ukončení — méně předčasných dokončení" + "higher": "Ukončí cyklus rychleji – může uzavřít v průběhu pauzy", + "lower": "Čeká déle na ukončení – méně předčasných dokončení" }, "watchdog_interval": { "higher": "Povoluje delší tichá úseky; méně falešných vynucených ukončení", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Sekundy nad prahem pro potvrzení spuštění", "start_threshold_w": "Minimální výkon ve wattech pro započtení spuštění", "stop_threshold_w": "Pod touto hodnotou se stroj počítá jako vypnutý", - "dtw_bandwidth": "Jak velké časové deformace shoda tvaru umožňuje", - "corr_weight": "Váha tvaru křivky vs. úrovně výkonu při přiřazování", - "duration_weight": "Jak moc shoda délky trvání ovlivňuje přiřazení", - "energy_weight": "Jak moc shoda spotřeby energie ovlivňuje přiřazení", - "profile_match_min_duration_ratio": "Nejkratší běh (oproti profilu), který ještě může odpovídat", - "profile_match_max_duration_ratio": "Nejdelší běh (oproti profilu), který ještě může odpovídat" + "dtw_bandwidth": "Fáze 3: pásmo deformace Sakoe-Chiba (0 = DTW vypnuto; výchozí 0,2 = 20 % délky cyklu)", + "corr_weight": "Fáze 2: rovnováha mezi tvarem křivky (korelace) a úrovní výkonu (MAE); výchozí 0,45", + "duration_weight": "Fáze 4: jak silně shoda délky trvání ovlivňuje výsledné skóre (výchozí 0,22)", + "energy_weight": "Fáze 4: jak silně shoda energie ovlivňuje výsledné skóre (výchozí 0,22)", + "profile_match_min_duration_ratio": "Fáze 1: minimální délka cyklu jako zlomek doby trvání profilu; výchozí 0,1 (10 %)", + "profile_match_max_duration_ratio": "Fáze 1: maximální délka cyklu jako zlomek doby trvání profilu; výchozí 1,5 (150 %)", + "keep_min_score": "Fáze 2: minimální skóre pro setrvání v závodu; výchozí 0,1 připouští slabé shody, pozdější fáze vyberou nejlepší", + "dtw_blend": "Fáze 3: 0 = pouze základní skóre, 1 = pouze skóre DTW, 0,5 = rovnoměrné míchání (výchozí)", + "dtw_ensemble_w": "Fáze 3 (režim kombinace): váha škálovaného L1 oproti derivátnímu DTW; výchozí 0,7 upřednostňuje úroveň", + "dtw_ddtw_scale": "Fáze 3: vzdálenost polosaturace DDTW; menší = citlivější na tvar (výchozí 30)", + "dtw_refine_top_n": "Fáze 3: kandidáti, které DTW přehodnotí; zvyšte na 7-9, pokud správný profil skončí na 4.-5. místě (výchozí 5)", + "duration_scale": "Fáze 4: logaritmický poměr, při kterém se shoda délky trvání sníží na polovinu; menší = přísnější penalizace (výchozí 0,175)", + "energy_scale": "Fáze 4: logaritmický poměr, při kterém se shoda energie sníží na polovinu; menší = přísnější penalizace (výchozí 0,25)" }, "col": { "profile_tip": "Název odpovídajícího programu. Bez štítku znamená, že na konci cyklu nebyl nalezen žádný profil.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimalizace: {param}" + }, + "pg_detail": { + "simulate": "Simulovat cyklus" + }, + "split": { + "apply": "Rozdělování cyklu" + }, + "trim": { + "apply": "Ořezávání cyklu" + }, + "merge": { + "apply": "Slučování cyklů" + }, + "rebuild": { + "envelopes": "Přepočítávání obálek" + }, + "cancelling": "Ruším..." + }, + "setup": { + "hdr": { + "card": "Nastavení zařízení", + "healthy_chip": "Nastavení dokončeno" + }, + "phase0": { + "washer": "WashData již detekuje vaše cykly. Zaznamenejte první cyklus, abyste povolili názvy programů a odhady doby trvání.", + "dishwasher": "WashData sleduje. Myčky nádobí mají složité cykly – nahrání prvního cyklu je silně doporučeno. Pokud detekovaný cyklus běží příliš dlouho, ořízněte ho v editoru cyklů před uložením jako profil.", + "generic": "WashData sleduje. Zaznamenejte nebo označte detekovaný cyklus a začněte vytvářet profily." + }, + "phase1a": { + "labelled": "Dobrý začátek – váš první program je uložen. Pro nejčistší data zvažte nahrání dalšího cyklu pomocí widgetu rekordéru." + }, + "phase1b": { + "recorded": "Váš záznam byl uložen jako {profile_name}. Nyní nahrajte nebo označte další běžné programy, abyste rozšířili pokrytí." + }, + "phase1c": { + "verify": "Máte {count} programů od komunity. Spusťte cyklus, abyste ověřili, že je WashData správně rozpoznává – párování se bude zlepšovat, jak zařízení buduje vlastní historii." + }, + "phase2": { + "cluster": "WashData zaznamenal {count} cyklů, které neodpovídají žádnému uloženému programu – vypadají si navzájem podobně. Chcete pro ně vytvořit nový profil?", + "unmatched": "Váš poslední cyklus neodpovídal žádnému uloženému programu. Je to nový program?" + }, + "phase3": { + "suggestions": "WashData má doporučení k nastavení na základě vaší historie cyklů – zkontrolujte je pro zlepšení přesnosti detekce.", + "groups": "Některé vaše profily vypadají jako stejný program při různých teplotách. Uspořádejte je do skupiny pro lepší párování.", + "phases": "Přidejte fáze programu k {profile_name} pro přesnější odhady zbývajícího času." + }, + "phase4": { + "healthy": "Toto zařízení je plně nastaveno ({profile_count} profilů)." + }, + "cta": { + "start_recording": "Spustit nahrávání", + "label_detected_cycle": "Již mám detekovaný cyklus – místo toho ho označte", + "browse_cycles": "Procházet cykly a označit další", + "view_profiles": "Zobrazit vaše profily", + "create_from_cluster": "Vytvořit profil z těchto cyklů", + "create_profile": "Vytvořit pro něj profil", + "review_suggestions": "Zkontrolovat návrhy", + "organise_profiles": "Uspořádat profily", + "configure_phases": "Konfigurovat fáze", + "skip_step": "Přeskočit tento krok", + "skip_forever": "Nezobrazovat znovu", + "hide_guidance": "Skrýt pokyny", + "show_guidance": "Zobrazit pokyny" } } } diff --git a/custom_components/ha_washdata/translations/panel/da.json b/custom_components/ha_washdata/translations/panel/da.json index 180ef98..f37eb3e 100644 --- a/custom_components/ha_washdata/translations/panel/da.json +++ b/custom_components/ha_washdata/translations/panel/da.json @@ -87,7 +87,7 @@ "on_cycle_finished": "På cyklus færdig", "on_cycle_started": "På cyklus startet", "pause_cycle": "Pause", - "pause_cycle_tip": "Sæt den kørende cyklus på pause — apparatet genoptager, hvor det slap", + "pause_cycle_tip": "Sæt den kørende cyklus på pause – apparatet genoptager, hvor det slap", "pg_apply_device": "Anvend på enhed", "pg_autofill": "Autoudfyld", "play": "Afspil", @@ -97,8 +97,8 @@ "process_tip": "Gem det optagede spor som en ny eller eksisterende profil", "rebuild": "Genopbyg konvolutter", "rebuild_envelope": "Genopbyg konvolut", - "rebuild_tip": "Genberegn den forventede effektkonvolut (min/max-bånd) for alle profiler fra deres mærkede cyklusser — kør efter mærkning af nye cyklusser eller korrigering af gamle", - "rec_start_tip": "Begynd at optage apparatets effektspor — start umiddelbart inden du kører en cyklus", + "rebuild_tip": "Genberegn den forventede effektkonvolut (min/max-bånd) for alle profiler fra deres mærkede cyklusser – kør efter mærkning af nye cyklusser eller korrigering af gamle", + "rec_start_tip": "Begynd at optage apparatets effektspor – start umiddelbart inden du kører en cyklus", "rec_stop": "Stop", "rec_stop_tip": "Stop optagelsen og behold det indfangede spor til gennemgang", "record": "Start optagelse", @@ -231,7 +231,7 @@ "interval": "Bør være mindst 2x samplingsintervallet ({si} s)", "sampling": "Samplingsinterval bør højst være halvdelen af watchdog-intervallet ({wi} s)" }, - "settings_banner": "{n} indstillingskonflikt{s} — tjek de fremhævede sektioner og ret dem inden du gemmer.", + "settings_banner": "{n} indstillingskonflikt{s} – tjek de fremhævede sektioner og ret dem inden du gemmer.", "settings_banner_btn": "Gå til første" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Vis forventet kurveoverlejring (matchet profil, orange)", "show_raw": "Vis kontakt for råe stikkontaktværdier i live strømgraf", "sparkline": "Tendens for seneste cyklusvarigheder", - "stage2": "Trin 2 — kerneligning", - "stage3": "Trin 3 — DTW", - "stage4": "Trin 4 — overensstemmelse", + "stage2": "Trin 2 – kerneligning", + "stage3": "Trin 3 – DTW", + "stage4": "Trin 4 – overensstemmelse", "status": "Status", "tail_trim": "Beskær slut (s)", "timer_auto_pause": "Auto-pause", @@ -561,7 +561,12 @@ "flags": "Flags", "include_phase_map": "Inkludér fasekort", "include_settings": "Inkludér detektions- og matchindstillinger", - "show_contributor": "Vis bidragydernes navne" + "show_contributor": "Vis bidragydernes navne", + "task_pg_detail": "Simulér cyklus", + "task_split": "Opdeler cyklus", + "task_trim": "Beskærer cyklus", + "task_merge": "Sammenfletter cyklusser", + "task_rebuild": "Genopbygger envelopes" }, "log": { "all_levels": "Alle niveauer", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Faldt under det normale effektbånd i ~{n}s.", "artifact_footer": "Fremhævet på grafen ovenfor. Disse er forbigående artefakter (f.eks. døren åbnet midt i cyklussen), ikke nødvendigvis problemer.", "artifact_header": "{n} uregelmæssigheder opdaget i denne cyklus", - "artifact_pause_detail": "Effekten faldt til næsten nul i ~{n}s og genoptog derefter — sandsynligvis blev døren åbnet midt i cyklussen, eller cyklussen blev sat på pause.", + "artifact_pause_detail": "Effekten faldt til næsten nul i ~{n}s og genoptog derefter – sandsynligvis blev døren åbnet midt i cyklussen, eller cyklussen blev sat på pause.", "artifact_spike_detail": "Steg over det normale effektbånd i ~{n}s.", "auto_label_intro": "Tildel profiler til umærkede cyklusser, hvis matchkonfidens rydder tærsklen.", "automations_intro": "WashData udløser {start} / {end}-hændelser og stiller entiteter til rådighed, så notifikationer og handlinger bedst bygges som normale Home Assistant-automatiseringer. Automatiseringer, der bruger denne enhed, vises nedenfor.", @@ -654,10 +659,10 @@ "collecting_data": "Indsamler data: {need} cyklus{plural} mere, før finjustering kan starte ({current}/{min}).", "compare_overlay_profiles": "Overlejringsprofiler (svage)", "compare_profiles_tip": "Overlæg andre profilkonvolutter på skemaet ovenfor for at se, hvilken der passer bedst til denne cyklus.", - "compare_selected_cycles": "Valgte cyklusser (fast) — vis/skjul", + "compare_selected_cycles": "Valgte cyklusser (fast) – vis/skjul", "consider_new_profile": "Overvej at oprette en ny profil.", "coverage_gap": "seneste cyklusser har ingen matchende profil ({pct}% af sidste 30).", - "coverage_gap_similar_cycles": "{count} lignende umærkede cyklusser fundet — opret en profil for at begynde at matche dem.", + "coverage_gap_similar_cycles": "{count} lignende umærkede cyklusser fundet – opret en profil for at begynde at matche dem.", "cycles_deleted": "{count} cyklus(ser) slettet", "enough_data": "Nok data til at lære af ({current}/{min} cyklusser).", "export_description": "Eksporter alle profiler og cyklusser til JSON, eller gendan fra en tidligere eksport.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modeller finjusteret til denne maskine.", "ml_loading": "indlæser ML...", "ml_settings_intro": "To uafhængige kontakter: Den ene anvender modellerne, mens en cyklus kører, den anden lader WashData finjustere dem til din maskine over tid.", - "name_first_program": "Du har nok cyklusser — navngiv dit første program for at begynde matchning.", + "name_first_program": "Du har nok cyklusser – navngiv dit første program for at begynde matchning.", "near_duplicate_cluster": "næsten dublet profilklynge fundet. Gruppering gør det muligt for matchning pålideligt at vælge mellem look-alikes (f.eks. samme program ved forskellig temperatur/spin).", "no_cycles_match": "Ingen cyklusser matcher det aktuelle filter.", "no_cycles_profile": "Ingen cyklusser for denne profil.", @@ -713,7 +718,7 @@ "notify_services_hint": "Brug {entity}-service-ID'er (kommaseparerede for flere). Skabelonvariabler: {vars}.", "old_actions_warning": "Konfigureret med den gamle handlingseditor (nu fjernet). De skyder stadig på cyklusbegivenheder, men kan ikke længere redigeres her. Konverter dem til en normal automatisering, eller fjern dem.", "onboarding_progress": "{n} / 3 cyklusser observeret", - "onboarding_watching": "Lad dit apparat køre normalt — WashData holder øje. Efter 3 cyklusser begynder programmatchning.", + "onboarding_watching": "Lad dit apparat køre normalt – WashData holder øje. Efter 3 cyklusser begynder programmatchning.", "pending_feedback": "Afventer detektionsfeedback", "performance_trend": "Præstationstendens ({n} cyklusser)", "pg_analysis_empty": "Indlæs en cyklus for at se matchanalyse.", @@ -763,7 +768,7 @@ "setting_changed": "Ændret fra {old} til {new} den {date}", "settings_basic_note": "Viser væsentlige indstillinger. Skift til Avanceret for hele listen.", "shape_drift_advisory": "⚠ Formdrift", - "shape_drift_detail": "Effektmønsteret for denne profil har ændret sig over tid — mulig slid på apparatet eller vedligeholdelse nødvendig (f.eks. afkalkning, filterrensning).", + "shape_drift_detail": "Effektmønsteret for denne profil har ændret sig over tid – mulig slid på apparatet eller vedligeholdelse nødvendig (f.eks. afkalkning, filterrensning).", "show_all_settings": "Vis alle indstillinger", "showing_suggestions": "Viser indstillingen {count} med forslag.", "split_intro": "Klik på grafen for at tilføje eller fjerne et opdelingspunkt, eller automatisk detektere ved inaktive huller. Hvert resulterende segment kan få sin egen profil.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Tilbagerulning mislykkedes: {error}", "toast_reverted": "{key} tilbageført", "toast_save_failed": "Lagring mislykkedes: {error}", - "trend_duration_longer": "Varighed, der trender længere ({pct}%/cyklus) — seneste gennemsnit {avg}", - "trend_duration_shorter": "Varighed, der trender kortere ({pct}%/cyklus) — seneste gennemsnit {avg}", + "trend_duration_longer": "Varighed, der trender længere ({pct}%/cyklus) – seneste gennemsnit {avg}", + "trend_duration_shorter": "Varighed, der trender kortere ({pct}%/cyklus) – seneste gennemsnit {avg}", "trend_energy_down": "Energi på vej ned ({pct} %/cyklus)", - "trend_energy_up": "Energitrends op ({pct} %/cyklus) — seneste gennemsnit {avg}", + "trend_energy_up": "Energitrends op ({pct} %/cyklus) – seneste gennemsnit {avg}", "trim_intro": "Træk i de røde håndtag, eller indtast værdier. Alt uden for vinduet fjernes.", "tuning_suggestions_available": "{count} indstillingsforslag tilgængeligt fra observerede cyklusser. De vises ved siden af ​​de relevante felter.", "unsure_detected_prefix": "WashData er usikker på, om det er registreret", @@ -857,7 +862,9 @@ "share_guidelines_title": "Inden du deler", "store_download_device_intro": "Overtag alle delte programmer og deres referencecyklusser til dit apparat. Dine egne optagede cyklusser og statistik påvirkes ikke.", "store_share_device_intro": "Upload {brand} {model} med de referencecyklusser, du vælger. Andre med samme apparat kan overtage dine programmer. Bidrag gennemgås inden offentliggørelse.", - "share_profile_no_cycles": "Ingen referencecyklusser -- markér en cyklus som ⭐ under fanen Cyklusser for at inkludere denne profil" + "share_profile_no_cycles": "Ingen referencecyklusser -- markér en cyklus som ⭐ under fanen Cyklusser for at inkludere denne profil", + "advisory_phase_inconsistent": "'{name}' ser ud til at blande forskellige programmer eller temperaturer - dens cyklusser varmer op i meget forskellige tidsrum. Hvis du deler den op i separate profiler (f.eks. pr. temperatur), forbedres matchningen og tidsestimaterne.", + "advisory_phase_inconsistent_title": "⚠ Muligvis blandede programmer" }, "pg_desc": { "abrupt_drop_watts": "Pludseligt fald behandlet som øjeblikkelig afslutning", @@ -869,12 +876,19 @@ "start_duration_threshold": "Sekunder over tærsklen for at bekræfte start", "start_threshold_w": "Minimum watt for at tælle som startet", "stop_threshold_w": "Under dette tælles maskinen som slukket", - "dtw_bandwidth": "Hvor meget tidsforvrængning formgenkendelsen tillader", - "corr_weight": "Vægt på kurveform kontra effektniveau i genkendelsen", - "duration_weight": "Hvor meget overensstemmelse i køretid påvirker genkendelsen", - "energy_weight": "Hvor meget overensstemmelse i energiforbrug påvirker genkendelsen", - "profile_match_min_duration_ratio": "Korteste kørsel (i forhold til profilen), der stadig må matche", - "profile_match_max_duration_ratio": "Længste kørsel (i forhold til profilen), der stadig må matche" + "dtw_bandwidth": "Trin 3: Sakoe-Chiba-tidsforvrængningsband (0 = DTW slukket; standard 0,2 = 20% af cykellængde)", + "corr_weight": "Trin 2: balance mellem kurveform (korrelation) og effektniveau (MAE); standard 0,45", + "duration_weight": "Trin 4: hvor stærkt varighedsoverensstemmelse påvirker slutscoren (standard 0,22)", + "energy_weight": "Trin 4: hvor stærkt energioverensstemmelse påvirker slutscoren (standard 0,22)", + "profile_match_min_duration_ratio": "Trin 1: korteste cykellængde som brøkdel af profilvarighed; standard 0,1 (10%)", + "profile_match_max_duration_ratio": "Trin 1: længste cykellængde som brøkdel af profilvarighed; standard 1,5 (150%)", + "keep_min_score": "Trin 2: laveste score for at forblive i kampen; standard 0,1 tillader svage kandidater, senere trin vælger bedst", + "dtw_blend": "Trin 3: 0 = kun kernescore, 1 = kun DTW-score, 0,5 = ligelig blanding (standard)", + "dtw_ensemble_w": "Trin 3 (ensemble-tilstand): vægt på skaleret L1 kontra derivativ DTW; standard 0,7 foretrækker niveaubevidst", + "dtw_ddtw_scale": "Trin 3: DDTW-halvmætningsafstand; mindre = mere formfølsom (standard 30)", + "dtw_refine_top_n": "Trin 3: kandidater DTW gen-scorer; hæv til 7-9 hvis korrekt profil placerer sig 4.-5. (standard 5)", + "duration_scale": "Trin 4: log-forhold der varighedsoverensstemmelse halveres; lavere = strengere straf (standard 0,175)", + "energy_scale": "Trin 4: log-forhold der energioverensstemmelse halveres; lavere = strengere straf (standard 0,25)" }, "phase_desc": { "anti_crease": "Lejlighedsvis korte tumblinger efter færdiggørelse for at reducere rynker.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Valgfrie eksterne signaler: en slutudløser, en dørsensor, en pausekontakt og aflæsningspåmindelsen.", "label": "Udløsere og døre" + }, + "phase_eta": { + "label": "Resterende tid", + "intro": "Fasebevidst resterende tid, til maskiner hvis cykluslængde afhænger af temperatur eller centrifugering." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Fast pris pr. kWh brugt til omkostningstal, når der ikke er angivet en levende prisenhed ovenfor.", "label": "Statisk energipris (per kWh)" }, + "energy_sensor": { + "doc": "Valgfri kumulativ energitæller (total_increasing i kWh/Wh, f.eks. stikkets egen levetidsmåler). Når den er angivet, tages hver cyklus' rapporterede energi fra denne tællers forskel mellem start og slut, hvilket undgår den undertælling, du får ved at integrere en langsomt rapporterende effektsensor. Falder tilbage til den integrerede værdi, hvis aflæsningen mangler, dens enhed er ukendt, eller forskellen ikke er positiv. Lad feltet stå tomt for at fortsætte med at integrere effektsensoren.", + "label": "Energimålerenhed" + }, "expose_debug_entities": { "doc": "Udgiv ekstra diagnostiske HA-enheder (match konfidens, tvetydighed, interne tilstande). Fra holder enhedslisten ren til normal brug.", "label": "Vis fejlretningsenheder" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Viser tilskrivningen 'af ' på fællesskabsapparater og referencecyklusser." + }, + "enable_phase_matching": { + "label": "Fasebevidst resterende tid", + "doc": "Opdel hver kørende cyklus i faser (opvarmning, vask, centrifugering) og budgettér den resterende tid pr. fase, blandet med det klassiske estimat - tidligt i cyklussen læner den sig op ad fasebudgettet og mod slutningen op ad det klassiske estimat. Dette tilpasser nedtællingen til, hvor længe din maskine faktisk varmer op og kører, hvilket er mest mærkbart i den første halvdel af en cyklus. Fra = kun det klassiske estimat. Kun visningen af resterende tid påvirkes; programmatchning og cyklusregistrering er uændrede." + }, + "keep_min_score": { + "label": "Min. matchingsscore", + "doc": "Laveste lighedsscore en kandidat behøver for at forblive i matcheprocessen. Standard 0,1 er bevidst tillåtende – det tillader selv svage kandidater og stoler på at senere trin finder den bedste match. Hæv for at skære usandsynlige profiler fra tidligere; sænk for at udvide den indledende kandidatpulje." + }, + "dtw_blend": { + "label": "DTW-blanding", + "doc": "Hvor meget DTW-justeringsscoren erstatter kerneresultatet fra Trin 2. 0 = brug kun Trin 2-scoren, 1 = brug kun DTW-scoren, 0,5 (standard) = ligelig blanding. Hæv for at stole mere på tidsjustering når programmer har ensartede effektniveauer men forskellige timingmønstre." + }, + "dtw_ensemble_w": { + "label": "DTW-ensemblemiks", + "doc": "I ensemble-DTW-tilstand (standard) er dette vægten på skaleret L1 kontra derivativ DTW (DDTW). 1,0 = kun skaleret L1, 0 = kun DDTW, 0,7 = standard. Den skalerede variant sammenligner effektniveauer; den derivative reagerer på hvordan effekten ændres over tid. Ensemble blander begge." + }, + "dtw_ddtw_scale": { + "label": "Derivativ DTW-skala", + "doc": "Halvmætningsafstand for derivativ DTW-scoring. Scoren halveres når DDTW-afstanden er lig denne værdi. Lavere = mere følsom for formforskelle. Standard 30 er kalibreret til typiske husholdningsapparaters effektprofiler. Påvirker kun ensemble- og ddtw-DTW-tilstande." + }, + "dtw_refine_top_n": { + "label": "DTW-forfiningsnummer", + "doc": "Hvor mange topkandidater fra Trin 2 der gen-scores af DTW. DTW er mere kostbar så det anvendes kun på de N bedste. Standard 5. Hæv (til 7-9) hvis den korrekte profil sommetider kun når 4. eller 5. plads efter Trin 2 – DTW kan redde den. Lavere giver lidt hurtigere matching." + }, + "duration_scale": { + "label": "Varighedsskarphed", + "doc": "Skarphed i Trin 4's straf for varighedsoverensstemmelse. Det er log-forholdet ved hvilket overensstemmelses-scoren halveres. Lavere = strengere: et varighedsafvigelse skader mere. Standard 0,175 svarer til omtrent 18% varighedstolerens ved halvvægt. Par med Varighedsvægt." + }, + "energy_scale": { + "label": "Energiskarphed", + "doc": "Skarphed i Trin 4's straf for energioverensstemmelse. Lavere = strengere: et energiafvigelse skader mere. Standard 0,25 er bevidst mere tillåtende end varighedsskarphed fordi energi varierer med belastning. Par med Energivægt." } }, "setting_group": { @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimér: {param}" + }, + "pg_detail": { + "simulate": "Simulér cyklus" + }, + "split": { + "apply": "Opdeler cyklus" + }, + "trim": { + "apply": "Beskærer cyklus" + }, + "merge": { + "apply": "Sammenfletter cyklusser" + }, + "rebuild": { + "envelopes": "Genopbygger envelopes" + }, + "cancelling": "Annullerer..." + }, + "setup": { + "hdr": { + "card": "Apparatopsætning", + "healthy_chip": "Opsætning fuldført" + }, + "phase0": { + "washer": "WashData registrerer allerede dine cyklusser. Optag din første cyklus for at aktivere programnavne og tidsestimater.", + "dishwasher": "WashData holder øje. Opvaskemaskiner har komplekse cyklusser – det anbefales stærkt at optage din første cyklus. Hvis en registreret cyklus kører for længe, brug cyklus-editoren til at beskære den, inden du gemmer den som en profil.", + "generic": "WashData holder øje. Optag eller mærk en registreret cyklus for at begynde at opbygge profiler." + }, + "phase1a": { + "labelled": "Godt gået – dit første program er gemt. For de reneste data kan du overveje at optage din næste cyklus med optagelses-widgetten." + }, + "phase1b": { + "recorded": "Din optagelse blev gemt som {profile_name}. Optag nu eller mærk dine andre hyppigt brugte programmer for at opbygge dækning." + }, + "phase1c": { + "verify": "Du har {count} programmer fra fællesskabet. Kør en cyklus for at bekræfte, at WashData genkender den korrekt – matchningen forbedres, efterhånden som din enhed opbygger sin egen historik." + }, + "phase2": { + "cluster": "WashData har set {count} cyklusser, der ikke matcher noget gemt program – de ligner hinanden. Vil du oprette en ny profil til dem?", + "unmatched": "Din seneste cyklus matchede ikke noget gemt program. Er det et nyt program?" + }, + "phase3": { + "suggestions": "WashData har indstillingsanbefalinger baseret på din cyklushistorik – gennemgå dem for at forbedre registreringens nøjagtighed.", + "groups": "Nogle af dine profiler ligner det samme program ved forskellige temperaturer. Organiser dem i en gruppe for bedre matchning.", + "phases": "Tilføj programfaser til {profile_name} for mere præcise estimater af den resterende tid." + }, + "phase4": { + "healthy": "Dette apparat er fuldt opsat ({profile_count} profiler)." + }, + "cta": { + "start_recording": "Start optagelse", + "label_detected_cycle": "Jeg har allerede en registreret cyklus – mærk den i stedet", + "browse_cycles": "Gennemse cyklusser for at mærke en anden", + "view_profiles": "Se dine profiler", + "create_from_cluster": "Opret profil fra disse cyklusser", + "create_profile": "Opret en profil til den", + "review_suggestions": "Gennemgå anbefalinger", + "organise_profiles": "Organiser profiler", + "configure_phases": "Konfigurer faser", + "skip_step": "Spring dette trin over", + "skip_forever": "Vis ikke igen", + "hide_guidance": "Skjul vejledning", + "show_guidance": "Vis vejledning" } } } diff --git a/custom_components/ha_washdata/translations/panel/de.json b/custom_components/ha_washdata/translations/panel/de.json index 733d298..245d9f0 100644 --- a/custom_components/ha_washdata/translations/panel/de.json +++ b/custom_components/ha_washdata/translations/panel/de.json @@ -87,7 +87,7 @@ "on_cycle_finished": "Ein Zyklus beendet", "on_cycle_started": "Ein Zyklus gestartet", "pause_cycle": "Pause", - "pause_cycle_tip": "Laufenden Zyklus anhalten — das Gerät setzt dort fort, wo es aufgehört hat", + "pause_cycle_tip": "Laufenden Zyklus anhalten – das Gerät setzt dort fort, wo es aufgehört hat", "pg_apply_device": "Auf Gerät anwenden", "pg_autofill": "Automatisch ausfüllen", "play": "Abspielen", @@ -97,8 +97,8 @@ "process_tip": "Aufgezeichnete Datenspur als neues oder vorhandenes Profil speichern", "rebuild": "Umschläge neu erstellen", "rebuild_envelope": "Umschlag neu erstellen", - "rebuild_tip": "Erwarteten Leistungsumschlag (Min./Max.-Band) für alle Profile aus deren beschrifteten Zyklen neu berechnen — nach dem Beschriften neuer oder Korrigieren alter Zyklen ausführen", - "rec_start_tip": "Leistungsaufzeichnung des Geräts starten — kurz vor dem Starten eines Zyklus beginnen", + "rebuild_tip": "Erwarteten Leistungsumschlag (Min./Max.-Band) für alle Profile aus deren beschrifteten Zyklen neu berechnen – nach dem Beschriften neuer oder Korrigieren alter Zyklen ausführen", + "rec_start_tip": "Leistungsaufzeichnung des Geräts starten – kurz vor dem Starten eines Zyklus beginnen", "rec_stop": "Stoppen", "rec_stop_tip": "Aufnahme stoppen und die erfasste Datenspur zur Überprüfung bereitstellen", "record": "Aufnahme starten", @@ -231,7 +231,7 @@ "interval": "Sollte mindestens das 2-fache des Abtastintervalls ({si} s) betragen", "sampling": "Abtastintervall sollte höchstens die Hälfte des Watchdog-Intervalls ({wi} s) betragen" }, - "settings_banner": "{n} Einstellungskonflikt{s} — markierte Abschnitte prüfen und vor dem Speichern beheben.", + "settings_banner": "{n} Einstellungskonflikt{s} – markierte Abschnitte prüfen und vor dem Speichern beheben.", "settings_banner_btn": "Zum ersten" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Erwartete Kurvenüberlagerung anzeigen (angepasstes Profil, orange)", "show_raw": "Umschalter für rohe Steckdosenwerte im Live-Leistungsdiagramm anzeigen", "sparkline": "Trend der letzten Zyklusdauern", - "stage2": "Stufe 2 — Kernähnlichkeit", - "stage3": "Stufe 3 — DTW", - "stage4": "Stufe 4 — Übereinstimmung", + "stage2": "Stufe 2 – Kernähnlichkeit", + "stage3": "Stufe 3 – DTW", + "stage4": "Stufe 4 – Übereinstimmung", "status": "Status", "tail_trim": "Ende kürzen (s)", "timer_auto_pause": "Automatische Pause", @@ -561,7 +561,12 @@ "flags": "Flags", "include_phase_map": "Phasenkarte einbeziehen", "include_settings": "Erkennungs- und Abgleicheinstellungen einbeziehen", - "show_contributor": "Beiträgernamen anzeigen" + "show_contributor": "Beiträgernamen anzeigen", + "task_pg_detail": "Zyklus simulieren", + "task_split": "Zyklus wird geteilt", + "task_trim": "Zyklus wird zugeschnitten", + "task_merge": "Zyklen werden zusammengeführt", + "task_rebuild": "Hüllkurven werden neu aufgebaut" }, "log": { "all_levels": "Alle Ebenen", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Sank unter das übliche Leistungsband für ~{n}s.", "artifact_footer": "In der Grafik oben hervorgehoben. Hierbei handelt es sich um vorübergehende Artefakte (z. B. das Öffnen der Tür mitten im Zyklus), nicht unbedingt um Probleme.", "artifact_header": "{n} Anomalien wurden während dieses Zyklus erkannt", - "artifact_pause_detail": "Die Leistung fiel für ~{n}s nahezu auf null und wurde dann wieder aufgenommen — wahrscheinlich wurde die Tür während des Zyklus geöffnet oder der Zyklus wurde angehalten.", + "artifact_pause_detail": "Die Leistung fiel für ~{n}s nahezu auf null und wurde dann wieder aufgenommen – wahrscheinlich wurde die Tür während des Zyklus geöffnet oder der Zyklus wurde angehalten.", "artifact_spike_detail": "Überstieg das übliche Leistungsband für ~{n}s.", "auto_label_intro": "Weisen Sie Profile unbeschrifteten Zyklen zu, deren Übereinstimmungskonfidenz den Schwellenwert überschreitet.", "automations_intro": "WashData löst {start} / {end}-Ereignisse aus und stellt Entitäten bereit, sodass Benachrichtigungen und Aktionen am besten als normale Home Assistant-Automatisierungen eingerichtet werden. Automatisierungen, die dieses Gerät verwenden, werden unten angezeigt.", @@ -657,7 +662,7 @@ "compare_selected_cycles": "Ausgewählte Zyklen (durchgehend) – ein-/ausblenden", "consider_new_profile": "Erwägen Sie die Erstellung eines neuen Profils.", "coverage_gap": "Die letzten Zyklen haben kein passendes Profil ({pct} % der letzten 30).", - "coverage_gap_similar_cycles": "{count} ähnliche unbeschriftete Zyklen gefunden — erstellen Sie ein Profil, um diese zu erkennen.", + "coverage_gap_similar_cycles": "{count} ähnliche unbeschriftete Zyklen gefunden – erstellen Sie ein Profil, um diese zu erkennen.", "cycles_deleted": "{count} Zyklen gelöscht", "enough_data": "Genügend Daten zum Lernen ({current}/{min} Zyklen).", "export_description": "Exportieren Sie alle Profile und Zyklen nach JSON oder stellen Sie einen vorherigen Export wieder her.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Auf diese Maschine abgestimmte Modelle.", "ml_loading": "ML wird geladen…", "ml_settings_intro": "Zwei unabhängige Schalter: Einer wendet die Modelle an, während ein Zyklus läuft, der andere ermöglicht es WashData, sie im Laufe der Zeit an Ihre Maschine anzupassen.", - "name_first_program": "Sie haben genügend Zyklen — benennen Sie Ihr erstes Programm, um den Abgleich zu starten.", + "name_first_program": "Sie haben genügend Zyklen – benennen Sie Ihr erstes Programm, um den Abgleich zu starten.", "near_duplicate_cluster": "Beinahe doppelter Profilcluster erkannt. Durch die Gruppierung kann beim Matching zuverlässig zwischen Doppelgängern ausgewählt werden (z. B. dasselbe Programm bei unterschiedlicher Temperatur/Schleuder).", "no_cycles_match": "Keine Zyklen entsprechen dem aktuellen Filter.", "no_cycles_profile": "Keine Zyklen für dieses Profil.", @@ -713,7 +718,7 @@ "notify_services_hint": "Verwenden Sie {entity}-Dienst-IDs (kommagetrennt für mehrere). Template-Variablen: {vars}.", "old_actions_warning": "Konfiguriert mit dem alten Aktionseditor (jetzt entfernt). Sie werden weiterhin auf Zyklusereignisse ausgelöst, können hier jedoch nicht mehr bearbeitet werden. Wandeln Sie in eine normale Automatisierung um oder entfernen Sie sie.", "onboarding_progress": "{n} / 3 Zyklen beobachtet", - "onboarding_watching": "Lassen Sie Ihr Gerät normal laufen — WashData beobachtet. Nach 3 Zyklen beginnt der Programmabgleich.", + "onboarding_watching": "Lassen Sie Ihr Gerät normal laufen – WashData beobachtet. Nach 3 Zyklen beginnt der Programmabgleich.", "pending_feedback": "Ausstehende Rückmeldung zur Erkennung", "performance_trend": "Leistungstrend ({n} Zyklen)", "pg_analysis_empty": "Laden Sie einen Zyklus, um die Zuordnungsanalyse anzuzeigen.", @@ -763,7 +768,7 @@ "setting_changed": "Geändert von {old} zu {new} am {date}", "settings_basic_note": "Es werden die wesentlichen Einstellungen angezeigt. Wechseln Sie zu Erweitert für die vollständige Liste.", "shape_drift_advisory": "⚠ Musterdrift", - "shape_drift_detail": "Das Leistungsmuster dieses Profils hat sich im Laufe der Zeit verändert — möglicher Geräteverschleiß oder Wartung erforderlich (z. B. Entkalken, Filterreinigung).", + "shape_drift_detail": "Das Leistungsmuster dieses Profils hat sich im Laufe der Zeit verändert – möglicher Geräteverschleiß oder Wartung erforderlich (z. B. Entkalken, Filterreinigung).", "show_all_settings": "Alle Einstellungen anzeigen", "showing_suggestions": "{count} Einstellung mit Vorschlägen wird angezeigt.", "split_intro": "Klicken Sie auf das Diagramm, um einen Teilungspunkt hinzuzufügen oder zu entfernen oder eine automatische Erkennung anhand von Leerlauflücken durchzuführen. Jedes resultierende Segment kann ein eigenes Profil erhalten.", @@ -857,7 +862,9 @@ "share_guidelines_title": "Vor dem Teilen", "store_download_device_intro": "Übernehmen Sie alle geteilten Programme und deren Referenzzyklen auf Ihr Gerät. Ihre eigenen aufgezeichneten Zyklen und Statistiken bleiben unberührt.", "store_share_device_intro": "Laden Sie {brand} {model} mit den von Ihnen ausgewählten Referenzzyklen hoch. Andere mit demselben Gerät können Ihre Programme übernehmen. Einträge werden vor der öffentlichen Erscheinung geprüft.", - "share_profile_no_cycles": "Keine Referenzzyklen -- markieren Sie einen Zyklus als ⭐ im Tab Zyklen, um dieses Profil einzubeziehen" + "share_profile_no_cycles": "Keine Referenzzyklen -- markieren Sie einen Zyklus als ⭐ im Tab Zyklen, um dieses Profil einzubeziehen", + "advisory_phase_inconsistent": "'{name}' scheint verschiedene Programme oder Temperaturen zu vermischen - seine Zyklen heizen unterschiedlich lange. Wenn Sie es in separate Profile aufteilen (z. B. pro Temperatur), verbessern sich Abgleich und Zeitschätzungen.", + "advisory_phase_inconsistent_title": "⚠ Möglicherweise gemischte Programme" }, "pg_desc": { "abrupt_drop_watts": "Plötzlicher Abfall gilt als sofortiges Ende", @@ -869,12 +876,19 @@ "start_duration_threshold": "Sekunden über dem Schwellenwert, um den Start zu bestätigen", "start_threshold_w": "Mindestwattzahl, um als gestartet zu gelten", "stop_threshold_w": "Darunter gilt die Maschine als aus", - "dtw_bandwidth": "Wie viel Zeitverzerrung der Formabgleich zulässt", - "corr_weight": "Gewichtung von Kurvenform gegenüber Leistungspegel bei der Zuordnung", - "duration_weight": "Wie stark die Übereinstimmung der Laufzeit die Zuordnung beeinflusst", - "energy_weight": "Wie stark die Übereinstimmung des Energieverbrauchs die Zuordnung beeinflusst", - "profile_match_min_duration_ratio": "Kürzester Durchlauf (im Vergleich zum Profil), der noch zugeordnet werden darf", - "profile_match_max_duration_ratio": "Längster Durchlauf (im Vergleich zum Profil), der noch zugeordnet werden darf" + "dtw_bandwidth": "Stufe 3: Sakoe-Chiba-Verzerrungsband (0 = DTW aus; Standard 0,2 = 20% der Zykluslänge)", + "corr_weight": "Stufe 2: Balance zwischen Kurvenform (Korrelation) und Leistungspegel (MAE); Standard 0,45", + "duration_weight": "Stufe 4: wie stark die Dauerübereinstimmung den Endscore beeinflusst (Standard 0,22)", + "energy_weight": "Stufe 4: wie stark die Energieübereinstimmung den Endscore beeinflusst (Standard 0,22)", + "profile_match_min_duration_ratio": "Stufe 1: minimale Zyklusdauer als Bruchteil der Profildauer; Standard 0,1 (10%)", + "profile_match_max_duration_ratio": "Stufe 1: maximale Zyklusdauer als Bruchteil der Profildauer; Standard 1,5 (150%)", + "keep_min_score": "Stufe 2: Mindest-Score, um im Auswahlverfahren zu bleiben; Standard 0,1 lässt schwache Kandidaten zu, spätere Stufen wählen den besten", + "dtw_blend": "Stufe 3: 0 = nur Kernscore, 1 = nur DTW-Score, 0,5 = gleiche Mischung (Standard)", + "dtw_ensemble_w": "Stufe 3 (Ensemble-Modus): Gewicht auf skaliertem L1 gegenüber Ableitungs-DTW; Standard 0,7 bevorzugt pegelabhängige Erkennung", + "dtw_ddtw_scale": "Stufe 3: DDTW-Halbsättigungsabstand; kleiner = formempfindlicher (Standard 30)", + "dtw_refine_top_n": "Stufe 3: per DTW neu bewertete Kandidaten; auf 7-9 erhöhen, wenn das richtige Profil auf Platz 4-5 liegt (Standard 5)", + "duration_scale": "Stufe 4: Log-Verhältnis, bei dem die Dauerübereinstimmung halbiert wird; kleiner = strengere Strafe (Standard 0,175)", + "energy_scale": "Stufe 4: Log-Verhältnis, bei dem die Energieübereinstimmung halbiert wird; kleiner = strengere Strafe (Standard 0,25)" }, "phase_desc": { "anti_crease": "Gelegentliche kurze Trommelumwälzungen nach Abschluss, um Knitterung zu vermeiden.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Optionale externe Signale: ein Endauslöser, ein Türsensor, ein Pause-Schalter und die Entladeerinnerung.", "label": "Auslöser & Türen" + }, + "phase_eta": { + "label": "Verbleibende Zeit", + "intro": "Phasenbewusste Restzeit, für Maschinen, deren Zyklusdauer von Temperatur oder Schleuderdrehzahl abhängt." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Fester Preis pro kWh, der für Kostenzahlen verwendet wird, wenn oben keine Live-Preiseinheit festgelegt ist.", "label": "Statischer Energiepreis (pro kWh)" }, + "energy_sensor": { + "doc": "Optionaler kumulativer Energiezähler (total_increasing in kWh/Wh, z. B. der eigene Lebensdauerzähler der Steckdose). Wenn gesetzt, wird die gemeldete Energie jedes Zyklus aus der Differenz dieses Zählers zwischen Anfang und Ende ermittelt, was die Untererfassung vermeidet, die beim Integrieren eines langsam meldenden Leistungssensors entsteht. Greift auf den integrierten Wert zurück, wenn der Messwert fehlt, seine Einheit unbekannt ist oder die Differenz nicht positiv ausfällt. Leer lassen, um weiterhin den Leistungssensor zu integrieren.", + "label": "Energiezähler-Entität" + }, "expose_debug_entities": { "doc": "Veröffentlichen Sie zusätzliche diagnostische HA-Entitäten (Übereinstimmungskonfidenz, Mehrdeutigkeit, Zustandsinterna). Aus sorgt dafür, dass die Entitätsliste für den normalen Gebrauch sauber bleibt.", "label": "Debug-Entitäten anzeigen" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Zeigt die Nennung 'von ' bei Community-Geräten und Referenzzyklen an." + }, + "enable_phase_matching": { + "label": "Phasenbewusste Restzeit", + "doc": "Teilt jeden laufenden Zyklus in Phasen auf (Heizen, Waschen, Schleudern) und veranschlagt die verbleibende Zeit pro Phase, gemischt mit der klassischen Schätzung - zu Beginn des Zyklus stützt sie sich auf das Phasenbudget, gegen Ende auf die klassische Schätzung. Dadurch wird der Countdown darauf abgestimmt, wie lange Ihre Maschine tatsächlich heizt und läuft, was in der ersten Hälfte eines Zyklus am deutlichsten spürbar ist. Aus = nur die klassische Schätzung. Betroffen ist ausschließlich die Anzeige der verbleibenden Zeit; Programmabgleich und Zykluserkennung bleiben unverändert." + }, + "keep_min_score": { + "label": "Min. Match-Score", + "doc": "Mindest-Ähnlichkeitsscore, den ein Kandidat benötigt, um im Auswahlverfahren zu bleiben. Der Standard 0,1 ist absichtlich permissiv - er lässt auch schwache Kandidaten zu und verlässt sich auf spätere Stufen, um die beste Übereinstimmung zu finden. Erhöhen, um unwahrscheinliche Profile früher auszuschließen; verringern, um den anfänglichen Kandidatenpool zu erweitern." + }, + "dtw_blend": { + "label": "Warp-Mischgewicht", + "doc": "Wie stark der Zeitverzerrungsscore (DTW) den Kernscore der Stufe 2 ersetzt. 0 = nur Stufe-2-Score, 1 = nur DTW-Score, 0,5 (Standard) = gleiche Mischung. Erhöhen, um sich mehr auf die Warp-Ausrichtung zu verlassen, wenn Programme ähnliche Leistungspegel, aber unterschiedliche Zeitmuster aufweisen." + }, + "dtw_ensemble_w": { + "label": "Warp-Ensemble-Anteil", + "doc": "Im Ensemble-DTW-Modus (Standard) das Gewicht auf skaliertem L1 gegenüber Ableitungs-DTW (DDTW). 1,0 = nur skaliertes L1, 0 = nur DDTW, 0,7 = Standard. Die skalierte L1-Variante vergleicht Leistungspegel; die Ableitungsvariante reagiert auf Leistungsänderungen im Zeitverlauf. Ensemble kombiniert beide." + }, + "dtw_ddtw_scale": { + "label": "Ableitungs-Warp-Skalierung", + "doc": "Halbsättigungsabstand für die Ableitungs-DTW-Bewertung. Der Score halbiert sich, wenn die DDTW-Distanz diesen Wert erreicht. Kleiner = empfindlicher gegenüber Formunterschieden. Standard 30 ist auf typische Geräteleistungsverläufe kalibriert. Betrifft nur Ensemble- und DDTW-DTW-Modi." + }, + "dtw_refine_top_n": { + "label": "Warp-Verfeinerungsanzahl", + "doc": "Anzahl der besten Stufe-2-Kandidaten, die per DTW neu bewertet werden. DTW ist aufwendiger, daher wird es nur auf die besten N Kandidaten angewendet. Standard 5. Erhöhen (auf 7-9), wenn das richtige Profil nach Stufe 2 manchmal nur auf Platz 4 oder 5 landet - DTW kann es noch retten. Verringern beschleunigt das Matching leicht." + }, + "duration_scale": { + "label": "Laufzeit-Trennschärfe", + "doc": "Schärfe der Dauerübereinstimmungsstrafe in Stufe 4. Dies ist das Log-Verhältnis, bei dem der Übereinstimmungsscore halbiert wird. Kleiner = strenger: eine Dauerabweichung schadet mehr. Standard 0,175 entspricht etwa 18% Dauertoleranz beim halben Gewicht. Gemeinsam mit der Dauergewichtung anpassen." + }, + "energy_scale": { + "label": "Energie-Trennschärfe", + "doc": "Schärfe der Energieübereinstimmungsstrafe. Kleiner = strenger: eine Energieabweichung schadet mehr. Standard 0,25 ist absichtlich nachsichtiger als die Dauerschärfe, da Energie lastabhängig schwankt. Gemeinsam mit der Energiegewichtung anpassen." } }, "setting_group": { @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimieren: {param}" + }, + "pg_detail": { + "simulate": "Zyklus simulieren" + }, + "split": { + "apply": "Zyklus wird geteilt" + }, + "trim": { + "apply": "Zyklus wird zugeschnitten" + }, + "merge": { + "apply": "Zyklen werden zusammengeführt" + }, + "rebuild": { + "envelopes": "Hüllkurven werden neu aufgebaut" + }, + "cancelling": "Wird abgebrochen..." + }, + "setup": { + "hdr": { + "card": "Gerät einrichten", + "healthy_chip": "Einrichtung abgeschlossen" + }, + "phase0": { + "washer": "WashData erkennt bereits Ihre Zyklen. Nehmen Sie Ihren ersten Zyklus auf, um Programmnamen und Zeitschätzungen zu aktivieren.", + "dishwasher": "WashData beobachtet. Geschirrspüler haben komplexe Zyklen – es wird dringend empfohlen, Ihren ersten Zyklus aufzunehmen. Falls ein erkannter Zyklus zu lang läuft, kürzen Sie ihn im Zyklus-Editor, bevor Sie ihn als Profil speichern.", + "generic": "WashData beobachtet. Nehmen Sie einen Zyklus auf oder beschriften Sie einen erkannten Zyklus, um mit dem Aufbau von Profilen zu beginnen." + }, + "phase1a": { + "labelled": "Guter Start – Ihr erstes Programm ist gespeichert. Für die saubersten Daten empfiehlt es sich, den nächsten Zyklus mit dem Aufnahme-Widget aufzuzeichnen." + }, + "phase1b": { + "recorded": "Ihre Aufnahme wurde als {profile_name} gespeichert. Nehmen Sie nun Ihre anderen häufig verwendeten Programme auf oder beschriften Sie sie, um die Abdeckung zu verbessern." + }, + "phase1c": { + "verify": "Sie haben {count} Programme aus der Community. Führen Sie einen Zyklus durch, um zu überprüfen, ob WashData ihn korrekt erkennt – die Erkennung verbessert sich, je mehr Verlauf Ihr Gerät aufbaut." + }, + "phase2": { + "cluster": "WashData hat {count} Zyklen gesehen, die keinem gespeicherten Programm entsprechen – sie sehen einander ähnlich aus. Möchten Sie ein neues Profil dafür erstellen?", + "unmatched": "Ihr letzter Zyklus hat keinem gespeicherten Programm entsprochen. Ist es ein neues Programm?" + }, + "phase3": { + "suggestions": "WashData hat Einstellungsempfehlungen basierend auf Ihrem Zyklusverlauf – überprüfen Sie sie, um die Erkennungsgenauigkeit zu verbessern.", + "groups": "Einige Ihrer Profile sehen aus wie dasselbe Programm bei unterschiedlichen Temperaturen. Organisieren Sie sie in einer Gruppe für eine bessere Erkennung.", + "phases": "Fügen Sie {profile_name} Programmphasen hinzu, um genauere Schätzungen der verbleibenden Zeit zu erhalten." + }, + "phase4": { + "healthy": "Dieses Gerät ist vollständig eingerichtet ({profile_count} Profile)." + }, + "cta": { + "start_recording": "Aufnahme starten", + "label_detected_cycle": "Ich habe bereits einen erkannten Zyklus – stattdessen beschriften", + "browse_cycles": "Zyklen durchsuchen, um einen weiteren zu beschriften", + "view_profiles": "Ihre Profile anzeigen", + "create_from_cluster": "Profil aus diesen Zyklen erstellen", + "create_profile": "Ein Profil dafür erstellen", + "review_suggestions": "Empfehlungen überprüfen", + "organise_profiles": "Profile organisieren", + "configure_phases": "Phasen konfigurieren", + "skip_step": "Diesen Schritt überspringen", + "skip_forever": "Nicht mehr anzeigen", + "hide_guidance": "Anleitung ausblenden", + "show_guidance": "Anleitung einblenden" } } } diff --git a/custom_components/ha_washdata/translations/panel/el.json b/custom_components/ha_washdata/translations/panel/el.json index 8115303..1e191c6 100644 --- a/custom_components/ha_washdata/translations/panel/el.json +++ b/custom_components/ha_washdata/translations/panel/el.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "Εντοπίστηκαν {n} ανωμαλίες (π.χ. η πόρτα άνοιξε στα μέσα του κύκλου) — ανοίξτε για να τις δείτε στο γράφημα", + "artifact_tip": "Εντοπίστηκαν {n} ανωμαλίες (π.χ. η πόρτα άνοιξε στα μέσα του κύκλου) – ανοίξτε για να τις δείτε στο γράφημα", "auto": "(αυτόματη ανίχνευση)", "built_in_tag": "ενσωματωμένο", "declining": "↘ φθίνουσα", "energy_low": "Χαμηλότερη κατανάλωση ενέργειας από το συνηθισμένο", "energy_spike": "Υψηλότερη κατανάλωση ενέργειας από το συνηθισμένο", "fair_fit": "αποδεκτή ποιότητα", - "fair_fit_tip": "Μέτρια συνέπεια αντιστοίχισης — ορισμένοι κύκλοι που έχουν εκχωρηθεί σε αυτό το προφίλ έχουν χαμηλότερους βαθμούς εμπιστοσύνης. Επισημάνετε περισσότερους κύκλους ή εγγράψτε ξανά το προφίλ για να βελτιώσετε την ακρίβεια.", + "fair_fit_tip": "Μέτρια συνέπεια αντιστοίχισης – ορισμένοι κύκλοι που έχουν εκχωρηθεί σε αυτό το προφίλ έχουν χαμηλότερους βαθμούς εμπιστοσύνης. Επισημάνετε περισσότερους κύκλους ή εγγράψτε ξανά το προφίλ για να βελτιώσετε την ακρίβεια.", "feedback_requested": "Ζητήθηκαν σχόλια", "golden_cycle": "Καταγεγραμμένος κύκλος αναφοράς", "improving": "↗ βελτίωση", @@ -16,7 +16,7 @@ "overrun": "Έτρεξε περισσότερο από το συνηθισμένο", "underrun": "Ολοκληρώθηκε γρηγορότερα από το συνηθισμένο", "poor_fit": "κακή ποιότητα", - "poor_fit_tip": "Ασυνεπές ιστορικό αντιστοίχισης — σκεφτείτε να δημιουργήσετε ξανά αυτό το προφίλ", + "poor_fit_tip": "Ασυνεπές ιστορικό αντιστοίχισης – σκεφτείτε να δημιουργήσετε ξανά αυτό το προφίλ", "restart_gap_tip": "{n} κενό επανεκκίνησης HA: το ίχνος ισχύος έχει ένα κενό", "reviewed": "Αξιολογήθηκε", "steady": "→ σταθερό", @@ -87,7 +87,7 @@ "on_cycle_finished": "Ο κύκλος ολοκληρώθηκε", "on_cycle_started": "Ο κύκλος ξεκίνησε", "pause_cycle": "Παύση", - "pause_cycle_tip": "Παύση του εν λειτουργία κύκλου — η συσκευή θα συνεχίσει από εκεί που σταμάτησε", + "pause_cycle_tip": "Παύση του εν λειτουργία κύκλου – η συσκευή θα συνεχίσει από εκεί που σταμάτησε", "pg_apply_device": "Εφαρμογή στη συσκευή", "pg_autofill": "Αυτόματη συμπλήρωση", "play": "Αναπαραγωγή", @@ -97,8 +97,8 @@ "process_tip": "Αποθήκευση του καταγεγραμμένου ίχνους ως νέο ή υπάρχον προφίλ", "rebuild": "Ανακατασκευή φακέλων", "rebuild_envelope": "Ανακατασκευή φακέλου", - "rebuild_tip": "Επανυπολογισμός του αναμενόμενου φακέλου ισχύος (ελάχιστο/μέγιστο εύρος) για όλα τα προφίλ από τους επισημασμένους τους κύκλους — εκτελέστε μετά από επισήμανση νέων κύκλων ή διόρθωση παλιών", - "rec_start_tip": "Έναρξη καταγραφής του ίχνους ισχύος της συσκευής — ξεκινήστε λίγο πριν τη λειτουργία του κύκλου", + "rebuild_tip": "Επανυπολογισμός του αναμενόμενου φακέλου ισχύος (ελάχιστο/μέγιστο εύρος) για όλα τα προφίλ από τους επισημασμένους τους κύκλους – εκτελέστε μετά από επισήμανση νέων κύκλων ή διόρθωση παλιών", + "rec_start_tip": "Έναρξη καταγραφής του ίχνους ισχύος της συσκευής – ξεκινήστε λίγο πριν τη λειτουργία του κύκλου", "rec_stop": "Στάση", "rec_stop_tip": "Διακοπή εγγραφής και διατήρηση του καταγεγραμμένου ίχνους για αναθεώρηση", "record": "Ξεκινήστε την εγγραφή", @@ -182,7 +182,7 @@ }, "attn_sub": "Διορθώστε τις συγκρούσεις πριν αποθηκεύσετε", "attn_title": "{n} σύγκρουση{s} ρυθμίσεων", - "settings_banner": "{n} σύγκρουση{s} ρυθμίσεων — ελέγξτε τα επισημασμένα τμήματα και διορθώστε πριν αποθηκεύσετε.", + "settings_banner": "{n} σύγκρουση{s} ρυθμίσεων – ελέγξτε τα επισημασμένα τμήματα και διορθώστε πριν αποθηκεύσετε.", "settings_banner_btn": "Μετάβαση στο πρώτο", "confidence": { "auto": "Πρέπει να είναι μεγαλύτερο ή ίσο με το Κατώφλι Αντιστοίχισης ({match})", @@ -451,9 +451,9 @@ "show_expected": "Εμφάνιση αναμενόμενης επικάλυψης καμπύλης (αντιστοιχισμένο προφίλ, πορτοκαλί)", "show_raw": "Εμφάνιση εναλλακτή ακατέργαστης πρίζας στο γράφημα ζωντανής ισχύος", "sparkline": "Τάση διάρκειας πρόσφατων κύκλων", - "stage2": "Στάδιο 2 — βασική ομοιότητα", - "stage3": "Στάδιο 3 — DTW", - "stage4": "Στάδιο 4 — συμφωνία", + "stage2": "Στάδιο 2 – βασική ομοιότητα", + "stage3": "Στάδιο 3 – DTW", + "stage4": "Στάδιο 4 – συμφωνία", "status": "Κατάσταση", "tail_trim": "Περικοπή ουράς", "timer_auto_pause": "Αυτόματη παύση", @@ -561,7 +561,12 @@ "flags": "Σημαίες", "include_phase_map": "Συμπερίληψη χάρτη φάσεων", "include_settings": "Συμπερίληψη ρυθμίσεων ανίχνευσης & αντιστοίχισης", - "show_contributor": "Εμφάνιση ονομάτων συνεισφερόντων" + "show_contributor": "Εμφάνιση ονομάτων συνεισφερόντων", + "task_pg_detail": "Προσομοίωση κύκλου", + "task_split": "Διαχωρισμός κύκλου", + "task_trim": "Περικοπή κύκλου", + "task_merge": "Συγχώνευση κύκλων", + "task_rebuild": "Ανακατασκευή φακέλων" }, "log": { "all_levels": "Όλα τα επίπεδα", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Έπεσε κάτω από το συνηθισμένο εύρος ισχύος για ~{n}s.", "artifact_footer": "Επισημαίνεται στο παραπάνω γράφημα. Αυτά είναι παροδικά τεχνουργήματα (π.χ. η πόρτα άνοιξε στα μέσα του κύκλου), όχι απαραίτητα προβλήματα.", "artifact_header": "{n} ανωμαλίες που εντοπίστηκαν κατά τη διάρκεια αυτού του κύκλου", - "artifact_pause_detail": "Η ισχύς έπεσε σχεδόν στο μηδέν για ~{n}s και μετά ανέκαμψε — πιθανώς η πόρτα άνοιξε στα μέσα του κύκλου ή ο κύκλος τέθηκε σε παύση.", + "artifact_pause_detail": "Η ισχύς έπεσε σχεδόν στο μηδέν για ~{n}s και μετά ανέκαμψε – πιθανώς η πόρτα άνοιξε στα μέσα του κύκλου ή ο κύκλος τέθηκε σε παύση.", "artifact_spike_detail": "Υπερέβη το συνηθισμένο εύρος ισχύος για ~{n}s.", "auto_label_intro": "Εκχωρήστε προφίλ σε κύκλους χωρίς ετικέτα, των οποίων η εμπιστοσύνη αντιστοίχισης εξαλείφει το όριο.", "automations_intro": "Το WashData ενεργοποιεί συμβάντα {start} / {end} και εκθέτει οντότητες, οπότε οι ειδοποιήσεις και οι ενέργειες κατασκευάζονται καλύτερα ως κανονικοί αυτοματισμοί του Home Assistant. Οι αυτοματισμοί που χρησιμοποιούν αυτή τη συσκευή εμφανίζονται παρακάτω.", @@ -654,10 +659,10 @@ "collecting_data": "Συλλογή δεδομένων: απαιτούνται ακόμη {need} κύκλος{plural} πριν ξεκινήσει η βελτιστοποίηση ({current}/{min}).", "compare_overlay_profiles": "Προφίλ επικάλυψης (ασθενώς)", "compare_profiles_tip": "Επικαλύψτε άλλους φακέλους προφίλ στο παραπάνω γράφημα για να δείτε ποιος ταιριάζει καλύτερα σε αυτόν τον κύκλο.", - "compare_selected_cycles": "Επιλεγμένοι κύκλοι (συμπαγής) — εμφάνιση / απόκρυψη", + "compare_selected_cycles": "Επιλεγμένοι κύκλοι (συμπαγής) – εμφάνιση / απόκρυψη", "consider_new_profile": "Σκεφτείτε να δημιουργήσετε ένα νέο προφίλ.", "coverage_gap": "οι πρόσφατοι κύκλοι δεν έχουν αντίστοιχο προφίλ ({pct}% των τελευταίων 30).", - "coverage_gap_similar_cycles": "Βρέθηκαν {count} παρόμοιοι κύκλοι χωρίς ετικέτα — δημιουργήστε ένα προφίλ για να αρχίσετε την αντιστοίχισή τους.", + "coverage_gap_similar_cycles": "Βρέθηκαν {count} παρόμοιοι κύκλοι χωρίς ετικέτα – δημιουργήστε ένα προφίλ για να αρχίσετε την αντιστοίχισή τους.", "cycles_deleted": "Διαγράφηκαν {count} κύκλοι", "enough_data": "Αρκετά δεδομένα για εκμάθηση ({current}/{min} κύκλοι).", "export_description": "Εξαγωγή όλων των προφίλ και των κύκλων σε JSON ή επαναφορά από προηγούμενη εξαγωγή.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Μοντέλα προσαρμοσμένα σε αυτό το μηχάνημα.", "ml_loading": "φόρτωση ML…", "ml_settings_intro": "Δύο ανεξάρτητοι διακόπτες: ο ένας εφαρμόζει τα μοντέλα ενώ εκτελείται ένας κύκλος, ο άλλος επιτρέπει στην WashData να τα ρυθμίσει με ακρίβεια στο μηχάνημά σας με την πάροδο του χρόνου.", - "name_first_program": "Έχετε αρκετούς κύκλους — ονομάστε το πρώτο σας πρόγραμμα για να ξεκινήσει η αντιστοίχιση.", + "name_first_program": "Έχετε αρκετούς κύκλους – ονομάστε το πρώτο σας πρόγραμμα για να ξεκινήσει η αντιστοίχιση.", "near_duplicate_cluster": "Εντοπίστηκε σχεδόν διπλό σύμπλεγμα προφίλ. Η ομαδοποίηση επιτρέπει την αξιόπιστη επιλογή αντιστοίχισης μεταξύ ομοίων (π.χ. ίδιο πρόγραμμα σε διαφορετική θερμοκρασία/περιστροφή).", "no_cycles_match": "Κανένας κύκλος δεν ταιριάζει με το τρέχον φίλτρο.", "no_cycles_profile": "Δεν υπάρχουν κύκλοι για αυτό το προφίλ.", @@ -696,7 +701,7 @@ "no_devices": "Δεν έχουν διαμορφωθεί ακόμη συσκευές WashData.", "no_envelope": "Δεν υπάρχει ακόμη φάκελος - ανακατασκευή μετά από κύκλους ετικετών.", "no_envelope_overlay": "Δεν υπάρχει διαθέσιμος φάκελος για επικάλυψη.", - "no_fine_tuned": "Τίποτα δεν έχει βελτιωθεί ακόμα — Η WashData χρησιμοποιεί τα ενσωματωμένα μοντέλα της.", + "no_fine_tuned": "Τίποτα δεν έχει βελτιωθεί ακόμα – Η WashData χρησιμοποιεί τα ενσωματωμένα μοντέλα της.", "no_logs": "Δεν υπάρχουν ακόμα εγγραφές καταγραφής στην προσωρινή μνήμη.", "no_maintenance": "Δεν έχει καταγραφεί συντήρηση ακόμα.", "no_match_yet": "Καμία προσπάθεια αγώνα ακόμα - συμπληρώνεται κατά τη διάρκεια ενός κύκλου τρεξίματος.", @@ -713,7 +718,7 @@ "notify_services_hint": "Χρησιμοποιήστε αναγνωριστικά υπηρεσιών {entity} (χωρισμένα με κόμμα για πολλαπλά). Μεταβλητές προτύπου: {vars}.", "old_actions_warning": "Διαμορφώθηκε με το παλιό πρόγραμμα επεξεργασίας ενεργειών (τώρα καταργήθηκε). Εξακολουθούν να ενεργοποιούνται σε συμβάντα κύκλου, αλλά δεν είναι πλέον δυνατή η επεξεργασία τους εδώ. Μετατρέψτε τα σε κανονικό αυτοματισμό ή αφαιρέστε τα.", "onboarding_progress": "{n} / 3 κύκλοι παρατηρήθηκαν", - "onboarding_watching": "Αφήστε τη συσκευή σας να λειτουργεί κανονικά — το WashData παρακολουθεί. Μετά από 3 κύκλους, θα ξεκινήσει η αντιστοίχιση προγραμμάτων.", + "onboarding_watching": "Αφήστε τη συσκευή σας να λειτουργεί κανονικά – το WashData παρακολουθεί. Μετά από 3 κύκλους, θα ξεκινήσει η αντιστοίχιση προγραμμάτων.", "pending_feedback": "Εκκρεμεί σχόλια ανίχνευσης", "performance_trend": "Τάση απόδοσης ({n} κύκλοι)", "pg_analysis_empty": "Φορτώστε έναν κύκλο για να δείτε την ανάλυση αντιστοίχισης.", @@ -763,7 +768,7 @@ "setting_changed": "Άλλαξε από {old} σε {new} στις {date}", "settings_basic_note": "Εμφανίζονται οι βασικές ρυθμίσεις. Μεταβείτε στο Για προχωρημένους για την πλήρη λίστα.", "shape_drift_advisory": "⚠ Απόκλιση σχήματος", - "shape_drift_detail": "Το μοτίβο ισχύος αυτού του προφίλ έχει αλλάξει με την πάροδο του χρόνου — πιθανή φθορά της συσκευής ή απαιτείται συντήρηση (π.χ. αφαλάτωση, καθαρισμός φίλτρου).", + "shape_drift_detail": "Το μοτίβο ισχύος αυτού του προφίλ έχει αλλάξει με την πάροδο του χρόνου – πιθανή φθορά της συσκευής ή απαιτείται συντήρηση (π.χ. αφαλάτωση, καθαρισμός φίλτρου).", "show_all_settings": "Εμφάνιση όλων των ρυθμίσεων", "showing_suggestions": "Εμφάνιση ρύθμισης {count} με προτάσεις.", "split_intro": "Κάντε κλικ στο γράφημα για να προσθέσετε ή να αφαιρέσετε ένα σημείο διαίρεσης ή για αυτόματη ανίχνευση με κενά αδράνειας. Κάθε τμήμα που προκύπτει μπορεί να αποκτήσει το δικό του προφίλ.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Η επαναφορά απέτυχε: {error}", "toast_reverted": "Έγινε επαναφορά: {key}", "toast_save_failed": "Η αποθήκευση απέτυχε: {error}", - "trend_duration_longer": "Μεγαλύτερη τάση διάρκειας ({pct}%/κύκλος) — πρόσφατος μέσος όρος {avg}", - "trend_duration_shorter": "Μικρότερη τάση διάρκειας ({pct}%/κύκλος) — πρόσφατος μέσος όρος {avg}", + "trend_duration_longer": "Μεγαλύτερη τάση διάρκειας ({pct}%/κύκλος) – πρόσφατος μέσος όρος {avg}", + "trend_duration_shorter": "Μικρότερη τάση διάρκειας ({pct}%/κύκλος) – πρόσφατος μέσος όρος {avg}", "trend_energy_down": "Πτωτική τάση ενέργειας ({pct}%/κύκλος)", - "trend_energy_up": "Ανοδικές τάσεις ενέργειας ({pct}%/κύκλος) — πρόσφατος μέσος όρος {avg}", + "trend_energy_up": "Ανοδικές τάσεις ενέργειας ({pct}%/κύκλος) – πρόσφατος μέσος όρος {avg}", "trim_intro": "Σύρετε τις κόκκινες λαβές ή εισαγάγετε τιμές. Όλα έξω από το παράθυρο αφαιρούνται.", "tuning_suggestions_available": "{count} πρόταση συντονισμού διαθέσιμη από παρατηρούμενους κύκλους. Εμφανίζονται δίπλα στα σχετικά πεδία.", "unsure_detected_prefix": "Το WashData δεν είναι βέβαιο ότι εντόπισε", @@ -857,7 +862,9 @@ "share_guidelines_title": "Πριν κοινοποιήσετε", "store_download_device_intro": "Υιοθετήστε κάθε κοινοποιημένο πρόγραμμα και τους κύκλους αναφοράς του στη συσκευή σας. Οι δικοί σας καταγεγραμμένοι κύκλοι και στατιστικά δεν επηρεάζονται.", "store_share_device_intro": "Ανεβάστε {brand} {model} με τους κύκλους αναφοράς που επιλέγετε. Άλλοι με την ίδια συσκευή μπορούν να υιοθετήσουν τα προγράμματά σας. Οι καταχωρίσεις ελέγχονται πριν εμφανιστούν δημόσια.", - "share_profile_no_cycles": "Δεν υπάρχουν κύκλοι αναφοράς -- επισημάνετε έναν κύκλο ως ⭐ στην καρτέλα Κύκλοι για να συμπεριλάβετε αυτό το προφίλ" + "share_profile_no_cycles": "Δεν υπάρχουν κύκλοι αναφοράς -- επισημάνετε έναν κύκλο ως ⭐ στην καρτέλα Κύκλοι για να συμπεριλάβετε αυτό το προφίλ", + "advisory_phase_inconsistent": "Το '{name}' φαίνεται να αναμειγνύει διαφορετικά προγράμματα ή θερμοκρασίες - οι κύκλοι του θερμαίνονται για πολύ διαφορετικά χρονικά διαστήματα. Ο διαχωρισμός του σε ξεχωριστά προφίλ (π.χ. ανά θερμοκρασία) θα βελτιώσει την αντιστοίχιση και τις εκτιμήσεις χρόνου.", + "advisory_phase_inconsistent_title": "⚠ Πιθανώς μεικτά προγράμματα" }, "phase_desc": { "anti_crease": "Περιστασιακά σύντομες ανατροπές μετά την ολοκλήρωση για μείωση των ρυτίδων.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Προαιρετικά εξωτερικά σήματα: ενεργοποιητής τέλους, αισθητήρας πόρτας, διακόπτης παύσης και υπενθύμιση αποφόρτωσης.", "label": "Ενεργοποιητές & Πόρτες" + }, + "phase_eta": { + "label": "Υπολειπόμενος Χρόνος", + "intro": "Υπολειπόμενος χρόνος με επίγνωση φάσεων, για μηχανές των οποίων η διάρκεια κύκλου εξαρτάται από τη θερμοκρασία ή το στύψιμο." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Σταθερή τιμή ανά kWh που χρησιμοποιείται για τα στοιχεία κόστους όταν δεν έχει οριστεί παραπάνω οντότητα ζωντανής τιμής.", "label": "Στατική Τιμή Ενέργειας (ανά kWh)" }, + "energy_sensor": { + "doc": "Προαιρετικός αθροιστικός μετρητής ενέργειας (total_increasing kWh/Wh, π.χ. ο ίδιος ο μετρητής συνολικής κατανάλωσης της πρίζας). Όταν οριστεί, η αναφερόμενη ενέργεια κάθε κύκλου λαμβάνεται από τη διαφορά αυτού του μετρητή από την αρχή έως το τέλος, γεγονός που αποφεύγει την υποκαταμέτρηση που προκύπτει από την ολοκλήρωση ενός αισθητήρα ισχύος που αναφέρει αργά. Επανέρχεται στην ολοκληρωμένη τιμή αν η ένδειξη λείπει, η μονάδα της είναι άγνωστη ή η διαφορά δεν είναι θετική. Αφήστε το κενό για να συνεχιστεί η ολοκλήρωση του αισθητήρα ισχύος.", + "label": "Οντότητα Μετρητή Ενέργειας" + }, "expose_debug_entities": { "doc": "Δημοσιεύστε επιπλέον διαγνωστικές οντότητες HA (εμπιστοσύνη αντιστοίχισης, ασάφεια, εσωτερικά στοιχεία κατάστασης). Το Off διατηρεί τη λίστα οντοτήτων καθαρή για κανονική χρήση.", "label": "Εμφάνιση Οντοτήτων Αποσφαλμάτωσης" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Εμφάνιση της ένδειξης \"από <συνεισφέρων>\" στις κοινοτικές συσκευές και τους κύκλους αναφοράς." + }, + "enable_phase_matching": { + "label": "Υπολειπόμενος χρόνος με επίγνωση φάσεων", + "doc": "Χωρίστε κάθε κύκλο που εκτελείται σε φάσεις (θέρμανση, πλύση, στύψιμο) και κατανείμετε τον υπολειπόμενο χρόνο ανά φάση, συνδυασμένο με την κλασική εκτίμηση - στηριζόμενοι στην κατανομή ανά φάση στην αρχή του κύκλου και στην κλασική εκτίμηση κοντά στο τέλος. Αυτό προσαρμόζει την αντίστροφη μέτρηση στο πόση ώρα πραγματικά θερμαίνεται και λειτουργεί η μηχανή σας, κάτι που είναι πιο εμφανές στο πρώτο μισό ενός κύκλου. Off = μόνο η κλασική εκτίμηση. Επηρεάζεται μόνο η ένδειξη υπολειπόμενου χρόνου· η αντιστοίχιση προγραμμάτων και η ανίχνευση κύκλου παραμένουν αμετάβλητες." + }, + "keep_min_score": { + "label": "Ελάχ. Βαθμός Αντιστοίχισης", + "doc": "Ελάχιστη βαθμολογία ομοιότητας για παραμονή υποψηφίου στον αγώνα αντιστοίχισης. Η προεπιλογή 0.1 είναι εκ προθέσεως επιεικής - επιτρέπει ακόμη και αδύναμους υποψηφίους, αφήνοντας τα επόμενα στάδια να βρουν το καλύτερο ταίριασμα. Αυξήστε για πρώιμο αποκλεισμό απίθανων προφίλ; μειώστε για διεύρυνση της αρχικής ομάδας υποψηφίων." + }, + "dtw_blend": { + "label": "Ανάμειξη DTW", + "doc": "Σε ποιο βαθμό ο βαθμός ευθυγράμμισης DTW αντικαθιστά τον βαθμό πυρήνα Σταδίου 2. 0 = μόνο ο βαθμός Σταδίου 2, 1 = μόνο ο βαθμός DTW, 0.5 (προεπιλογή) = ισόρροπη ανάμειξη. Αυξήστε για μεγαλύτερη εξάρτηση από ευθυγράμμιση παραμόρφωσης όταν τα προγράμματα έχουν παρόμοια επίπεδα ισχύος αλλά διαφορετικά χρονικά πρότυπα." + }, + "dtw_ensemble_w": { + "label": "Σύνολο DTW", + "doc": "Στη λειτουργία συνόλου DTW (η προεπιλογή), το βάρος στο κλιμακωμένο L1 έναντι παραγώγου DTW (DDTW). 1.0 = πλήρως κλιμακωμένο-L1, 0 = πλήρως DDTW, 0.7 = προεπιλογή. Η παραλλαγή κλιμακωμένου-L1 συγκρίνει επίπεδα ισχύος; η παραγώγου παραλλαγή ανταποκρίνεται στο πώς μεταβάλλεται η ισχύς. Το σύνολο συνδυάζει και τα δύο." + }, + "dtw_ddtw_scale": { + "label": "Κλίμακα DDTW", + "doc": "Απόσταση ημίσεος κορεσμού για τη βαθμολόγηση παραγώγου DTW. Η βαθμολογία υποδιπλασιάζεται όταν η απόσταση DDTW ισούται με αυτήν την τιμή. Μικρότερη = μεγαλύτερη ευαισθησία σε διαφορές σχήματος. Η προεπιλογή 30 είναι βαθμονομημένη στα τυπικά ίχνη ισχύος οικιακών συσκευών. Επηρεάζει μόνο τις λειτουργίες DTW συνόλου και ddtw." + }, + "dtw_refine_top_n": { + "label": "Αριθμός Εκλέπτυνσης DTW", + "doc": "Πόσοι κορυφαίοι υποψήφιοι Σταδίου 2 επαναβαθμολογούνται από DTW. Η DTW είναι πιο ακριβή υπολογιστικά, οπότε εφαρμόζεται μόνο στους καλύτερους N υποψηφίους. Προεπιλογή 5. Αυξήστε (σε 7-9) αν το σωστό προφίλ φτάνει μερικές φορές μόνο στη 4η ή 5η θέση μετά το Στάδιο 2 - η DTW μπορεί να το σώσει. Η μείωση επιταχύνει ελαφρώς την αντιστοίχιση." + }, + "duration_scale": { + "label": "Ευαισθησία Διάρκειας", + "doc": "Αυστηρότητα της ποινής συμφωνίας διάρκειας Σταδίου 4. Αυτός είναι ο λόγος λογαρίθμου στον οποίο ο βαθμός συμφωνίας υποδιπλασιάζεται. Μικρότερος = αυστηρότερος: μια ασυμφωνία διάρκειας επηρεάζει περισσότερο. Η προεπιλογή 0.175 αντιστοιχεί σε ανοχή διάρκειας περίπου 18% στο μισό βάρος. Χρησιμοποιήστε μαζί με το Βάρος Διάρκειας." + }, + "energy_scale": { + "label": "Ευαισθησία Ενέργειας", + "doc": "Αυστηρότητα της ποινής συμφωνίας ενέργειας Σταδίου 4. Μικρότερος = αυστηρότερος: μια ασυμφωνία ενέργειας επηρεάζει περισσότερο. Η προεπιλογή 0.25 είναι εκ προθέσεως πιο επιεικής από την Ευαισθησία Διάρκειας επειδή η ενέργεια ποικίλλει με το φορτίο. Χρησιμοποιήστε μαζί με το Βάρος Ενέργειας." } }, "setting_group": { @@ -1553,24 +1600,24 @@ "lower": "Επισημαίνει αυτόματα περισσότερους κύκλους; κάποιοι ενδέχεται να αναγνωριστούν λανθασμένα" }, "duration_tolerance": { - "higher": "Αποδέχεται ευρύτερο εύρος διάρκειας — πιο ευέλικτη αντιστοίχιση", - "lower": "Απαιτεί πιο ακριβή αντιστοίχιση διάρκειας — αυστηρότερο, μπορεί να απορρίψει ασυνήθεις κύκλους" + "higher": "Αποδέχεται ευρύτερο εύρος διάρκειας – πιο ευέλικτη αντιστοίχιση", + "lower": "Απαιτεί πιο ακριβή αντιστοίχιση διάρκειας – αυστηρότερο, μπορεί να απορρίψει ασυνήθεις κύκλους" }, "end_energy_threshold": { "higher": "Κλείνει μόνο κύκλους που κατανάλωσαν σημαντική ενέργεια", "lower": "Κλείνει και συντομότερους ή χαμηλής ενέργειας κύκλους" }, "end_repeat_count": { - "higher": "Απαιτεί επανάληψη του σήματος λήξης περισσότερες φορές — πιο αξιόπιστο, ελαφρώς πιο αργό", - "lower": "Τερματίζει μετά από λιγότερες επαναλήψεις — ταχύτερη ανίχνευση, μεγαλύτερος κίνδυνος ψευδούς λήξης" + "higher": "Απαιτεί επανάληψη του σήματος λήξης περισσότερες φορές – πιο αξιόπιστο, ελαφρώς πιο αργό", + "lower": "Τερματίζει μετά από λιγότερες επαναλήψεις – ταχύτερη ανίχνευση, μεγαλύτερος κίνδυνος ψευδούς λήξης" }, "learning_confidence": { - "higher": "Μαθαίνει μόνο από πολύ σίγουρες αντιστοιχίσεις — πιο αργό αλλά πιο αξιόπιστο", + "higher": "Μαθαίνει μόνο από πολύ σίγουρες αντιστοιχίσεις – πιο αργό αλλά πιο αξιόπιστο", "lower": "Μαθαίνει από περισσότερους κύκλους; το μοντέλο μπορεί να έχει περισσότερο θόρυβο" }, "min_off_gap": { "higher": "Απαιτείται μεγαλύτερη αδράνεια πριν ξεκινήσει νέος κύκλος", - "lower": "Σύντομη αδράνεια εκκινεί νέο κύκλο — διαδοχικές λειτουργίες μπορεί να διαχωριστούν" + "lower": "Σύντομη αδράνεια εκκινεί νέο κύκλο – διαδοχικές λειτουργίες μπορεί να διαχωριστούν" }, "min_power": { "higher": "Λιγότερο ευαίσθητο στην κατανάλωση αδράνειας; μπορεί να χάσει ήσυχα προγράμματα", @@ -1581,24 +1628,24 @@ "lower": "Αναγκαστική διακοπή κολλημένου κύκλου νωρίτερα" }, "off_delay": { - "higher": "Περισσότερος χρόνος για παύσεις συσκευής — χειρίζεται μακρά εμποτίσματα ή φάσεις στεγνώματος", - "lower": "Ταχύτερη ανίχνευση λήξης — καλό για συσκευές με καθαρό ενεργό/ανενεργό" + "higher": "Περισσότερος χρόνος για παύσεις συσκευής – χειρίζεται μακρά εμποτίσματα ή φάσεις στεγνώματος", + "lower": "Ταχύτερη ανίχνευση λήξης – καλό για συσκευές με καθαρό ενεργό/ανενεργό" }, "profile_match_threshold": { "higher": "Επιβεβαιώνει μόνο αντιστοιχίσεις υψηλής εμπιστοσύνης; περισσότεροι κύκλοι παραμένουν χωρίς ετικέτα", "lower": "Αντιστοιχεί περισσότερους κύκλους; κάποιοι μπορεί να είναι ελαφρώς λανθασμένοι" }, "running_dead_zone": { - "higher": "Αγνοεί σύντομες αιχμές ισχύος κατά την αδράνεια — λιγότερο ευαίσθητο σε θόρυβο", - "lower": "Ανταποκρίνεται σε συντομότερες αναλαμπές ισχύος — εντοπίζει γρήγορη παροδική δραστηριότητα" + "higher": "Αγνοεί σύντομες αιχμές ισχύος κατά την αδράνεια – λιγότερο ευαίσθητο σε θόρυβο", + "lower": "Ανταποκρίνεται σε συντομότερες αναλαμπές ισχύος – εντοπίζει γρήγορη παροδική δραστηριότητα" }, "start_threshold_w": { "higher": "Αποφεύγει ψευδείς εκκινήσεις από σύντομες αιχμές ισχύος", "lower": "Εντοπίζει προγράμματα χαμηλής ισχύος; μπορεί να ενεργοποιηθεί από θόρυβο" }, "stop_threshold_w": { - "higher": "Τερματίζει τον κύκλο γρηγορότερα — μπορεί να κλείσει κατά τη διάρκεια παύσης", - "lower": "Αναμένει περισσότερο για τερματισμό — λιγότερες πρόωρες ολοκληρώσεις" + "higher": "Τερματίζει τον κύκλο γρηγορότερα – μπορεί να κλείσει κατά τη διάρκεια παύσης", + "lower": "Αναμένει περισσότερο για τερματισμό – λιγότερες πρόωρες ολοκληρώσεις" }, "watchdog_interval": { "higher": "Επιτρέπονται μακρύτερες ήσυχες φάσεις; λιγότερες ψευδείς αναγκαστικές διακοπές", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Δευτερόλεπτα πάνω από το κατώφλι για επιβεβαίωση εκκίνησης", "start_threshold_w": "Ελάχιστα watt για να θεωρηθεί ότι ξεκίνησε", "stop_threshold_w": "Κάτω από αυτό, το μηχάνημα θεωρείται εκτός λειτουργίας", - "dtw_bandwidth": "Πόση χρονική παραμόρφωση επιτρέπει η αντιστοίχιση σχήματος", - "corr_weight": "Βάρος του σχήματος καμπύλης έναντι του επιπέδου ισχύος στην αντιστοίχιση", - "duration_weight": "Πόσο επηρεάζει η συμφωνία διάρκειας την αντιστοίχιση", - "energy_weight": "Πόσο επηρεάζει η συμφωνία ενέργειας την αντιστοίχιση", - "profile_match_min_duration_ratio": "Ο συντομότερος κύκλος (σε σχέση με το προφίλ) που επιτρέπεται ακόμη να ταιριάξει", - "profile_match_max_duration_ratio": "Ο μακρύτερος κύκλος (σε σχέση με το προφίλ) που επιτρέπεται ακόμη να ταιριάξει" + "dtw_bandwidth": "Στάδιο 3: ζώνη παραμόρφωσης Sakoe-Chiba (0 = DTW ανενεργό; προεπιλογή 0.2 = 20% μήκους κύκλου)", + "corr_weight": "Στάδιο 2: ισορροπία μεταξύ σχήματος καμπύλης (συσχέτιση) και επιπέδου ισχύος (MAE); προεπιλογή 0.45", + "duration_weight": "Στάδιο 4: πόσο επηρεάζει η συμφωνία διάρκειας τον τελικό βαθμό (προεπιλογή 0.22)", + "energy_weight": "Στάδιο 4: πόσο επηρεάζει η συμφωνία ενέργειας τον τελικό βαθμό (προεπιλογή 0.22)", + "profile_match_min_duration_ratio": "Στάδιο 1: ελάχιστο μήκος κύκλου ως κλάσμα της διάρκειας προφίλ; προεπιλογή 0.1 (10%)", + "profile_match_max_duration_ratio": "Στάδιο 1: μέγιστο μήκος κύκλου ως κλάσμα της διάρκειας προφίλ; προεπιλογή 1.5 (150%)", + "keep_min_score": "Στάδιο 2: κατώτατο όριο βαθμολογίας για παραμονή στον αγώνα; προεπιλογή 0.1 επιτρέπει αδύναμα ταιριάσματα, τα επόμενα στάδια επιλέγουν το καλύτερο", + "dtw_blend": "Στάδιο 3: 0 = μόνο βαθμός πυρήνα, 1 = μόνο βαθμός DTW, 0.5 = ισόρροπη ανάμειξη (προεπιλογή)", + "dtw_ensemble_w": "Στάδιο 3 (λειτουργία συνόλου): βάρος στο κλιμακωμένο-L1 έναντι παραγώγου DTW; προεπιλογή 0.7 ευνοεί το επίπεδο ισχύος", + "dtw_ddtw_scale": "Στάδιο 3: απόσταση ημίσεος κορεσμού DDTW; μικρότερη = μεγαλύτερη ευαισθησία σχήματος (προεπιλογή 30)", + "dtw_refine_top_n": "Στάδιο 3: υποψήφιοι που επαναβαθμολογεί η DTW; αυξήστε σε 7-9 αν το σωστό προφίλ βρίσκεται στη 4η-5η θέση (προεπιλογή 5)", + "duration_scale": "Στάδιο 4: λόγος λογαρίθμου όπου η συμφωνία διάρκειας υποδιπλασιάζεται; μικρότερος = αυστηρότερη ποινή (προεπιλογή 0.175)", + "energy_scale": "Στάδιο 4: λόγος λογαρίθμου όπου η συμφωνία ενέργειας υποδιπλασιάζεται; μικρότερος = αυστηρότερη ποινή (προεπιλογή 0.25)" }, "col": { "profile_tip": "Όνομα αντιστοιχισμένου προγράμματος. Χωρίς ετικέτα σημαίνει ότι κανένα προφίλ δεν αντιστοιχίστηκε στο τέλος του κύκλου.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Βελτιστοποίηση: {param}" + }, + "pg_detail": { + "simulate": "Προσομοίωση κύκλου" + }, + "split": { + "apply": "Διαχωρισμός κύκλου" + }, + "trim": { + "apply": "Περικοπή κύκλου" + }, + "merge": { + "apply": "Συγχώνευση κύκλων" + }, + "rebuild": { + "envelopes": "Ανακατασκευή φακέλων" + }, + "cancelling": "Ακύρωση..." + }, + "setup": { + "hdr": { + "card": "Ρύθμιση συσκευής", + "healthy_chip": "Ρύθμιση ολοκληρώθηκε" + }, + "phase0": { + "washer": "Το WashData ανιχνεύει ήδη τους κύκλους σας. Εγγράψτε τον πρώτο κύκλο για να ενεργοποιήσετε ονόματα προγραμμάτων και εκτιμήσεις χρόνου.", + "dishwasher": "Το WashData παρακολουθεί. Τα πλυντήρια πιάτων έχουν σύνθετους κύκλους – συνιστάται ανεπιφύλακτα η εγγραφή του πρώτου κύκλου. Αν ένας ανιχνευθείς κύκλος διαρκεί πολύ, χρησιμοποιήστε το πρόγραμμα επεξεργασίας κύκλων για να τον περικόψετε πριν αποθηκευτεί ως προφίλ.", + "generic": "Το WashData παρακολουθεί. Εγγράψτε ή επισημάνετε έναν ανιχνευθέντα κύκλο για να ξεκινήσετε να δημιουργείτε προφίλ." + }, + "phase1a": { + "labelled": "Καλή αρχή – το πρώτο πρόγραμμά σας αποθηκεύτηκε. Για τα πιο καθαρά δεδομένα, σκεφτείτε να εγγράψετε τον επόμενο κύκλο με το widget εγγραφής." + }, + "phase1b": { + "recorded": "Η εγγραφή σας αποθηκεύτηκε ως {profile_name}. Τώρα εγγράψτε ή επισημάνετε τα άλλα συνηθισμένα προγράμματά σας για να διευρύνετε την κάλυψη." + }, + "phase1c": { + "verify": "Έχετε {count} προγράμματα από την κοινότητα. Εκτελέστε έναν κύκλο για να επαληθεύσετε ότι το WashData τα αναγνωρίζει σωστά – η αντιστοίχιση θα βελτιώνεται καθώς η συσκευή σας δημιουργεί το δικό της ιστορικό." + }, + "phase2": { + "cluster": "Το WashData έχει δει {count} κύκλους που δεν αντιστοιχούν σε κανένα αποθηκευμένο πρόγραμμα – μοιάζουν μεταξύ τους. Θέλετε να δημιουργήσετε ένα νέο προφίλ για αυτούς;", + "unmatched": "Ο τελευταίος κύκλος δεν αντιστοιχεί σε κανένα αποθηκευμένο πρόγραμμα. Είναι νέο πρόγραμμα;" + }, + "phase3": { + "suggestions": "Το WashData έχει προτάσεις ρυθμίσεων βάσει του ιστορικού κύκλων σας – ελέγξτε τις για βελτίωση της ακρίβειας ανίχνευσης.", + "groups": "Μερικά από τα προφίλ σας μοιάζουν με το ίδιο πρόγραμμα σε διαφορετικές θερμοκρασίες. Οργανώστε τα σε μια ομάδα για καλύτερη αντιστοίχιση.", + "phases": "Προσθέστε φάσεις προγράμματος στο {profile_name} για πιο ακριβείς εκτιμήσεις χρόνου που απομένει." + }, + "phase4": { + "healthy": "Αυτή η συσκευή έχει ρυθμιστεί πλήρως ({profile_count} προφίλ)." + }, + "cta": { + "start_recording": "Έναρξη εγγραφής", + "label_detected_cycle": "Έχω ήδη ανιχνευθέντα κύκλο – επισήμανσή του", + "browse_cycles": "Αναζήτηση κύκλων για επισήμανση άλλου", + "view_profiles": "Προβολή προφίλ", + "create_from_cluster": "Δημιουργία προφίλ από αυτούς τους κύκλους", + "create_profile": "Δημιουργία προφίλ για αυτόν", + "review_suggestions": "Έλεγχος προτάσεων", + "organise_profiles": "Οργάνωση προφίλ", + "configure_phases": "Διαμόρφωση φάσεων", + "skip_step": "Παράλειψη βήματος", + "skip_forever": "Να μην εμφανιστεί ξανά", + "hide_guidance": "Απόκρυψη οδηγιών", + "show_guidance": "Εμφάνιση οδηγιών" } } } diff --git a/custom_components/ha_washdata/translations/panel/en.json b/custom_components/ha_washdata/translations/panel/en.json index 6c25f18..fc863bd 100644 --- a/custom_components/ha_washdata/translations/panel/en.json +++ b/custom_components/ha_washdata/translations/panel/en.json @@ -1,11 +1,11 @@ { "badge": { - "artifact_tip": "{n} anomalies detected (e.g. door opened mid-cycle) — open to see them on the graph", + "artifact_tip": "{n} anomalies detected (e.g. door opened mid-cycle) – open to see them on the graph", "auto": "(auto-detected)", "built_in_tag": "built-in", "declining": "↘ declining", "fair_fit": "acceptable quality", - "fair_fit_tip": "Moderate match consistency — some cycles assigned to this profile have lower confidence scores. Label more cycles or re-record the profile to improve accuracy.", + "fair_fit_tip": "Moderate match consistency – some cycles assigned to this profile have lower confidence scores. Label more cycles or re-record the profile to improve accuracy.", "feedback_requested": "Feedback requested", "golden_cycle": "Recorded reference cycle", "improving": "↗ improving", @@ -16,7 +16,7 @@ "energy_spike": "Higher energy than usual", "energy_low": "Lower energy than usual", "poor_fit": "poor quality", - "poor_fit_tip": "Inconsistent match history — consider rebuilding this profile", + "poor_fit_tip": "Inconsistent match history – consider rebuilding this profile", "restart_gap_tip": "{n} HA restart gap: power trace has a hole", "reviewed": "Reviewed", "steady": "→ steady", @@ -87,7 +87,7 @@ "on_cycle_finished": "On cycle finished", "on_cycle_started": "On cycle started", "pause_cycle": "Pause", - "pause_cycle_tip": "Pause the running cycle — the appliance will resume where it left off", + "pause_cycle_tip": "Pause the running cycle – the appliance will resume where it left off", "pg_apply_device": "Apply to device", "pg_autofill": "Auto-fill", "play": "Play", @@ -97,8 +97,8 @@ "process_tip": "Save the recorded trace as a new or existing profile", "rebuild": "Rebuild Envelopes", "rebuild_envelope": "Rebuild Envelope", - "rebuild_tip": "Recompute the expected power envelope (min/max band) for all profiles from their labelled cycles — run after labelling new cycles or correcting old ones", - "rec_start_tip": "Begin recording the appliance's power trace — start just before running a cycle", + "rebuild_tip": "Recompute the expected power envelope (min/max band) for all profiles from their labelled cycles – run after labelling new cycles or correcting old ones", + "rec_start_tip": "Begin recording the appliance's power trace – start just before running a cycle", "rec_stop": "Stop", "rec_stop_tip": "Stop recording and hold the captured trace for review", "record": "Start Recording", @@ -181,7 +181,7 @@ }, "attn_sub": "Fix conflicts before saving", "attn_title": "{n} setting conflict{s}", - "settings_banner": "{n} setting conflict{s} — check the highlighted sections and fix before saving.", + "settings_banner": "{n} setting conflict{s} – check the highlighted sections and fix before saving.", "settings_banner_btn": "Go to first", "confidence": { "auto": "Must be at or above Match Threshold ({match})", @@ -448,9 +448,9 @@ "show_expected": "Show expected curve overlay (matched profile, orange)", "show_raw": "Show raw socket toggle in live power graph", "sparkline": "Recent cycle-duration trend", - "stage2": "Stage 2 — core similarity", - "stage3": "Stage 3 — DTW", - "stage4": "Stage 4 — agreement", + "stage2": "Stage 2 – core similarity", + "stage3": "Stage 3 – DTW", + "stage4": "Stage 4 – agreement", "status": "Status", "tail_trim": "Tail Trim (s)", "timer_auto_pause": "Auto-pause", @@ -495,6 +495,11 @@ "pg_alert_indefinite": "Would run indefinitely", "task_pg_history": "Test on history", "task_pg_sweep": "Optimize", + "task_pg_detail": "Simulate cycle", + "task_split": "Splitting cycle", + "task_trim": "Trimming cycle", + "task_merge": "Merging cycles", + "task_rebuild": "Rebuilding envelopes", "task_reprocess": "Reprocessing", "task_ml_training": "Learning", "eta_secs": "~{n}s left", @@ -540,7 +545,7 @@ "personalized": "● Personalized to this machine", "better_than_baseline": "{pct}% better than the baseline estimate ({metric})", "trend_improving_tip": "This model's fit has improved across recent re-checks.", - "trend_declining_tip": "This model's fit has slipped across recent re-checks — reviewing more cycles may help it re-learn.", + "trend_declining_tip": "This model's fit has slipped across recent re-checks – reviewing more cycles may help it re-learn.", "trend_steady_tip": "This model's fit has held roughly steady across recent re-checks.", "cap_label": { "end": "Cycle-end detection", @@ -605,19 +610,19 @@ "artifact_dip_detail": "Dropped below the usual power band for ~{n}s.", "artifact_footer": "Highlighted on the graph above. These are transient artifacts (e.g. the door opened mid-cycle), not necessarily problems.", "artifact_header": "{n} anomalies detected during this cycle", - "artifact_pause_detail": "Power dropped to near zero for ~{n}s then resumed — likely the door was opened mid-cycle or the cycle was paused.", + "artifact_pause_detail": "Power dropped to near zero for ~{n}s then resumed – likely the door was opened mid-cycle or the cycle was paused.", "artifact_spike_detail": "Drew above the usual power band for ~{n}s.", "auto_label_intro": "Assign profiles to unlabelled cycles whose match confidence clears the threshold.", "automations_intro": "WashData fires {start} / {end} events and exposes entities, so notifications and actions are best built as normal Home Assistant automations. Automations that use this device appear below.", "cleanup_intro": "Every labelled cycle overlaid. Tick outliers and delete to clean up the profile.", "clear_debug_hint": "Remove stored debug data to free space.", - "collecting_data": "Collecting data — {need} more cycle{plural} before fine-tuning can start ({current}/{min}).", + "collecting_data": "Collecting data – {need} more cycle{plural} before fine-tuning can start ({current}/{min}).", "compare_overlay_profiles": "Overlay profiles (faint)", "compare_profiles_tip": "Overlay other profile envelopes on the chart above to see which one best fits this cycle.", - "compare_selected_cycles": "Selected cycles (solid) — show / hide", + "compare_selected_cycles": "Selected cycles (solid) – show / hide", "consider_new_profile": "Consider creating a new profile.", "coverage_gap": "recent cycles have no matching profile ({pct}% of last 30).", - "coverage_gap_similar_cycles": "{count} similar unlabelled cycles found — create a profile to start matching them.", + "coverage_gap_similar_cycles": "{count} similar unlabelled cycles found – create a profile to start matching them.", "cycles_deleted": "{count} cycle(s) deleted", "enough_data": "Enough data to learn from ({current}/{min} cycles).", "export_description": "Export all profiles and cycles to JSON, or restore from a previous export.", @@ -646,7 +651,7 @@ "ml_learned_intro": "Models fine-tuned to this machine.", "ml_loading": "loading ML…", "ml_settings_intro": "Two independent switches: one applies the models while a cycle runs, the other lets WashData fine-tune them to your machine over time.", - "name_first_program": "You have enough cycles — name your first program to start matching.", + "name_first_program": "You have enough cycles – name your first program to start matching.", "near_duplicate_cluster": "near-duplicate profile cluster detected. Grouping lets matching reliably pick between look-alikes (e.g. same program at different temperature/spin).", "no_cycles_match": "No cycles match the current filter.", "no_cycles_profile": "No cycles for this profile.", @@ -658,7 +663,7 @@ "no_devices": "No WashData devices configured yet.", "no_envelope": "No envelope yet - rebuild after labelling cycles.", "no_envelope_overlay": "No envelope available to overlay.", - "no_fine_tuned": "Nothing fine-tuned yet — WashData is using its built-in models.", + "no_fine_tuned": "Nothing fine-tuned yet – WashData is using its built-in models.", "no_logs": "No log records buffered yet.", "no_maintenance": "No maintenance recorded yet.", "no_match_yet": "No match attempt yet - this populates during a running cycle.", @@ -675,7 +680,7 @@ "notify_services_hint": "Use {entity} service IDs (comma-separated for multiple). Template variables: {vars}.", "old_actions_warning": "Configured with the old actions editor (now removed). They still fire on cycle events but can no longer be edited here. Convert them into a normal automation, or remove them.", "onboarding_progress": "{n} / 3 cycles observed", - "onboarding_watching": "Run your appliance normally — WashData is watching. After 3 cycles, program matching will begin.", + "onboarding_watching": "Run your appliance normally – WashData is watching. After 3 cycles, program matching will begin.", "pending_feedback": "Pending detection feedback", "performance_trend": "Performance trend ({n} cycles)", "pg_analysis_empty": "Load a cycle to see match analysis.", @@ -725,7 +730,7 @@ "setting_changed": "Changed from {old} to {new} on {date}", "settings_basic_note": "Showing essential settings. Switch to Advanced for the full list.", "shape_drift_advisory": "⚠ Shape drifting", - "shape_drift_detail": "The power pattern for this profile has shifted over time — possible appliance wear or maintenance needed (e.g. descaling, filter cleaning).", + "shape_drift_detail": "The power pattern for this profile has shifted over time – possible appliance wear or maintenance needed (e.g. descaling, filter cleaning).", "show_all_settings": "Show all settings", "showing_suggestions": "Showing {count} setting with suggestions.", "split_intro": "Click the graph to add or remove a split point, or auto-detect by idle gaps. Each resulting segment can get its own profile.", @@ -753,10 +758,10 @@ "toast_revert_failed": "Revert failed: {error}", "toast_reverted": "{key} reverted", "toast_save_failed": "Save failed: {error}", - "trend_duration_longer": "Duration trending longer ({pct}%/cycle) — recent avg {avg}", - "trend_duration_shorter": "Duration trending shorter ({pct}%/cycle) — recent avg {avg}", + "trend_duration_longer": "Duration trending longer ({pct}%/cycle) – recent avg {avg}", + "trend_duration_shorter": "Duration trending shorter ({pct}%/cycle) – recent avg {avg}", "trend_energy_down": "Energy trending down ({pct}%/cycle)", - "trend_energy_up": "Energy trending up ({pct}%/cycle) — recent avg {avg}", + "trend_energy_up": "Energy trending up ({pct}%/cycle) – recent avg {avg}", "trim_intro": "Drag the red handles, or enter values. Everything outside the window is removed.", "tuning_suggestions_available": "{count} tuning suggestion available from observed cycles. They appear beside the relevant fields.", "unsure_detected_prefix": "WashData is unsure it detected", @@ -774,12 +779,14 @@ "pg_sweep_progress": "Step {done} / {total}", "pg_analysis_hint": "Select a cycle and click ↺ to load analysis.", "diagnostics_load_failed": "Could not load diagnostics: {error}", - "manual_duration_ref_hint": "Only used when no reference cycle is selected — a reference cycle sets the duration from its own length.", + "manual_duration_ref_hint": "Only used when no reference cycle is selected – a reference cycle sets the duration from its own length.", "advisory_poor_health": "'{name}' has a low fit score - its recent cycles vary a lot or match weakly. Review its cycles or re-record the profile so matching and time estimates stay accurate.", "advisory_shape_drift": "'{name}' has drifted significantly from its original power shape. The appliance may have changed behaviour over time (e.g. limescale, wear). Consider re-recording this profile with recent cycles.", "advisory_shape_drift_corr": "'{name}' has drifted significantly from its original power shape (correlation {corr}). The appliance may have changed behaviour over time (e.g. limescale, wear). Consider re-recording this profile with recent cycles.", "advisory_duration_trend_up": "'{name}' cycles are running progressively longer (about +{pct}% per cycle). If the appliance's behaviour changed, re-record or rebuild this profile.", "advisory_energy_trend_up": "'{name}' is drawing progressively more energy (about +{pct}% per cycle) - worth checking the appliance if that is unexpected.", + "advisory_phase_inconsistent": "'{name}' looks like it mixes different programs or temperatures - its cycles heat for very different lengths of time. Splitting it into separate profiles (e.g. per temperature) will improve matching and time estimates.", + "advisory_phase_inconsistent_title": "⚠ Possibly mixed programs", "pg_running_history": "Replaying your cycles…", "pg_running_sweep": "Testing values across your cycles…", "pg_row_load_hint": "Load this cycle in the graph above", @@ -810,7 +817,7 @@ "share_guideline_quality": "Only share cycles that completed normally -- no mid-cycle interruptions, door-open events, or power blips.", "share_guideline_review": "Your upload starts as pending and appears publicly once enough community members confirm it.", "share_consent": "I confirm these cycles ran to normal completion without interruption", - "share_profile_no_cycles": "No reference cycles — mark a cycle as ⭐ in the Cycles tab to include this profile" + "share_profile_no_cycles": "No reference cycles – mark a cycle as ⭐ in the Cycles tab to include this profile" }, "pg_desc": { "abrupt_drop_watts": "Sudden drop treated as immediate end", @@ -822,12 +829,19 @@ "start_duration_threshold": "Seconds above threshold to confirm start", "start_threshold_w": "Minimum watts to count as started", "stop_threshold_w": "Below this, machine counts as off", - "dtw_bandwidth": "How much time-warping the shape match allows", - "corr_weight": "Weight on curve-shape vs power level in the match", - "duration_weight": "How much run-length agreement affects the match", - "energy_weight": "How much energy agreement affects the match", - "profile_match_min_duration_ratio": "Shortest run (vs the profile) still allowed to match", - "profile_match_max_duration_ratio": "Longest run (vs the profile) still allowed to match" + "dtw_bandwidth": "Stage 3: Sakoe-Chiba warp band (0 = DTW off; default 0.2 = 20% of cycle length)", + "corr_weight": "Stage 2: balance between curve shape (correlation) and power level (MAE); default 0.45", + "duration_weight": "Stage 4: how strongly run-length agreement affects the final score (default 0.22)", + "energy_weight": "Stage 4: how strongly energy agreement affects the final score (default 0.22)", + "profile_match_min_duration_ratio": "Stage 1: minimum cycle length as a fraction of profile duration; default 0.1 (10%)", + "profile_match_max_duration_ratio": "Stage 1: maximum cycle length as a fraction of profile duration; default 1.5 (150%)", + "keep_min_score": "Stage 2: floor score to stay in the race; default 0.1 admits weak matches, later stages pick the best", + "dtw_blend": "Stage 3: 0 = core score only, 1 = DTW score only, 0.5 = equal blend (default)", + "dtw_ensemble_w": "Stage 3 (ensemble mode): weight on scaled-L1 vs derivative DTW; default 0.7 favours level-aware", + "dtw_ddtw_scale": "Stage 3: DDTW half-saturation distance; smaller = more shape-sensitive (default 30)", + "dtw_refine_top_n": "Stage 3: candidates DTW re-scores; raise to 7-9 if correct profile ranks 4th-5th (default 5)", + "duration_scale": "Stage 4: log-ratio where duration agreement halves; smaller = stricter penalty (default 0.175)", + "energy_scale": "Stage 4: log-ratio where energy agreement halves; smaller = stricter penalty (default 0.25)" }, "phase_desc": { "anti_crease": "Occasional short tumbles after completion to reduce wrinkles.", @@ -910,6 +924,10 @@ "triggers": { "intro": "Optional external signals: an end trigger, a door sensor, a pause switch, and the unload reminder.", "label": "Triggers & Doors" + }, + "phase_eta": { + "intro": "Phase-aware time-remaining, for machines whose cycle length depends on temperature or spin.", + "label": "Time Remaining" } }, "setting": { @@ -993,6 +1011,10 @@ "doc": "Fixed price per kWh used for cost figures when no live price entity is set above.", "label": "Static Energy Price (per kWh)" }, + "energy_sensor": { + "doc": "Optional cumulative energy counter (total_increasing kWh/Wh, e.g. the plug's own lifetime meter). When set, each cycle's reported energy is taken from this counter's start-to-end delta, which avoids the under-counting you get from integrating a slow-reporting power sensor. Falls back to the integrated value if the reading is missing, its unit is unknown, or the delta is not positive. Leave blank to keep integrating the power sensor.", + "label": "Energy Meter Entity" + }, "expose_debug_entities": { "doc": "Publish extra diagnostic HA entities (match confidence, ambiguity, state internals). Off keeps the entity list clean for normal use.", "label": "Expose Debug Entities" @@ -1275,6 +1297,38 @@ }, "show_contributor": { "doc": "Show the \"by \" attribution on community appliances and reference cycles." + }, + "enable_phase_matching": { + "doc": "Break each running cycle into phases (heating, wash, spin) and budget the time remaining per phase, blended with the classic estimate - leaning on the phase budget early in the cycle and the classic estimate near the end. This personalises the countdown to how long your machine actually heats and runs, which is most noticeable in the first half of a cycle. Off = the classic estimate only. Only the time-remaining display is affected; program matching and cycle detection are unchanged.", + "label": "Phase-aware time remaining" + }, + "keep_min_score": { + "label": "Min Match Score", + "doc": "Minimum similarity score a candidate needs to stay in the match race. The default 0.1 is deliberately permissive - it admits even weak candidates and relies on later stages to find the best match. Raise it to prune unlikely profiles earlier; lower it to widen the initial candidate pool." + }, + "dtw_blend": { + "label": "Warp Blend", + "doc": "How much the time-warp (DTW) alignment score replaces the Stage 2 core score. 0 = use only the Stage 2 score, 1 = use only the DTW score, 0.5 (default) = equal blend. Raise it to rely more on warp alignment when programs have similar power levels but different timing patterns." + }, + "dtw_ensemble_w": { + "label": "Warp Ensemble Mix", + "doc": "In ensemble DTW mode (the default), the weight on scaled L1 versus derivative DTW (DDTW). 1.0 = all scaled-L1, 0 = all DDTW, 0.7 = default. The scaled-L1 variant compares power levels; the derivative variant reacts to how power changes over time. Ensemble blends both." + }, + "dtw_ddtw_scale": { + "label": "Derivative Warp Scale", + "doc": "Half-saturation distance for derivative DTW scoring. The score halves when the DDTW distance equals this value. Smaller = more sensitive to shape differences. Default 30 is calibrated to typical appliance power traces. Only affects ensemble and ddtw DTW modes." + }, + "dtw_refine_top_n": { + "label": "Warp Refine Count", + "doc": "How many top Stage 2 candidates are re-scored by DTW. DTW is more expensive so it is only applied to the best N candidates. Default 5. Raise it (to 7-9) if the correct profile sometimes only reaches 4th or 5th place after Stage 2 - DTW can rescue it. Lowering speeds up matching slightly." + }, + "duration_scale": { + "label": "Duration Sharpness", + "doc": "Sharpness of the Stage 4 duration agreement penalty. This is the log-ratio at which the agreement score halves. Smaller = stricter: a duration mismatch hurts more. Default 0.175 corresponds to roughly 18% duration tolerance at half-weight. Pair with Duration Weight." + }, + "energy_scale": { + "label": "Energy Sharpness", + "doc": "Sharpness of the Stage 4 energy agreement penalty. Smaller = stricter: an energy mismatch hurts more. Default 0.25 is intentionally more forgiving than Duration Sharpness because energy varies with load. Pair with Energy Weight." } }, "setting_group": { @@ -1394,24 +1448,24 @@ "lower": "Labels more cycles automatically; some may be misidentified" }, "duration_tolerance": { - "higher": "Accepts a wider duration range — more flexible matching", - "lower": "Requires closer duration match — stricter but may reject unusual cycles" + "higher": "Accepts a wider duration range – more flexible matching", + "lower": "Requires closer duration match – stricter but may reject unusual cycles" }, "end_energy_threshold": { "higher": "Closes only cycles that used substantial energy", "lower": "Closes shorter or lower-energy cycles too" }, "end_repeat_count": { - "higher": "Requires the end signal to repeat more times — more robust, slightly slower", - "lower": "Ends after fewer repetitions — faster detection, more false-end risk" + "higher": "Requires the end signal to repeat more times – more robust, slightly slower", + "lower": "Ends after fewer repetitions – faster detection, more false-end risk" }, "learning_confidence": { - "higher": "Learns only from very confident matches — slower but more reliable", + "higher": "Learns only from very confident matches – slower but more reliable", "lower": "Learns from more cycles; model may be noisier" }, "min_off_gap": { "higher": "Longer idle needed before a new cycle starts", - "lower": "Brief idle triggers a new cycle — back-to-back runs may split" + "lower": "Brief idle triggers a new cycle – back-to-back runs may split" }, "min_power": { "higher": "Less sensitive to idle power draw; may miss very quiet programs", @@ -1422,24 +1476,24 @@ "lower": "Force-stops a stuck cycle sooner" }, "off_delay": { - "higher": "More time for appliance pauses — handles long soak or drying phases", - "lower": "Faster end detection — works well for clean on/off appliances" + "higher": "More time for appliance pauses – handles long soak or drying phases", + "lower": "Faster end detection – works well for clean on/off appliances" }, "profile_match_threshold": { "higher": "Commits only high-confidence matches; more cycles stay unlabelled", "lower": "Matches more cycles; some may be slightly wrong" }, "running_dead_zone": { - "higher": "Ignores brief power spikes during idle — less noise-sensitive", - "lower": "Responds to shorter power bursts — catches fast transient activity" + "higher": "Ignores brief power spikes during idle – less noise-sensitive", + "lower": "Responds to shorter power bursts – catches fast transient activity" }, "start_threshold_w": { "higher": "Avoids false starts from brief power spikes", "lower": "Catches low-power programs; may trigger on noise" }, "stop_threshold_w": { - "higher": "Ends cycle faster — may close during a pause", - "lower": "Waits longer to end — fewer premature completions" + "higher": "Ends cycle faster – may close during a pause", + "lower": "Waits longer to end – fewer premature completions" }, "watchdog_interval": { "higher": "Longer quiet phases allowed; fewer false force-stops", @@ -1712,6 +1766,69 @@ }, "pg_sweep": { "optimize": "Optimize: {param}" + }, + "pg_detail": { + "simulate": "Simulate cycle" + }, + "split": { + "apply": "Splitting cycle" + }, + "trim": { + "apply": "Trimming cycle" + }, + "merge": { + "apply": "Merging cycles" + }, + "rebuild": { + "envelopes": "Rebuilding envelopes" + }, + "cancelling": "Cancelling..." + }, + "setup": { + "hdr": { + "card": "Device Setup", + "healthy_chip": "Setup complete" + }, + "phase0": { + "washer": "WashData is already detecting your cycles. Record your first cycle to enable program names and time estimates.", + "dishwasher": "WashData is watching. Dishwashers have complex cycles – recording your first cycle is strongly recommended. If a detected cycle runs too long, use the cycle editor to trim it before saving as a profile.", + "generic": "WashData is watching. Record or label a detected cycle to start building profiles." + }, + "phase1a": { + "labelled": "Good start – your first program is saved. For the cleanest data, consider recording your next cycle with the recorder widget." + }, + "phase1b": { + "recorded": "Your recording was saved as {profile_name}. Now record or label your other common programs to build coverage." + }, + "phase1c": { + "verify": "You have {count} programs from the community. Run a cycle to verify WashData recognises it correctly – matching will improve as your device builds its own history." + }, + "phase2": { + "cluster": "WashData has seen {count} cycles that don't match any saved program – they look similar to each other. Want to create a new profile for them?", + "unmatched": "Your last cycle didn't match any saved program. Is it a new program?" + }, + "phase3": { + "suggestions": "WashData has settings recommendations based on your cycle history – review them to improve detection accuracy.", + "groups": "Some of your profiles look like the same program at different temperatures. Organise them into a group for better matching.", + "phases": "Add program phases to {profile_name} for more accurate time-remaining estimates." + }, + "phase4": { + "healthy": "This device is fully set up ({profile_count} profiles)." + }, + "cta": { + "start_recording": "Start Recording", + "label_detected_cycle": "I already have a detected cycle – label it instead", + "browse_cycles": "Browse cycles to label another", + "view_profiles": "View your profiles", + "create_from_cluster": "Create profile from these cycles", + "create_profile": "Create a profile for it", + "review_suggestions": "Review suggestions", + "organise_profiles": "Organise profiles", + "configure_phases": "Configure phases", + "skip_step": "Skip this step", + "skip_forever": "Don't show again", + "hide_guidance": "Hide guidance", + "show_guidance": "Show guidance" } } -} +} \ No newline at end of file diff --git a/custom_components/ha_washdata/translations/panel/es.json b/custom_components/ha_washdata/translations/panel/es.json index c86c40c..2cb7a6e 100644 --- a/custom_components/ha_washdata/translations/panel/es.json +++ b/custom_components/ha_washdata/translations/panel/es.json @@ -87,7 +87,7 @@ "on_cycle_finished": "En ciclo terminado", "on_cycle_started": "En ciclo iniciado", "pause_cycle": "Pausa", - "pause_cycle_tip": "Pausar el ciclo en curso — el electrodoméstico reanudará donde lo dejó", + "pause_cycle_tip": "Pausar el ciclo en curso – el electrodoméstico reanudará donde lo dejó", "pg_apply_device": "Aplicar al dispositivo", "pg_autofill": "Autocompletar", "play": "Reproducir", @@ -97,8 +97,8 @@ "process_tip": "Guardar la grabación capturada como un perfil nuevo o existente", "rebuild": "Reconstruir sobres", "rebuild_envelope": "Reconstruir envolvente", - "rebuild_tip": "Recalcular la envolvente de potencia esperada (banda mín/máx) para todos los perfiles a partir de sus ciclos etiquetados — ejecutar después de etiquetar nuevos ciclos o corregir los existentes", - "rec_start_tip": "Comenzar a grabar la traza de potencia del electrodoméstico — iniciar justo antes de ejecutar un ciclo", + "rebuild_tip": "Recalcular la envolvente de potencia esperada (banda mín/máx) para todos los perfiles a partir de sus ciclos etiquetados – ejecutar después de etiquetar nuevos ciclos o corregir los existentes", + "rec_start_tip": "Comenzar a grabar la traza de potencia del electrodoméstico – iniciar justo antes de ejecutar un ciclo", "rec_stop": "Detener", "rec_stop_tip": "Detener la grabación y retener la traza capturada para revisión", "record": "Iniciar grabación", @@ -182,7 +182,7 @@ }, "attn_sub": "Corrige los conflictos antes de guardar", "attn_title": "{n} conflicto{s} de ajustes", - "settings_banner": "{n} conflicto{s} de ajustes — revisa las secciones resaltadas y corrígelos antes de guardar.", + "settings_banner": "{n} conflicto{s} de ajustes – revisa las secciones resaltadas y corrígelos antes de guardar.", "settings_banner_btn": "Ir al primero", "confidence": { "auto": "Debe ser mayor o igual al Umbral de coincidencia ({match})", @@ -451,9 +451,9 @@ "show_expected": "Mostrar superposición de curva esperada (perfil coincidente, naranja)", "show_raw": "Mostrar el interruptor de señal bruta del enchufe en el gráfico de potencia en vivo", "sparkline": "Tendencia reciente de duración de ciclos", - "stage2": "Etapa 2 — similitud principal", - "stage3": "Etapa 3 — DTW", - "stage4": "Etapa 4 — concordancia", + "stage2": "Etapa 2 – similitud principal", + "stage3": "Etapa 3 – DTW", + "stage4": "Etapa 4 – concordancia", "status": "Estado", "tail_trim": "Recorte de fin (s)", "timer_auto_pause": "Pausa automática", @@ -561,7 +561,12 @@ "flags": "Indicadores", "include_phase_map": "Incluir mapa de fases", "include_settings": "Incluir ajustes de detección y reconocimiento", - "show_contributor": "Mostrar nombres de colaboradores" + "show_contributor": "Mostrar nombres de colaboradores", + "task_pg_detail": "Simular ciclo", + "task_split": "Dividiendo el ciclo", + "task_trim": "Recortando el ciclo", + "task_merge": "Combinando ciclos", + "task_rebuild": "Reconstruyendo envolventes" }, "log": { "all_levels": "Todos los niveles", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Cayó por debajo de la banda de potencia habitual durante ~{n}s.", "artifact_footer": "Destacado en el gráfico de arriba. Estos son artefactos transitorios (por ejemplo, la puerta se abrió a mitad del ciclo), no necesariamente problemas.", "artifact_header": "{n} anomalías detectadas durante este ciclo", - "artifact_pause_detail": "La potencia cayó a casi cero durante ~{n}s y luego se reanudó — probablemente la puerta se abrió a mitad del ciclo o el ciclo fue pausado.", + "artifact_pause_detail": "La potencia cayó a casi cero durante ~{n}s y luego se reanudó – probablemente la puerta se abrió a mitad del ciclo o el ciclo fue pausado.", "artifact_spike_detail": "Superó la banda de potencia habitual durante ~{n}s.", "auto_label_intro": "Asigne perfiles a ciclos sin etiquetar cuya confianza de coincidencia supere el umbral.", "automations_intro": "WashData dispara los eventos {start} / {end} y expone entidades, por lo que las notificaciones y acciones se construyen mejor como automatizaciones normales de Home Assistant. Las automatizaciones que usan este dispositivo aparecen a continuación.", @@ -657,7 +662,7 @@ "compare_selected_cycles": "Ciclos seleccionados (sólidos): mostrar/ocultar", "consider_new_profile": "Considere la posibilidad de crear un nuevo perfil.", "coverage_gap": "Los ciclos recientes no tienen ningún perfil coincidente ({pct}% de los últimos 30).", - "coverage_gap_similar_cycles": "{count} ciclos similares sin etiquetar encontrados — cree un perfil para comenzar a identificarlos.", + "coverage_gap_similar_cycles": "{count} ciclos similares sin etiquetar encontrados – cree un perfil para comenzar a identificarlos.", "cycles_deleted": "{count} ciclo(s) eliminado(s)", "enough_data": "Suficientes datos para aprender ({current}/{min} ciclos).", "export_description": "Exporte todos los perfiles y ciclos a JSON o restaure desde una exportación anterior.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modelos adaptados a esta máquina.", "ml_loading": "cargando ML...", "ml_settings_intro": "Dos interruptores independientes: uno aplica los modelos mientras se ejecuta un ciclo, el otro permite que WashData los ajuste a su máquina con el tiempo.", - "name_first_program": "Ya tiene suficientes ciclos — nombre su primer programa para empezar a hacer coincidencias.", + "name_first_program": "Ya tiene suficientes ciclos – nombre su primer programa para empezar a hacer coincidencias.", "near_duplicate_cluster": "Se detectó un grupo de perfiles casi duplicados. La agrupación permite elegir de manera confiable entre elementos similares (por ejemplo, el mismo programa a diferente temperatura/centrifugado).", "no_cycles_match": "Ningún ciclo coincide con el filtro actual.", "no_cycles_profile": "No hay ciclos para este perfil.", @@ -713,7 +718,7 @@ "notify_services_hint": "Use los IDs de servicio {entity} (separados por comas para varios). Variables de plantilla: {vars}.", "old_actions_warning": "Configurado con el antiguo editor de acciones (ahora eliminado). Todavía se activan en eventos de ciclo pero ya no se pueden editar aquí. Conviértalos en una automatización normal o elimínelos.", "onboarding_progress": "{n} / 3 ciclos observados", - "onboarding_watching": "Use su electrodoméstico con normalidad — WashData está observando. Después de 3 ciclos, comenzará la coincidencia de programas.", + "onboarding_watching": "Use su electrodoméstico con normalidad – WashData está observando. Después de 3 ciclos, comenzará la coincidencia de programas.", "pending_feedback": "Comentarios de detección pendientes", "performance_trend": "Tendencia de rendimiento ({n} ciclos)", "pg_analysis_empty": "Cargue un ciclo para ver el análisis de coincidencia.", @@ -763,7 +768,7 @@ "setting_changed": "Cambiado de {old} a {new} el {date}", "settings_basic_note": "Mostrando ajustes esenciales. Cambie a Avanzado para ver la lista completa.", "shape_drift_advisory": "⚠ Forma a la deriva", - "shape_drift_detail": "El patrón de potencia de este perfil ha cambiado con el tiempo — posible desgaste del electrodoméstico o mantenimiento necesario (p. ej., descalcificación, limpieza del filtro).", + "shape_drift_detail": "El patrón de potencia de este perfil ha cambiado con el tiempo – posible desgaste del electrodoméstico o mantenimiento necesario (p. ej., descalcificación, limpieza del filtro).", "show_all_settings": "Mostrar todas las configuraciones", "showing_suggestions": "Mostrando configuración {count} con sugerencias.", "split_intro": "Haga clic en el gráfico para agregar o eliminar un punto de división, o detectar automáticamente por espacios inactivos. Cada segmento resultante puede obtener su propio perfil.", @@ -857,7 +862,9 @@ "share_guidelines_title": "Antes de compartir", "store_download_device_intro": "Adopta todos los programas compartidos y sus ciclos de referencia en tu dispositivo. Tus propios ciclos grabados y estadísticas no se ven afectados.", "store_share_device_intro": "Sube {brand} {model} con los ciclos de referencia que selecciones. Otros con el mismo electrodoméstico podrán adoptar tus programas. Las entradas se revisan antes de aparecer públicamente.", - "share_profile_no_cycles": "Sin ciclos de referencia -- marca un ciclo como ⭐ en la pestaña Ciclos para incluir este perfil" + "share_profile_no_cycles": "Sin ciclos de referencia -- marca un ciclo como ⭐ en la pestaña Ciclos para incluir este perfil", + "advisory_phase_inconsistent": "'{name}' parece mezclar diferentes programas o temperaturas - sus ciclos calientan durante tiempos muy distintos. Dividirlo en perfiles separados (p. ej. por temperatura) mejorará la coincidencia y las estimaciones de tiempo.", + "advisory_phase_inconsistent_title": "⚠ Posibles programas mezclados" }, "pg_desc": { "abrupt_drop_watts": "Caída repentina tratada como fin inmediato", @@ -869,12 +876,19 @@ "start_duration_threshold": "Segundos por encima del umbral para confirmar el inicio", "start_threshold_w": "Vatios mínimos para contar como iniciado", "stop_threshold_w": "Por debajo de esto, la máquina cuenta como apagada", - "dtw_bandwidth": "Cuánta deformación temporal permite la coincidencia de forma", - "corr_weight": "Peso de la forma de la curva frente al nivel de potencia en la coincidencia", - "duration_weight": "Cuánto influye la concordancia de duración en la coincidencia", - "energy_weight": "Cuánto influye la concordancia de energía en la coincidencia", - "profile_match_min_duration_ratio": "Ciclo más corto (frente al perfil) que aún puede coincidir", - "profile_match_max_duration_ratio": "Ciclo más largo (frente al perfil) que aún puede coincidir" + "dtw_bandwidth": "Etapa 3: banda de deformación Sakoe-Chiba (0 = DTW desactivado; predeterminado 0,2 = 20% de la longitud del ciclo)", + "corr_weight": "Etapa 2: equilibrio entre la forma de la curva (correlación) y el nivel de potencia (MAE); predeterminado 0,45", + "duration_weight": "Etapa 4: cuánto influye la concordancia de duración en la puntuación final (predeterminado 0,22)", + "energy_weight": "Etapa 4: cuánto influye la concordancia de energía en la puntuación final (predeterminado 0,22)", + "profile_match_min_duration_ratio": "Etapa 1: duración mínima del ciclo como fracción de la duración del perfil; predeterminado 0,1 (10%)", + "profile_match_max_duration_ratio": "Etapa 1: duración máxima del ciclo como fracción de la duración del perfil; predeterminado 1,5 (150%)", + "keep_min_score": "Etapa 2: puntuación mínima para seguir en la carrera; el valor predeterminado 0,1 admite coincidencias débiles, las etapas posteriores seleccionan la mejor", + "dtw_blend": "Etapa 3: 0 = solo puntuación central, 1 = solo puntuación DTW, 0,5 = mezcla igual (predeterminado)", + "dtw_ensemble_w": "Etapa 3 (modo ensemble): peso en L1 escalado frente a DTW derivado; el predeterminado 0,7 favorece la sensibilidad al nivel", + "dtw_ddtw_scale": "Etapa 3: distancia de semisaturación DDTW; menor = mayor sensibilidad a la forma (predeterminado 30)", + "dtw_refine_top_n": "Etapa 3: candidatos que DTW vuelve a puntuar; aumente a 7-9 si el perfil correcto se sitúa en 4.º-5.º lugar (predeterminado 5)", + "duration_scale": "Etapa 4: log-razón donde la concordancia de duración se reduce a la mitad; menor = penalización más estricta (predeterminado 0,175)", + "energy_scale": "Etapa 4: log-razón donde la concordancia de energía se reduce a la mitad; menor = penalización más estricta (predeterminado 0,25)" }, "phase_desc": { "anti_crease": "Volteos breves ocasionales tras finalizar para evitar que la ropa se arrugue.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Señales externas opcionales: un disparador de fin, un sensor de puerta, un interruptor de pausa y el recordatorio de descarga.", "label": "Disparadores y puertas" + }, + "phase_eta": { + "label": "Tiempo restante", + "intro": "Tiempo restante según las fases, para máquinas cuya duración de ciclo depende de la temperatura o el centrifugado." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Precio fijo por kWh utilizado para cifras de costos cuando no se establece ninguna entidad de precio en vivo arriba.", "label": "Precio de energía estático (por kWh)" }, + "energy_sensor": { + "doc": "Contador de energía acumulada opcional (total_increasing en kWh/Wh, p. ej. el propio medidor de consumo total del enchufe). Cuando se establece, la energía indicada de cada ciclo se toma de la diferencia de este contador entre el inicio y el final, lo que evita la subestimación que se produce al integrar un sensor de potencia que informa lentamente. Recurre al valor integrado si falta la lectura, si su unidad es desconocida o si el delta no es positivo. Deje en blanco para seguir integrando el sensor de potencia.", + "label": "Entidad del medidor de energía" + }, "expose_debug_entities": { "doc": "Publicar entidades HA de diagnóstico adicionales (confianza de coincidencia, ambigüedad, estados internos). Desactivado mantiene la lista de entidades limpia para un uso normal.", "label": "Exponer entidades de depuración" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Mostrar la atribución \"por \" en los electrodomésticos de la comunidad y los ciclos de referencia." + }, + "enable_phase_matching": { + "label": "Tiempo restante según las fases", + "doc": "Divide cada ciclo en curso en fases (calentamiento, lavado, centrifugado) y reparte el tiempo restante por fase, combinado con la estimación clásica - apoyándose en el reparto por fases al principio del ciclo y en la estimación clásica cerca del final. Esto personaliza la cuenta atrás según cuánto calienta y funciona realmente tu máquina, lo que se nota más en la primera mitad del ciclo. Desactivado = solo la estimación clásica. Solo se ve afectada la visualización del tiempo restante; la coincidencia de programas y la detección de ciclos no cambian." + }, + "keep_min_score": { + "label": "Score mín de coincidencia", + "doc": "Puntuación de similitud mínima que necesita un candidato para seguir en la carrera de coincidencia. El valor predeterminado 0,1 es deliberadamente permisivo - admite incluso candidatos débiles y se basa en etapas posteriores para encontrar la mejor coincidencia. Auméntelo para eliminar perfiles improbables antes; redúzcalo para ampliar el pool de candidatos inicial." + }, + "dtw_blend": { + "label": "Fusión Warp", + "doc": "Cuánto reemplaza la puntuación de alineación por deformación temporal (DTW) a la puntuación central de la Etapa 2. 0 = usar solo la puntuación de la Etapa 2, 1 = usar solo la puntuación DTW, 0,5 (predeterminado) = mezcla igual. Auméntelo para confiar más en el alineamiento Warp cuando los programas tienen niveles de potencia similares pero diferentes patrones de temporización." + }, + "dtw_ensemble_w": { + "label": "Mix ensemble Warp", + "doc": "En el modo DTW ensemble (el predeterminado), el peso en L1 escalado frente a DTW derivado (DDTW). 1,0 = todo L1 escalado, 0 = todo DDTW, 0,7 = predeterminado. La variante L1 escalado compara niveles de potencia; la variante derivada reacciona a cómo cambia la potencia con el tiempo. Ensemble combina ambas." + }, + "dtw_ddtw_scale": { + "label": "Escala Warp derivada", + "doc": "Distancia de semisaturación para la puntuación DTW derivada. La puntuación se reduce a la mitad cuando la distancia DDTW es igual a este valor. Menor = más sensible a las diferencias de forma. El predeterminado 30 está calibrado para trazas de potencia típicas de electrodomésticos. Solo afecta a los modos DTW ensemble y ddtw." + }, + "dtw_refine_top_n": { + "label": "Contador de refinamiento Warp", + "doc": "Cuántos candidatos principales de la Etapa 2 son repuntuados por DTW. DTW es más costoso, por lo que solo se aplica a los mejores N candidatos. Predeterminado 5. Auméntelo (a 7-9) si el perfil correcto a veces solo alcanza el 4.º o 5.º lugar tras la Etapa 2 - DTW puede rescatarlo. Reducirlo acelera ligeramente la coincidencia." + }, + "duration_scale": { + "label": "Nitidez de duración", + "doc": "Precisión de la penalización por concordancia de duración en la Etapa 4. Este es el log-ratio en el que el score de concordancia se reduce a la mitad. Menor = más estricto: una discordancia de duración penaliza más. El predeterminado 0,175 corresponde a aproximadamente el 18% de tolerancia de duración a medio peso. Combinar con Peso de duración." + }, + "energy_scale": { + "label": "Nitidez de energía", + "doc": "Precisión de la penalización por concordancia de energía. Menor = más estricto: una discordancia de energía penaliza más. El predeterminado 0,25 es intencionalmente más indulgente que la Precisión de duración, porque la energía varía con la carga. Combinar con Peso de energía." } }, "setting_group": { @@ -1570,24 +1624,24 @@ "lower": "Etiqueta más ciclos automáticamente; algunos pueden ser mal identificados" }, "duration_tolerance": { - "higher": "Acepta un rango de duración más amplio — correspondencia más flexible", - "lower": "Exige una duración más cercana — más estricto, puede rechazar ciclos inusuales" + "higher": "Acepta un rango de duración más amplio – correspondencia más flexible", + "lower": "Exige una duración más cercana – más estricto, puede rechazar ciclos inusuales" }, "end_energy_threshold": { "higher": "Solo cierra ciclos que usaron energía significativa", "lower": "También cierra ciclos cortos o de baja energía" }, "end_repeat_count": { - "higher": "Exige que la señal de fin se repita más veces — más robusto, ligeramente más lento", - "lower": "Termina tras menos repeticiones — detección más rápida, mayor riesgo de fin falso" + "higher": "Exige que la señal de fin se repita más veces – más robusto, ligeramente más lento", + "lower": "Termina tras menos repeticiones – detección más rápida, mayor riesgo de fin falso" }, "learning_confidence": { - "higher": "Aprende solo de coincidencias muy confiables — más lento pero más fiable", + "higher": "Aprende solo de coincidencias muy confiables – más lento pero más fiable", "lower": "Aprende de más ciclos; el modelo puede ser menos preciso" }, "min_off_gap": { "higher": "Se necesita más tiempo de inactividad antes de iniciar un nuevo ciclo", - "lower": "Un breve reposo inicia un nuevo ciclo — ejecuciones consecutivas pueden dividirse" + "lower": "Un breve reposo inicia un nuevo ciclo – ejecuciones consecutivas pueden dividirse" }, "min_power": { "higher": "Menos sensible al consumo en reposo; puede perder programas muy silenciosos", @@ -1598,24 +1652,24 @@ "lower": "Detiene un ciclo atascado antes" }, "off_delay": { - "higher": "Más tiempo para pausas del electrodoméstico — gestiona fases largas de remojo o secado", - "lower": "Detección de fin más rápida — ideal para electrodomésticos con encendido/apagado limpio" + "higher": "Más tiempo para pausas del electrodoméstico – gestiona fases largas de remojo o secado", + "lower": "Detección de fin más rápida – ideal para electrodomésticos con encendido/apagado limpio" }, "profile_match_threshold": { "higher": "Solo confirma coincidencias de alta confianza; más ciclos quedan sin etiquetar", "lower": "Coincide con más ciclos; algunos pueden ser ligeramente incorrectos" }, "running_dead_zone": { - "higher": "Ignora breves picos de potencia en reposo — menos sensible al ruido", - "lower": "Reacciona a impulsos más cortos — detecta actividad transitoria rápida" + "higher": "Ignora breves picos de potencia en reposo – menos sensible al ruido", + "lower": "Reacciona a impulsos más cortos – detecta actividad transitoria rápida" }, "start_threshold_w": { "higher": "Evita arranques falsos por breves picos de potencia", "lower": "Detecta programas de baja potencia; puede activarse con ruido" }, "stop_threshold_w": { - "higher": "Termina el ciclo antes — puede cerrarse durante una pausa", - "lower": "Espera más para terminar — menos finales prematuros" + "higher": "Termina el ciclo antes – puede cerrarse durante una pausa", + "lower": "Espera más para terminar – menos finales prematuros" }, "watchdog_interval": { "higher": "Se permiten fases silenciosas más largas; menos paradas forzadas falsas", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimizar: {param}" + }, + "pg_detail": { + "simulate": "Simular ciclo" + }, + "split": { + "apply": "Dividiendo el ciclo" + }, + "trim": { + "apply": "Recortando el ciclo" + }, + "merge": { + "apply": "Combinando ciclos" + }, + "rebuild": { + "envelopes": "Reconstruyendo envolventes" + }, + "cancelling": "Cancelando..." + }, + "setup": { + "hdr": { + "card": "Configuración del dispositivo", + "healthy_chip": "Configuración completada" + }, + "phase0": { + "washer": "WashData ya está detectando tus ciclos. Graba tu primer ciclo para activar los nombres de programas y las estimaciones de tiempo.", + "dishwasher": "WashData está observando. Los lavavajillas tienen ciclos complejos – se recomienda encarecidamente grabar el primer ciclo. Si un ciclo detectado se alarga demasiado, usa el editor de ciclos para recortarlo antes de guardarlo como perfil.", + "generic": "WashData está observando. Graba o etiqueta un ciclo detectado para empezar a crear perfiles." + }, + "phase1a": { + "labelled": "Buen comienzo – tu primer programa está guardado. Para datos más precisos, considera grabar tu próximo ciclo con el widget de grabación." + }, + "phase1b": { + "recorded": "Tu grabación se guardó como {profile_name}. Ahora graba o etiqueta tus otros programas habituales para ampliar la cobertura." + }, + "phase1c": { + "verify": "Tienes {count} programas de la comunidad. Ejecuta un ciclo para verificar que WashData lo reconoce correctamente – la correspondencia mejorará a medida que tu dispositivo construya su propio historial." + }, + "phase2": { + "cluster": "WashData ha observado {count} ciclos que no coinciden con ningún programa guardado – se parecen entre sí. ¿Deseas crear un nuevo perfil para ellos?", + "unmatched": "Tu último ciclo no coincidió con ningún programa guardado. ¿Es un nuevo programa?" + }, + "phase3": { + "suggestions": "WashData tiene recomendaciones de configuración basadas en tu historial de ciclos – revísalas para mejorar la precisión de detección.", + "groups": "Algunos de tus perfiles parecen el mismo programa a diferentes temperaturas. Organízalos en un grupo para una mejor correspondencia.", + "phases": "Añade fases de programa a {profile_name} para estimaciones más precisas del tiempo restante." + }, + "phase4": { + "healthy": "Este dispositivo está completamente configurado ({profile_count} perfiles)." + }, + "cta": { + "start_recording": "Iniciar grabación", + "label_detected_cycle": "Ya tengo un ciclo detectado – etiquetarlo en su lugar", + "browse_cycles": "Explorar ciclos para etiquetar otro", + "view_profiles": "Ver tus perfiles", + "create_from_cluster": "Crear perfil a partir de estos ciclos", + "create_profile": "Crear un perfil para este programa", + "review_suggestions": "Revisar sugerencias", + "organise_profiles": "Organizar perfiles", + "configure_phases": "Configurar fases", + "skip_step": "Omitir este paso", + "skip_forever": "No volver a mostrar", + "hide_guidance": "Ocultar guía", + "show_guidance": "Mostrar guía" } } } diff --git a/custom_components/ha_washdata/translations/panel/et.json b/custom_components/ha_washdata/translations/panel/et.json index 44af818..98b6d95 100644 --- a/custom_components/ha_washdata/translations/panel/et.json +++ b/custom_components/ha_washdata/translations/panel/et.json @@ -87,7 +87,7 @@ "on_cycle_finished": "Tsükkel lõppenud", "on_cycle_started": "Tsükkel algas", "pause_cycle": "Paus", - "pause_cycle_tip": "Peata käimasolev tsükkel — seade jätkab sealt, kus pooleli jäi", + "pause_cycle_tip": "Peata käimasolev tsükkel – seade jätkab sealt, kus pooleli jäi", "pg_apply_device": "Rakenda seadmele", "pg_autofill": "Täida automaatselt", "play": "Esita", @@ -97,8 +97,8 @@ "process_tip": "Salvesta salvestatud jälg uue või olemasoleva profiilina", "rebuild": "Ümbrikute ümberehitamine", "rebuild_envelope": "Ümberehitage ümbrik", - "rebuild_tip": "Arvuta uuesti kõigi profiilide eeldatav võimsusümbrik (min/max riba) nende märgistatud tsüklite põhjal — käivitage pärast uute tsüklite märgistamist või vanade parandamist", - "rec_start_tip": "Alusta seadme võimsusjälje salvestamist — käivita vahetult enne tsükli käivitamist", + "rebuild_tip": "Arvuta uuesti kõigi profiilide eeldatav võimsusümbrik (min/max riba) nende märgistatud tsüklite põhjal – käivitage pärast uute tsüklite märgistamist või vanade parandamist", + "rec_start_tip": "Alusta seadme võimsusjälje salvestamist – käivita vahetult enne tsükli käivitamist", "rec_stop": "Peatus", "rec_stop_tip": "Peata salvestamine ja hoia jäädvustatud jälg ülevaatamiseks", "record": "Alustage salvestamist", @@ -182,7 +182,7 @@ }, "attn_sub": "Lahendage konfliktid enne salvestamist", "attn_title": "{n} seadete konflikt{s}", - "settings_banner": "{n} seadete konflikt{s} — kontrollige esile tõstetud sektsioone ja parandage enne salvestamist.", + "settings_banner": "{n} seadete konflikt{s} – kontrollige esile tõstetud sektsioone ja parandage enne salvestamist.", "settings_banner_btn": "Mine esimesele", "confidence": { "auto": "Peab olema vähemalt vaste läve tasemel ({match})", @@ -451,9 +451,9 @@ "show_expected": "Kuva eeldatav kõvera ülekate (sobiv profiil, oranž)", "show_raw": "Kuva töötlemata pistikupesa lülitit reaalajas võimsusgraafikus", "sparkline": "Hiljutine tsükli kestuse trend", - "stage2": "Etapp 2 — põhisarnasus", - "stage3": "Etapp 3 — DTW", - "stage4": "Etapp 4 — kokkulangevus", + "stage2": "Etapp 2 – põhisarnasus", + "stage3": "Etapp 3 – DTW", + "stage4": "Etapp 4 – kokkulangevus", "status": "Olek", "tail_trim": "Saba trimm (id)", "timer_auto_pause": "Automaatne paus", @@ -561,7 +561,12 @@ "flags": "Embleemid", "include_phase_map": "Kaasa faasikaart", "include_settings": "Kaasa tuvastus- ja sobitamisseaded", - "show_contributor": "Näita kaastöötajate nimesid" + "show_contributor": "Näita kaastöötajate nimesid", + "task_pg_detail": "Tsükli simuleerimine", + "task_split": "Tsükli poolitamine", + "task_trim": "Tsükli kärpimine", + "task_merge": "Tsüklite ühendamine", + "task_rebuild": "Võimsusvahemike taasloomine" }, "log": { "all_levels": "Kõik tasemed", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Langes alla tavapärase võimsusriba umbes {n}s jooksul.", "artifact_footer": "Ülaltoodud graafikul esile tõstetud. Need on mööduvad artefaktid (nt uks avati tsükli keskel), mitte tingimata probleemid.", "artifact_header": "Selle tsükli jooksul tuvastati {n} anomaaliat", - "artifact_pause_detail": "Võimsus langes peaaegu nulli umbes {n}s jooksul ja taastus seejärel — tõenäoliselt avati uks tsükli keskel või tsükkel peatati.", + "artifact_pause_detail": "Võimsus langes peaaegu nulli umbes {n}s jooksul ja taastus seejärel – tõenäoliselt avati uks tsükli keskel või tsükkel peatati.", "artifact_spike_detail": "Ületas tavapärase võimsusriba umbes {n}s jooksul.", "auto_label_intro": "Määrake profiilid märgistamata tsüklitele, mille sobivuse usaldus eemaldab läve.", "automations_intro": "WashData käivitab {start} / {end} sündmusi ja avaldab olemeid, seega on teated ja toimingud kõige paremini ehitada tavaliste Home Assistanti automaatikana. Seda seadet kasutavad automaatikad kuvatakse allpool.", @@ -654,10 +659,10 @@ "collecting_data": "Andmete kogumine - {need} tsüklit veel enne peenhäälestuse alustamist ({current}/{min}).", "compare_overlay_profiles": "Ülekatteprofiilid (nõrkjad)", "compare_profiles_tip": "Katke ülaltoodud diagrammil teised profiiliümbrikud, et näha, milline neist selle tsükliga kõige paremini sobib.", - "compare_selected_cycles": "Valitud tsüklid (tahked) — näita/peida", + "compare_selected_cycles": "Valitud tsüklid (tahked) – näita/peida", "consider_new_profile": "Kaaluge uue profiili loomist.", "coverage_gap": "viimastel tsüklitel pole sobivat profiili ({pct}% viimasest 30-st).", - "coverage_gap_similar_cycles": "{count} sarnast märgistamata tsüklit leitud — loo profiil, et neid sobitama hakata.", + "coverage_gap_similar_cycles": "{count} sarnast märgistamata tsüklit leitud – loo profiil, et neid sobitama hakata.", "cycles_deleted": "{count} tsüklit kustutatud", "enough_data": "Piisavalt andmeid õppimiseks ({current}/{min} tsüklit).", "export_description": "Eksportige kõik profiilid ja tsüklid JSON-i või taastage eelmisest ekspordist.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Selle masina jaoks peenhäälestatud mudelid.", "ml_loading": "ML laadimine…", "ml_settings_intro": "Kaks sõltumatut lülitit: üks rakendab mudeleid tsükli töötamise ajal, teine ​​võimaldab WashDatal neid aja jooksul teie masinale täpselt häälestada.", - "name_first_program": "Sul on piisavalt tsükleid — pane oma esimesele programmile nimi, et alustada sobitamist.", + "name_first_program": "Sul on piisavalt tsükleid – pane oma esimesele programmile nimi, et alustada sobitamist.", "near_duplicate_cluster": "Tuvastati peaaegu duplikaatprofiili klaster. Rühmitamine võimaldab sobitamisel usaldusväärselt valida sarnasuste vahel (nt sama programm erineval temperatuuril/tsentrifuugimisel).", "no_cycles_match": "Ükski tsükkel ei vasta praegusele filtrile.", "no_cycles_profile": "Sellel profiilil pole tsükleid.", @@ -713,7 +718,7 @@ "notify_services_hint": "Kasutage {entity} teenuse ID-sid (mitme korral komadega eraldatult). Malli muutujad: {vars}.", "old_actions_warning": "Seadistatud vana toimingute redaktoriga (nüüd eemaldatud). Need käivituvad endiselt tsüklisündmustele, kuid neid ei saa siin enam redigeerida. Teisendage need tavaliseks automaatikaks või eemaldage need.", "onboarding_progress": "{n} / 3 tsüklit jälgitud", - "onboarding_watching": "Kasuta oma seadet tavapäraselt — WashData jälgib. Pärast 3 tsüklit algab programmide sobitamine.", + "onboarding_watching": "Kasuta oma seadet tavapäraselt – WashData jälgib. Pärast 3 tsüklit algab programmide sobitamine.", "pending_feedback": "Ootab tuvastamise tagasisidet", "performance_trend": "Toimivuse trend ({n} tsüklit)", "pg_analysis_empty": "Vaste analüüsi nägemiseks laadi tsükkel.", @@ -763,7 +768,7 @@ "setting_changed": "Muudetud {old} → {new} kuupäeval {date}", "settings_basic_note": "Kuvatakse olulisi seadeid. Täieliku loendi nägemiseks vali Täpsemalt.", "shape_drift_advisory": "⚠ Kuju triivib", - "shape_drift_detail": "Selle profiili võimsusstruktuur on aja jooksul muutunud — võimalik seadme kulumine või vajalik hooldus (nt katlakivi eemaldamine, filtri puhastamine).", + "shape_drift_detail": "Selle profiili võimsusstruktuur on aja jooksul muutunud – võimalik seadme kulumine või vajalik hooldus (nt katlakivi eemaldamine, filtri puhastamine).", "show_all_settings": "Kuva kõik seaded", "showing_suggestions": "Kuvatakse seade {count} koos soovitustega.", "split_intro": "Jaotuspunkti lisamiseks või eemaldamiseks või tühikäigu automaatseks tuvastamiseks klõpsake graafikul. Iga saadud segment võib saada oma profiili.", @@ -857,7 +862,9 @@ "share_guidelines_title": "Enne jagamist", "store_download_device_intro": "Võtke kasutusele iga jagatud programm ja selle võrdlustsüklid oma seadmel. Teie enda salvestatud tsükleid ja statistikat ei mõjutata.", "store_share_device_intro": "Laadige {brand} {model} üles valitud võrdlustsüklitega. Teised sama kodumasina kasutajad saavad teie programme kasutusele võtta. Kirjeid vaadatakse üle enne avalikustamist.", - "share_profile_no_cycles": "Võrdlustsükleid pole -- märkige tsükkel ⭐-ks Tsüklid-vahekaardil, et lisada see profiil" + "share_profile_no_cycles": "Võrdlustsükleid pole -- märkige tsükkel ⭐-ks Tsüklid-vahekaardil, et lisada see profiil", + "advisory_phase_inconsistent": "'{name}' näib segavat erinevaid programme või temperatuure - selle tsüklid kuumutavad väga erineva aja jooksul. Selle jagamine eraldi profiilideks (nt temperatuuri järgi) parandab sobitamist ja ajahinnanguid.", + "advisory_phase_inconsistent_title": "⚠ Võimalik programmide segu" }, "pg_desc": { "abrupt_drop_watts": "Järsk langus käsitletakse kohese lõpuna", @@ -869,12 +876,19 @@ "start_duration_threshold": "Sekundid üle läve käivituse kinnitamiseks", "start_threshold_w": "Minimaalne vatt, et lugeda käivitunuks", "stop_threshold_w": "Sellest allpool loetakse masin väljalülitatuks", - "dtw_bandwidth": "Kui palju ajalist venitamist kuju sobitamine lubab", - "corr_weight": "Kõvera kuju vs võimsustaseme kaal sobitamisel", - "duration_weight": "Kui palju kestuse kokkulangevus sobitamist mõjutab", - "energy_weight": "Kui palju energia kokkulangevus sobitamist mõjutab", - "profile_match_min_duration_ratio": "Lühim tsükkel (profiiliga võrreldes), mida veel lubatakse sobitada", - "profile_match_max_duration_ratio": "Pikim tsükkel (profiiliga võrreldes), mida veel lubatakse sobitada" + "dtw_bandwidth": "Etapp 3: Sakoe-Chiba aja-nihkeriba (0 = DTW väljas; vaikimisi 0,2 = 20% tsükli pikkusest)", + "corr_weight": "Etapp 2: tasakaal kõvera kuju (korrelatsioon) ja võimsustaseme (MAE) vahel; vaikimisi 0,45", + "duration_weight": "Etapp 4: kui tugevalt kestuse vastavus mõjutab lõppskooris (vaikimisi 0,22)", + "energy_weight": "Etapp 4: kui tugevalt energia vastavus mõjutab lõppskooris (vaikimisi 0,22)", + "profile_match_min_duration_ratio": "Etapp 1: lühim tsükkel profiili kestuse murdosana; vaikimisi 0,1 (10%)", + "profile_match_max_duration_ratio": "Etapp 1: pikim tsükkel profiili kestuse murdosana; vaikimisi 1,5 (150%)", + "keep_min_score": "Etapp 2: madalaim skoor võistluses püsimiseks; vaikimisi 0,1 lubab nõrku kandidaate, hilisemad etapid valivad parima", + "dtw_blend": "Etapp 3: 0 = ainult põhiskoor, 1 = ainult DTW-skoor, 0,5 = võrdne segu (vaikimisi)", + "dtw_ensemble_w": "Etapp 3 (ensemble-režiim): skaleeritud L1 kaal vs tuletise DTW; vaikimisi 0,7 eelistab tasemeteadlikkust", + "dtw_ddtw_scale": "Etapp 3: DDTW pool-küllastumiskaugus; väiksem = tundlikum kuju erinevuste suhtes (vaikimisi 30)", + "dtw_refine_top_n": "Etapp 3: kandidaadid keda DTW hindab ümber; tõstke 7-9-ni kui õige profiil jõuab 4.-5. kohale (vaikimisi 5)", + "duration_scale": "Etapp 4: log-suhe kus kestuse vastavus poolitub; väiksem = rangem karistus (vaikimisi 0,175)", + "energy_scale": "Etapp 4: log-suhe kus energia vastavus poolitub; väiksem = rangem karistus (vaikimisi 0,25)" }, "phase_desc": { "anti_crease": "Kortsude vähendamiseks tehke aeg-ajalt lühikesi tõmblusi pärast lõpetamist.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Valikulised välised signaalid: lõpu päästik, ukse andur, pausi lüliti ja mahalaadimisel meeldetuletaja.", "label": "Päästikud ja Uksed" + }, + "phase_eta": { + "label": "Järelejäänud aeg", + "intro": "Faasiteadlik järelejäänud aeg masinatele, mille tsükli pikkus sõltub temperatuurist või tsentrifuugimisest." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Fikseeritud hind kWh kohta, mida kasutatakse kulunäitajate jaoks, kui ülal pole määratud reaalajas hinna olemit.", "label": "Staatiline Energia Hind (kWh kohta)" }, + "energy_sensor": { + "doc": "Valikuline kumulatiivne energialoendur (total_increasing kWh/Wh, nt pistiku enda kogutarbimise arvesti). Kui see on määratud, võetakse iga tsükli energianäit selle loenduri algus- ja lõppnäidu vahest, mis väldib alamõõtmist, mida tekitab aeglaselt uueneva võimsusanduri integreerimine. Kui näit puudub, selle ühik on teadmata või vahe pole positiivne, kasutatakse tagavaraks integreeritud väärtust. Jätke tühjaks, et jätkata võimsusanduri integreerimist.", + "label": "Energiaarvesti Olem" + }, "expose_debug_entities": { "doc": "Avaldage täiendavad diagnostilised HA-üksused (vaste usaldus, mitmetähenduslikkus, oleku sisemised andmed). Väljas hoiab olemiloendi tavakasutuseks puhtana.", "label": "Näita Silumise Olemeid" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Kuva \" poolt\" omistamine kogukonna kodumasinate ja võrdlustsüklite juures." + }, + "enable_phase_matching": { + "label": "Faasiteadlik järelejäänud aeg", + "doc": "Jaga iga käimasolev tsükkel faasideks (kuumutamine, pesu, tsentrifuugimine) ja eelarvesta järelejäänud aeg faaside kaupa, segatuna klassikalise hinnanguga - toetudes tsükli alguses faasi eelarvele ja lõpu lähedal klassikalisele hinnangule. See kohandab loenduri sellele, kui kaua su masin tegelikult kuumutab ja töötab, mis on kõige märgatavam tsükli esimeses pooles. Väljas = ainult klassikaline hinnang. Mõjutab ainult järelejäänud aja kuva; programmi sobitamine ja tsükli tuvastamine jäävad muutmata." + }, + "keep_min_score": { + "label": "Min vastenduse skoor", + "doc": "Madalaim sarnasuskoor, mida kandidaat vajab sobitamisel püsimiseks. Vaikimisi 0,1 on tahtlikult lubav – see lubab isegi nõrku kandidaate ja toetub hilisematele etappidele parima vaste leidmiseks. Tõstke, et kärpida ebatõenäolisi profiile varem; langetage algse kandidaatide hulga laiendamiseks." + }, + "dtw_blend": { + "label": "DTW-segu", + "doc": "Kui palju DTW-joondamise skoor asendab 2. etapi põhiskoorit. 0 = kasuta ainult 2. etapi skoorit, 1 = kasuta ainult DTW-skoorit, 0,5 (vaikimisi) = võrdne segu. Tõstke, et rohkem toetuda aja-joondamisele, kui programmidel on sarnased võimsustasemed kuid erinevad ajastamismustrid." + }, + "dtw_ensemble_w": { + "label": "DTW ensemble-miks", + "doc": "Ensemble-DTW-režiimis (vaikimisi) on see kaal skaleeritud L1 versus tuletise DTW (DDTW). 1,0 = ainult skaleeritud L1, 0 = ainult DDTW, 0,7 = vaikimisi. Skaleeritud variant võrdleb võimsustasemeid; tuletise variant reageerib sellele, kuidas võimsus aja jooksul muutub. Ensemble ühendab mõlemad." + }, + "dtw_ddtw_scale": { + "label": "Tuletise DTW skaala", + "doc": "Pool-küllastumiskaugus tuletise DTW hindamisel. Skoor poolitub, kui DDTW-kaugus võrdub selle väärtusega. Väiksem = tundlikum kuju erinevuste suhtes. Vaikimisi 30 on kalibreeritud tüüpiliste seadmete võimsusjälgedele. Mõjutab ainult ensemble- ja ddtw-DTW-režiime." + }, + "dtw_refine_top_n": { + "label": "DTW täpsustusarv", + "doc": "Mitu parimat 2. etapi kandidaati DTW ümber hindab. DTW on kallim, seega rakendatakse seda ainult N parimale. Vaikimisi 5. Tõstke (7-9-ni), kui õige profiil jõuab mõnikord ainult 4. või 5. kohale pärast 2. etappi – DTW saab selle päästa. Madalama väärtusega sobitumine kiireneb veidi." + }, + "duration_scale": { + "label": "Kestuse teravus", + "doc": "4. etapi kestuse vastavuse karistuse teravus. See on log-suhe, mille juures vastavuse skoor poolitub. Väiksem = rangem: kestuse mittevastavus teeb rohkem kahju. Vaikimisi 0,175 vastab ligikaudu 18% kestuse tolerantsile poolkaalu juures. Kasutage koos Kestuse kaaluga." + }, + "energy_scale": { + "label": "Energia teravus", + "doc": "4. etapi energia vastavuse karistuse teravus. Väiksem = rangem: energia mittevastavus teeb rohkem kahju. Vaikimisi 0,25 on tahtlikult andelikum kui kestuse teravus, kuna energia varieerub koormuse järgi. Kasutage koos Energia kaaluga." } }, "setting_group": { @@ -1570,24 +1624,24 @@ "lower": "Märgistab automaatselt rohkem tsükleid; mõnda võidakse valesti tuvastada" }, "duration_tolerance": { - "higher": "Aktsepteerib laiemat kestuse vahemikku — paindlikum sobitamine", - "lower": "Nõuab täpsemat kestuse vastavust — rangem, kuid võib lükata tagasi ebatavalisi tsükleid" + "higher": "Aktsepteerib laiemat kestuse vahemikku – paindlikum sobitamine", + "lower": "Nõuab täpsemat kestuse vastavust – rangem, kuid võib lükata tagasi ebatavalisi tsükleid" }, "end_energy_threshold": { "higher": "Sulgeb ainult tsüklid, mis kasutasid olulisel määral energiat", "lower": "Sulgeb ka lühemad või madalama energiatarbe tsüklid" }, "end_repeat_count": { - "higher": "Nõuab lõpusignaali kordamist rohkem kordi — usaldusväärsem, pisut aeglasem", - "lower": "Lõpetab pärast vähemat kordust — kiirem tuvastamine, suurem vale lõpu oht" + "higher": "Nõuab lõpusignaali kordamist rohkem kordi – usaldusväärsem, pisut aeglasem", + "lower": "Lõpetab pärast vähemat kordust – kiirem tuvastamine, suurem vale lõpu oht" }, "learning_confidence": { - "higher": "Õpib ainult väga kindlatest vastetest — aeglasem, kuid usaldusväärsem", + "higher": "Õpib ainult väga kindlatest vastetest – aeglasem, kuid usaldusväärsem", "lower": "Õpib rohkematest tsüklitest; mudel võib olla müraram" }, "min_off_gap": { "higher": "Enne uue tsükli algust on vajalik pikem tegevusetus", - "lower": "Lühike tegevusetus käivitab uue tsükli — järjestikused käivitused võivad jaguneda" + "lower": "Lühike tegevusetus käivitab uue tsükli – järjestikused käivitused võivad jaguneda" }, "min_power": { "higher": "Vähem tundlik ooterežiimi tarbimise suhtes; võib vahele jätta vaikse töörežiimi", @@ -1598,24 +1652,24 @@ "lower": "Jõudab kinni jäänud tsükli sundpeatamise kiiremini" }, "off_delay": { - "higher": "Rohkem aega seadme paussideks — käsitleb pikka leotamist või kuivatamist", - "lower": "Kiirem lõpu tuvastamine — sobib hästi selge sisse/välja seadmetele" + "higher": "Rohkem aega seadme paussideks – käsitleb pikka leotamist või kuivatamist", + "lower": "Kiirem lõpu tuvastamine – sobib hästi selge sisse/välja seadmetele" }, "profile_match_threshold": { "higher": "Kinnitab ainult suure kindlusega vasteid; rohkem tsükleid jääb märgistamata", "lower": "Sobib rohkema tsükliga; mõnda võib olla veidi vale" }, "running_dead_zone": { - "higher": "Ignoreerib lühikesi voolutõuse jõudeolekus — vähem müra suhtes tundlik", - "lower": "Reageerib lühematele energiapursetele — tabab kiiret mööduvat aktiivsust" + "higher": "Ignoreerib lühikesi voolutõuse jõudeolekus – vähem müra suhtes tundlik", + "lower": "Reageerib lühematele energiapursetele – tabab kiiret mööduvat aktiivsust" }, "start_threshold_w": { "higher": "Väldib lühikestest energiatõusudest tulenevaid valesid käivitusi", "lower": "Tabab madalvõimsusega programmid; võib käivituda müra peale" }, "stop_threshold_w": { - "higher": "Lõpetab tsükli kiiremini — võib sulgeda pausi ajal", - "lower": "Ootab lõpetamisega kauem — vähem enneaegseid lõpetusi" + "higher": "Lõpetab tsükli kiiremini – võib sulgeda pausi ajal", + "lower": "Ootab lõpetamisega kauem – vähem enneaegseid lõpetusi" }, "watchdog_interval": { "higher": "Lubatud pikemad vaiksed faasid; vähem valesid sundpeatusi", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimeerimine: {param}" + }, + "pg_detail": { + "simulate": "Tsükli simuleerimine" + }, + "split": { + "apply": "Tsükli poolitamine" + }, + "trim": { + "apply": "Tsükli kärpimine" + }, + "merge": { + "apply": "Tsüklite ühendamine" + }, + "rebuild": { + "envelopes": "Võimsusvahemike taasloomine" + }, + "cancelling": "Katkestan..." + }, + "setup": { + "hdr": { + "card": "Seadme seadistus", + "healthy_chip": "Seadistus lõpetatud" + }, + "phase0": { + "washer": "WashData tuvastab juba teie tsükleid. Salvestage esimene tsükkel, et lubada programmide nimed ja ajahinnangud.", + "dishwasher": "WashData jälgib. Nõudepesumasinatel on keerukad tsüklid – esimese tsükli salvestamine on tungivalt soovitatav. Kui tuvastatud tsükkel töötab liiga kaua, kasutage tsükli redaktorit selle kärpimiseks enne profiilina salvestamist.", + "generic": "WashData jälgib. Salvestage või märgistage tuvastatud tsükkel profiilide loomise alustamiseks." + }, + "phase1a": { + "labelled": "Hea algus – teie esimene programm on salvestatud. Puhtaimate andmete saamiseks kaaluge järgmise tsükli salvestamist salvestusvidinaga." + }, + "phase1b": { + "recorded": "Teie salvestus on salvestatud kui {profile_name}. Nüüd salvestage või märgistage oma teised sagedased programmid katvuse suurendamiseks." + }, + "phase1c": { + "verify": "Teil on {count} programmi kogukonnast. Käivitage tsükkel, et kontrollida, kas WashData tunneb selle õigesti ära – sobitamine paraneb, kui teie seade koostab oma ajaloo." + }, + "phase2": { + "cluster": "WashData on näinud {count} tsüklit, mis ei vasta ühelegi salvestatud programmile – need näevad sarnased välja. Kas soovite neile luua uue profiili?", + "unmatched": "Viimane tsükkel ei vastanud ühelegi salvestatud programmile. Kas see on uus programm?" + }, + "phase3": { + "suggestions": "WashDatal on teie tsüklite ajaloo põhjal seadete soovitusi – vaadake need läbi tuvastustäpsuse parandamiseks.", + "groups": "Mõned teie profiilid näevad välja nagu sama programm erinevatel temperatuuridel. Korraldage need rühma parema sobitamise jaoks.", + "phases": "Lisage programmifaasid profiilile {profile_name} täpsemate järelejäänud aja hinnangute saamiseks." + }, + "phase4": { + "healthy": "See seade on täielikult seadistatud ({profile_count} profiili)." + }, + "cta": { + "start_recording": "Alusta salvestamist", + "label_detected_cycle": "Mul on juba tuvastatud tsükkel – märgista see", + "browse_cycles": "Sirvi tsükleid, et märgistada teine", + "view_profiles": "Vaata oma profiile", + "create_from_cluster": "Loo profiil nendest tsüklitest", + "create_profile": "Loo sellele profiil", + "review_suggestions": "Vaata soovitusi läbi", + "organise_profiles": "Korralda profiile", + "configure_phases": "Seadista faasid", + "skip_step": "Jäta see samm vahele", + "skip_forever": "Ära näita enam", + "hide_guidance": "Peida juhised", + "show_guidance": "Näita juhiseid" } } } diff --git a/custom_components/ha_washdata/translations/panel/fi.json b/custom_components/ha_washdata/translations/panel/fi.json index 478ad2c..739a78b 100644 --- a/custom_components/ha_washdata/translations/panel/fi.json +++ b/custom_components/ha_washdata/translations/panel/fi.json @@ -87,7 +87,7 @@ "on_cycle_finished": "Kierros päättyi", "on_cycle_started": "Kierros alkoi", "pause_cycle": "Tauko", - "pause_cycle_tip": "Keskeytä käynnissä oleva sykli — laite jatkaa siitä, mihin se jäi", + "pause_cycle_tip": "Keskeytä käynnissä oleva sykli – laite jatkaa siitä, mihin se jäi", "pg_apply_device": "Käytä laitteeseen", "pg_autofill": "Autotäyttö", "play": "Toista", @@ -97,8 +97,8 @@ "process_tip": "Tallenna tallennettu jälki uutena tai olemassa olevana profiilina", "rebuild": "Rakenna kirjekuoret uudelleen", "rebuild_envelope": "Rakenna kirjekuori uudelleen", - "rebuild_tip": "Laske uudelleen kaikkien profiilien odotettu tehokuori (min/maks-alue) merkityistä sykleistä — suorita uusien syklien merkitsemisen tai vanhojen korjaamisen jälkeen", - "rec_start_tip": "Aloita laitteen tehojäljen tallentaminen — käynnistä juuri ennen syklin suorittamista", + "rebuild_tip": "Laske uudelleen kaikkien profiilien odotettu tehokuori (min/maks-alue) merkityistä sykleistä – suorita uusien syklien merkitsemisen tai vanhojen korjaamisen jälkeen", + "rec_start_tip": "Aloita laitteen tehojäljen tallentaminen – käynnistä juuri ennen syklin suorittamista", "rec_stop": "Pysäytä", "rec_stop_tip": "Lopeta tallennus ja pidä tallennettu jälki tarkistettavana", "record": "Aloita tallennus", @@ -231,7 +231,7 @@ "interval": "Tulisi olla vähintään 2x näytteenottoväli ({si} s)", "sampling": "Näytteenottovälin tulee olla enintään puolet tarkkailijavälistä ({wi} s)" }, - "settings_banner": "{n} asetusten ristiriita{s} — tarkista korostetut osiot ja korjaa ne ennen tallentamista.", + "settings_banner": "{n} asetusten ristiriita{s} – tarkista korostetut osiot ja korjaa ne ennen tallentamista.", "settings_banner_btn": "Siirry ensimmäiseen" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Näytä odotettu käyrän peittokuva (vastaava profiili, oranssi)", "show_raw": "Näytä raakadatan pistorasiakytkin reaaliaikaisessa tehograafissa", "sparkline": "Viimeaikainen syklin keston suuntaus", - "stage2": "Vaihe 2 — ydinsamankaltaisuus", - "stage3": "Vaihe 3 — DTW", - "stage4": "Vaihe 4 — vastaavuus", + "stage2": "Vaihe 2 – ydinsamankaltaisuus", + "stage3": "Vaihe 3 – DTW", + "stage4": "Vaihe 4 – vastaavuus", "status": "Tila", "tail_trim": "Hännän leikkaus (s)", "timer_auto_pause": "Automaattinen tauko", @@ -561,7 +561,12 @@ "flags": "Liput", "include_phase_map": "Sisällytä vaihdekartta", "include_settings": "Sisällytä tunnistus- ja täsmäytysasetukset", - "show_contributor": "Näytä osallistujien nimet" + "show_contributor": "Näytä osallistujien nimet", + "task_pg_detail": "Simuloidaan sykliä", + "task_split": "Jaetaan sykliä", + "task_trim": "Rajataan sykliä", + "task_merge": "Yhdistetään syklejä", + "task_rebuild": "Rakennetaan tehoalueita uudelleen" }, "log": { "all_levels": "Kaikki tasot", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Alitti tavallisen tehon alarajan noin {n} sekunnin ajan.", "artifact_footer": "Korostettu yllä olevassa kaaviossa. Nämä ovat ohimeneviä esineitä (esim. ovi avautui kesken syklin), eivät välttämättä ongelmia.", "artifact_header": "{n} poikkeavaa havaittu tämän jakson aikana", - "artifact_pause_detail": "Teho laski lähes nollaan noin {n} sekunniksi ja jatkui sitten — todennäköisesti ovi avattiin syklin aikana tai sykli pysäytettiin.", + "artifact_pause_detail": "Teho laski lähes nollaan noin {n} sekunniksi ja jatkui sitten – todennäköisesti ovi avattiin syklin aikana tai sykli pysäytettiin.", "artifact_spike_detail": "Ylitti tavallisen tehon ylärajan noin {n} sekunnin ajan.", "auto_label_intro": "Määritä profiilit nimeämättömille sykleille, joiden osuvuusvarmuus tyhjentää kynnyksen.", "automations_intro": "WashData laukaisee {start} / {end}-tapahtumat ja tarjoaa entiteetit, joten ilmoitukset ja toiminnot kannattaa rakentaa tavallisina Home Assistant -automaatioina. Tätä laitetta käyttävät automaatiot näkyvät alla.", @@ -654,10 +659,10 @@ "collecting_data": "Kerätään tietoja - vielä {need} sykliä tarvitaan ennen hienosäädön käynnistymistä ({current}/{min}).", "compare_overlay_profiles": "Päällekkäiset profiilit (himmeä)", "compare_profiles_tip": "Aseta muut profiilikuoret yllä olevaan kaavioon nähdäksesi, mikä sopii parhaiten tähän jaksoon.", - "compare_selected_cycles": "Valitut jaksot (kiinteä) — näytä/piilota", + "compare_selected_cycles": "Valitut jaksot (kiinteä) – näytä/piilota", "consider_new_profile": "Harkitse uuden profiilin luomista.", "coverage_gap": "viimeaikaisilla jaksoilla ei ole vastaavaa profiilia ({pct} % viimeisestä 30:stä).", - "coverage_gap_similar_cycles": "{count} samanlaista merkitsemätöntä sykliä löydetty — luo profiili, jotta voit aloittaa niiden täsmäämisen.", + "coverage_gap_similar_cycles": "{count} samanlaista merkitsemätöntä sykliä löydetty – luo profiili, jotta voit aloittaa niiden täsmäämisen.", "cycles_deleted": "{count} sykli(ä) poistettu", "enough_data": "Tarpeeksi tietoja oppimiseen ({current}/{min} sykliä).", "export_description": "Vie kaikki profiilit ja syklit JSONiin tai palauta edellisestä viennistä.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Mallit hienosäädetty tälle koneelle.", "ml_loading": "ladataan ML…", "ml_settings_intro": "Kaksi erillistä kytkintä: toinen käyttää malleja syklin aikana, toinen antaa WashDatan hienosäätää ne koneellesi ajan myötä.", - "name_first_program": "Sinulla on tarpeeksi syklejä — nimeä ensimmäinen ohjelma aloittaaksesi täsmäyksen.", + "name_first_program": "Sinulla on tarpeeksi syklejä – nimeä ensimmäinen ohjelma aloittaaksesi täsmäyksen.", "near_duplicate_cluster": "lähes kaksoiskappale profiiliklusteri havaittu. Ryhmittely mahdollistaa täsmäämisen luotettavasti valitsemaan samankaltaisten ohjelmien välillä (esim. sama ohjelma eri lämpötilassa/linkouksessa).", "no_cycles_match": "Yksikään jakso ei vastaa nykyistä suodatinta.", "no_cycles_profile": "Ei jaksoja tälle profiilille.", @@ -713,7 +718,7 @@ "notify_services_hint": "Käytä {entity}-palvelun tunnuksia (pilkulla eroteltuna usealle). Mallimuuttujat: {vars}.", "old_actions_warning": "Määritetty vanhalla toimintoeditorilla (nyt poistettu). Ne käynnistyvät edelleen syklitapahtumissa, mutta niitä ei voi enää muokata täällä. Muunna ne normaaliksi automaatioksi tai poista ne.", "onboarding_progress": "{n} / 3 sykliä havaittu", - "onboarding_watching": "Käytä laitettasi normaalisti — WashData tarkkailee. Kolmen syklin jälkeen ohjelmien täsmäys alkaa.", + "onboarding_watching": "Käytä laitettasi normaalisti – WashData tarkkailee. Kolmen syklin jälkeen ohjelmien täsmäys alkaa.", "pending_feedback": "Odottaa tunnistuspalautetta", "performance_trend": "Tehotrendi ({n} jaksoa)", "pg_analysis_empty": "Lataa sykli nähdäksesi osuma-analyysin.", @@ -763,7 +768,7 @@ "setting_changed": "Muutettu {old} → {new} {date}", "settings_basic_note": "Näytetään olennaiset asetukset. Vaihda Edistynyt-tilaan nähdäksesi koko luettelon.", "shape_drift_advisory": "⚠ Muoto ajautuu", - "shape_drift_detail": "Tämän profiilin tehomuoto on muuttunut ajan myötä — mahdollinen laitteen kuluminen tai huolto tarvitaan (esim. kalkinpoisto, suodattimen puhdistus).", + "shape_drift_detail": "Tämän profiilin tehomuoto on muuttunut ajan myötä – mahdollinen laitteen kuluminen tai huolto tarvitaan (esim. kalkinpoisto, suodattimen puhdistus).", "show_all_settings": "Näytä kaikki asetukset", "showing_suggestions": "Näytetään asetus {count} ja ehdotuksia.", "split_intro": "Napsauta kaaviota lisätäksesi tai poistaaksesi jakopisteen tai tunnistaa automaattisesti tyhjäkäynnin väliltä. Jokainen tuloksena oleva segmentti voi saada oman profiilinsa.", @@ -857,7 +862,9 @@ "share_guidelines_title": "Ennen jakamista", "store_download_device_intro": "Ota käyttöön jokainen jaettu ohjelma ja sen viitesyklit laitteellesi. Omat tallennetut syklisi ja tilastosi eivät vaikutu.", "store_share_device_intro": "Lataa {brand} {model} valitsemiesi viitesyklien kanssa. Muut saman laitteen omistajat voivat ottaa ohjelmiasi käyttöön. Merkinnät tarkistetaan ennen julkaisua.", - "share_profile_no_cycles": "Ei viitesyklejä -- merkitse sykli ⭐:ksi Syklit-välilehdessä sisällyttääksesi tämän profiilin" + "share_profile_no_cycles": "Ei viitesyklejä -- merkitse sykli ⭐:ksi Syklit-välilehdessä sisällyttääksesi tämän profiilin", + "advisory_phase_inconsistent": "'{name}' vaikuttaa sekoittavan eri ohjelmia tai lämpötiloja - sen syklit lämmittävät hyvin eripituisia aikoja. Sen jakaminen erillisiin profiileihin (esim. lämpötilan mukaan) parantaa täsmäytystä ja aika-arvioita.", + "advisory_phase_inconsistent_title": "⚠ Mahdollisesti sekoittuneet ohjelmat" }, "pg_desc": { "abrupt_drop_watts": "Äkillinen pudotus tulkitaan välittömäksi lopuksi", @@ -869,12 +876,19 @@ "start_duration_threshold": "Sekunteja kynnyksen yläpuolella käynnistyksen vahvistamiseksi", "start_threshold_w": "Vähimmäiswatit, jotta lasketaan käynnistyneeksi", "stop_threshold_w": "Tämän alapuolella laite lasketaan pois päältä olevaksi", - "dtw_bandwidth": "Kuinka paljon ajan venytystä muodon täsmäytys sallii", - "corr_weight": "Käyrän muodon ja tehotason painotus täsmäytyksessä", - "duration_weight": "Kuinka paljon keston yhteensopivuus vaikuttaa täsmäytykseen", - "energy_weight": "Kuinka paljon energian yhteensopivuus vaikuttaa täsmäytykseen", - "profile_match_min_duration_ratio": "Lyhin sykli (profiiliin verrattuna), joka voi vielä täsmätä", - "profile_match_max_duration_ratio": "Pisin sykli (profiiliin verrattuna), joka voi vielä täsmätä" + "dtw_bandwidth": "Vaihe 3: Sakoe-Chiba-ajankäyristyskaista (0 = DTW pois; oletus 0,2 = 20% syklinpituudesta)", + "corr_weight": "Vaihe 2: tasapaino käyrän muodon (korrelaatio) ja tehotason (MAE) välillä; oletus 0,45", + "duration_weight": "Vaihe 4: kuinka vahvasti keston yhdenmukaisuus vaikuttaa lopullisiin pisteisiin (oletus 0,22)", + "energy_weight": "Vaihe 4: kuinka vahvasti energian yhdenmukaisuus vaikuttaa lopullisiin pisteisiin (oletus 0,22)", + "profile_match_min_duration_ratio": "Vaihe 1: lyhin sykli profiilinkeston murto-osana; oletus 0,1 (10%)", + "profile_match_max_duration_ratio": "Vaihe 1: pisin sykli profiilinkeston murto-osana; oletus 1,5 (150%)", + "keep_min_score": "Vaihe 2: alin pisteet pysyäkseen mukana; oletus 0,1 sallii heikot ehdokkaat, myöhemmät vaiheet valitsevat parhaan", + "dtw_blend": "Vaihe 3: 0 = vain ydinpisteet, 1 = vain DTW-pisteet, 0,5 = tasainen sekoitus (oletus)", + "dtw_ensemble_w": "Vaihe 3 (ensemble-tila): skaloidun L1 paino vs. derivatiivinen DTW; oletus 0,7 suosii tasonhavainnoivaa", + "dtw_ddtw_scale": "Vaihe 3: DDTW:n puolikyllästymisetäisyys; pienempi = herkempi muodon eroille (oletus 30)", + "dtw_refine_top_n": "Vaihe 3: ehdokkaat joille DTW laskee pisteet uudelleen; nosta 7-9:ään jos oikea profiili sijoittuu 4.-5. (oletus 5)", + "duration_scale": "Vaihe 4: log-suhde jossa keston yhdenmukaisuus puolittuu; pienempi = tiukempi rangaistus (oletus 0,175)", + "energy_scale": "Vaihe 4: log-suhde jossa energian yhdenmukaisuus puolittuu; pienempi = tiukempi rangaistus (oletus 0,25)" }, "phase_desc": { "anti_crease": "Satunnaisia lyhyitä rumpujaksia valmistumisen jälkeen vaatteiden rypistyistä ehkäisemiseksi.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Valinnaiset ulkoiset signaalit: päätyssignaali, ovianturi, taukokytkin ja purkamismuistutus.", "label": "Käynnistysimpulssit ja ovet" + }, + "phase_eta": { + "label": "Jäljellä oleva aika", + "intro": "Vaihetietoinen jäljellä oleva aika koneille, joiden syklin pituus riippuu lämpötilasta tai linkouksesta." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Kiinteää kWh-hintaa käytetään kustannusluvuissa, kun yllä ei ole asetettu reaaliaikaista hintakokonaisuutta.", "label": "Kiinteä energiahinta (per kWh)" }, + "energy_sensor": { + "doc": "Valinnainen kumulatiivinen energialaskuri (total_increasing kWh/Wh, esim. pistokkeen oma kokonaismittari). Kun tämä on asetettu, kunkin syklin raportoitu energia otetaan tämän laskurin alku- ja loppuarvon erotuksesta, mikä välttää alilaskennan, jonka saat integroimalla hitaasti raportoivaa tehoanturia. Palaa integroituun arvoon, jos lukema puuttuu, sen yksikkö on tuntematon tai erotus ei ole positiivinen. Jätä tyhjäksi jatkaaksesi tehoanturin integrointia.", + "label": "Energiamittarin entiteetti" + }, "expose_debug_entities": { "doc": "Julkaise ylimääräisiä diagnostisia HA-kokonaisuuksia (osumavarmuus, moniselitteisyys, tilan sisäiset tiedot). Pois päältä pitää entiteettiluettelon puhtaana normaalia käyttöä varten.", "label": "Näytä virheenkorjausentiteetit" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Näytä \"tekijä \" -merkintä yhteisön laitteissa ja viitesykleissä." + }, + "enable_phase_matching": { + "label": "Vaihetietoinen jäljellä oleva aika", + "doc": "Jaa jokainen käynnissä oleva sykli vaiheisiin (lämmitys, pesu, linkous) ja budjetoi jäljellä oleva aika vaihekohtaisesti, yhdistettynä klassiseen arvioon - nojaten syklin alkuvaiheessa vaihebudjettiin ja lopussa klassiseen arvioon. Tämä mukauttaa laskurin siihen, kuinka kauan koneesi todella lämmittää ja käy, mikä näkyy selvimmin syklin alkupuoliskolla. Pois = vain klassinen arvio. Vaikuttaa vain jäljellä olevan ajan näyttöön; ohjelman täsmäytys ja syklin tunnistus pysyvät ennallaan." + }, + "keep_min_score": { + "label": "Vähim. vastaavuuspisteet", + "doc": "Alin samankaltaisuuspisteet, jotka ehdokas tarvitsee pysyäkseen täsmäytyksessä mukana. Oletus 0,1 on tarkoituksellisesti salliva – se sallii jopa heikot ehdokkaat ja luottaa myöhempiin vaiheisiin parhaan löytämiseksi. Nosta leikkaamaan epätodennäköiset profiilit aikaisemmin; laske laajentamaan alkuperäistä ehdokaspoolia." + }, + "dtw_blend": { + "label": "DTW-sekoitus", + "doc": "Kuinka paljon DTW-kohdistuspisteet korvaavat Vaiheen 2 ydinpisteet. 0 = käytä vain Vaiheen 2 pisteitä, 1 = käytä vain DTW-pisteitä, 0,5 (oletus) = tasainen sekoitus. Nosta luottamaan enemmän aikakohdistukseen kun ohjelmilla on samanlaiset tehotasot mutta erilaiset ajoitusmustrit." + }, + "dtw_ensemble_w": { + "label": "DTW-ensemble-miksaus", + "doc": "Ensemble-DTW-tilassa (oletus) tämä on skaloidun L1:n paino verrattuna derivatiiviseen DTW:hen (DDTW). 1,0 = vain skaloitu L1, 0 = vain DDTW, 0,7 = oletus. Skaloitu muunnelma vertaa tehotasoja; derivatiivinen reagoi siihen miten teho muuttuu ajan kuluessa. Ensemble yhdistää molemmat." + }, + "dtw_ddtw_scale": { + "label": "Deriv. DTW-skala", + "doc": "Derivatiivisen DTW-pisteytyksen puolikyllästymisetäisyys. Pisteet puolittuvat kun DDTW-etäisyys on yhtä suuri kuin tämä arvo. Pienempi = herkempi muodon eroille. Oletus 30 on kalibroitu tyypillisten laitteiden tehokäyrille. Vaikuttaa vain ensemble- ja ddtw-DTW-tiloihin." + }, + "dtw_refine_top_n": { + "label": "DTW-tarkistusmäärä", + "doc": "Kuinka monelle Vaiheen 2 parhaalle ehdokkaalle DTW laskee pisteet uudelleen. DTW on kalliimpaa joten se sovelletaan vain N parhaaseen. Oletus 5. Nosta (7-9:ään) jos oikea profiili pääsee joskus vain 4. tai 5. sijalle Vaiheen 2 jälkeen – DTW voi pelastaa sen. Pienempi arvo nopeuttaa täsmäytystä hieman." + }, + "duration_scale": { + "label": "Keston tarkkuus", + "doc": "Vaiheen 4 keston yhdenmukaisuusrangaistuksen tarkkuus. Tämä on log-suhde jossa yhdenmukaisuuspisteet puolittuvat. Pienempi = tiukempi: keston poikkeama haittaa enemmän. Oletus 0,175 vastaa noin 18% kestontoleranssia puolipainolla. Yhdistä Kestopaino-asetukseen." + }, + "energy_scale": { + "label": "Energian tarkkuus", + "doc": "Vaiheen 4 energian yhdenmukaisuusrangaistuksen tarkkuus. Pienempi = tiukempi: energian poikkeama haittaa enemmän. Oletus 0,25 on tarkoituksellisesti sallivampi kuin kestotarkkuus koska energia vaihtelee kuorman mukaan. Yhdistä Energiapaino-asetukseen." } }, "setting_group": { @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimointi: {param}" + }, + "pg_detail": { + "simulate": "Simuloidaan sykliä" + }, + "split": { + "apply": "Jaetaan sykliä" + }, + "trim": { + "apply": "Rajataan sykliä" + }, + "merge": { + "apply": "Yhdistetään syklejä" + }, + "rebuild": { + "envelopes": "Rakennetaan tehoalueita uudelleen" + }, + "cancelling": "Perutaan..." + }, + "setup": { + "hdr": { + "card": "Laitteen asetukset", + "healthy_chip": "Asetukset valmiit" + }, + "phase0": { + "washer": "WashData tunnistaa jo syklisi. Tallenna ensimmäinen sykli ottaaksesi käyttöön ohjelmanimet ja aikaarviot.", + "dishwasher": "WashData tarkkailee. Astianpesukoneilla on monimutkaisia syklejä – ensimmäisen syklin tallentaminen on erittäin suositeltavaa. Jos tunnistettu sykli kestää liian kauan, käytä syklin editoria sen leikkaamiseen ennen profiilina tallentamista.", + "generic": "WashData tarkkailee. Tallenna tai merkitse tunnistettu sykli aloittaaksesi profiilien luomisen." + }, + "phase1a": { + "labelled": "Hyvä alku – ensimmäinen ohjelmasi on tallennettu. Puhtaimman datan saamiseksi harkitse seuraavan syklin tallentamista tallennuswidgetillä." + }, + "phase1b": { + "recorded": "Tallennuksesi on tallennettu nimellä {profile_name}. Tallenna tai merkitse nyt muut yleisimmät ohjelmasi kattavuuden rakentamiseksi." + }, + "phase1c": { + "verify": "Sinulla on {count} ohjelmaa yhteisöltä. Suorita sykli tarkistaaksesi, tunnistaako WashData sen oikein – täsmäytys paranee laitteen kerätessä omaa historiaansa." + }, + "phase2": { + "cluster": "WashData on havainnut {count} sykliä, jotka eivät täsmää mihinkään tallennettuun ohjelmaan – ne näyttävät samanlaisilta keskenään. Haluatko luoda niille uuden profiilin?", + "unmatched": "Viimeisin sykli ei täsmännyt mihinkään tallennettuun ohjelmaan. Onko se uusi ohjelma?" + }, + "phase3": { + "suggestions": "WashDatalla on asetussuosituksia syklihistoriasi perusteella – tarkista ne tunnistustarkkuuden parantamiseksi.", + "groups": "Jotkut profiileistasi näyttävät samalta ohjelmalta eri lämpötiloissa. Järjestä ne ryhmään parempaa täsmäytystä varten.", + "phases": "Lisää ohjelman vaiheet profiiliin {profile_name} tarkempien jäljellä olevan ajan arvioiden saamiseksi." + }, + "phase4": { + "healthy": "Tämä laite on täysin asennettu ({profile_count} profiilia)." + }, + "cta": { + "start_recording": "Aloita tallennus", + "label_detected_cycle": "Minulla on jo tunnistettu sykli – merkitse se sen sijaan", + "browse_cycles": "Selaa syklejä merkitäksesi toisen", + "view_profiles": "Näytä profiilit", + "create_from_cluster": "Luo profiili näistä sykleistä", + "create_profile": "Luo sille profiili", + "review_suggestions": "Tarkista ehdotukset", + "organise_profiles": "Järjestä profiilit", + "configure_phases": "Määritä vaiheet", + "skip_step": "Ohita tämä vaihe", + "skip_forever": "Älä näytä enää", + "hide_guidance": "Piilota opastus", + "show_guidance": "Näytä opastus" } } } diff --git a/custom_components/ha_washdata/translations/panel/fr.json b/custom_components/ha_washdata/translations/panel/fr.json index 0f76c85..03bbab9 100644 --- a/custom_components/ha_washdata/translations/panel/fr.json +++ b/custom_components/ha_washdata/translations/panel/fr.json @@ -87,7 +87,7 @@ "on_cycle_finished": "Le cycle terminé", "on_cycle_started": "Le cycle a démarré", "pause_cycle": "Pause", - "pause_cycle_tip": "Mettre en pause le cycle en cours — l'appareil reprendra là où il s'est arrêté", + "pause_cycle_tip": "Mettre en pause le cycle en cours – l'appareil reprendra là où il s'est arrêté", "pg_apply_device": "Appliquer à l'appareil", "pg_autofill": "Remplissage automatique", "play": "Lire", @@ -97,8 +97,8 @@ "process_tip": "Enregistrer la trace capturée en tant que profil nouveau ou existant", "rebuild": "Reconstruire les enveloppes", "rebuild_envelope": "Reconstruire l'enveloppe", - "rebuild_tip": "Recalculer l'enveloppe de puissance attendue (bande min/max) pour tous les profils à partir de leurs cycles étiquetés — à exécuter après l'étiquetage de nouveaux cycles ou la correction d'anciens", - "rec_start_tip": "Commencer l'enregistrement de la trace de puissance de l'appareil — démarrer juste avant de lancer un cycle", + "rebuild_tip": "Recalculer l'enveloppe de puissance attendue (bande min/max) pour tous les profils à partir de leurs cycles étiquetés – à exécuter après l'étiquetage de nouveaux cycles ou la correction d'anciens", + "rec_start_tip": "Commencer l'enregistrement de la trace de puissance de l'appareil – démarrer juste avant de lancer un cycle", "rec_stop": "Arrêt", "rec_stop_tip": "Arrêter l'enregistrement et conserver la trace capturée pour révision", "record": "Commencer l'enregistrement", @@ -182,7 +182,7 @@ }, "attn_sub": "Corriger les conflits avant d'enregistrer", "attn_title": "{n} conflit{s} de paramètres", - "settings_banner": "{n} conflit{s} de paramètres — vérifiez les sections surlignées et corrigez avant d'enregistrer.", + "settings_banner": "{n} conflit{s} de paramètres – vérifiez les sections surlignées et corrigez avant d'enregistrer.", "settings_banner_btn": "Aller au premier", "confidence": { "auto": "Doit être supérieur ou égal au Seuil de correspondance ({match})", @@ -451,9 +451,9 @@ "show_expected": "Afficher la superposition de courbe attendue (profil correspondant, orange)", "show_raw": "Afficher le commutateur de signal brut de la prise dans le graphique de puissance en direct", "sparkline": "Tendance récente de la durée des cycles", - "stage2": "Étape 2 — similarité principale", - "stage3": "Étape 3 — DTW", - "stage4": "Étape 4 — concordance", + "stage2": "Étape 2 – similarité principale", + "stage3": "Étape 3 – DTW", + "stage4": "Étape 4 – concordance", "status": "Statut", "tail_trim": "Rognage de fin (s)", "timer_auto_pause": "Pause automatique", @@ -561,7 +561,12 @@ "flags": "Indicateurs", "include_phase_map": "Inclure la carte des phases", "include_settings": "Inclure les paramètres de détection et de reconnaissance", - "show_contributor": "Afficher les noms des contributeurs" + "show_contributor": "Afficher les noms des contributeurs", + "task_pg_detail": "Simuler le cycle", + "task_split": "Division du cycle", + "task_trim": "Rognage du cycle", + "task_merge": "Fusion des cycles", + "task_rebuild": "Reconstruction des enveloppes" }, "log": { "all_levels": "Tous niveaux", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Est tombé en dessous de la bande de puissance habituelle pendant environ {n} s.", "artifact_footer": "Mis en évidence sur le graphique ci-dessus. Il s’agit d’artefacts transitoires (par exemple la porte ouverte en cours de cycle), pas nécessairement de problèmes.", "artifact_header": "{n} anomalies détectées au cours de ce cycle", - "artifact_pause_detail": "La puissance a chuté près de zéro pendant environ {n} s puis a repris — la porte a probablement été ouverte en cours de cycle ou le cycle a été mis en pause.", + "artifact_pause_detail": "La puissance a chuté près de zéro pendant environ {n} s puis a repris – la porte a probablement été ouverte en cours de cycle ou le cycle a été mis en pause.", "artifact_spike_detail": "A dépassé la bande de puissance habituelle pendant environ {n} s.", "auto_label_intro": "Attribuez des profils à des cycles non étiquetés dont la confiance de correspondance efface le seuil.", "automations_intro": "WashData déclenche les événements {start} / {end} et expose des entités, les notifications et actions sont donc mieux construites comme des automatisations Home Assistant normales. Les automatisations utilisant cet appareil apparaissent ci-dessous.", @@ -654,10 +659,10 @@ "collecting_data": "Collecte de données - encore {need} cycle{plural} avant que l'affinage puisse commencer ({current}/{min}).", "compare_overlay_profiles": "Profils de superposition (faible)", "compare_profiles_tip": "Superposez d'autres enveloppes de profil sur le graphique ci-dessus pour voir laquelle correspond le mieux à ce cycle.", - "compare_selected_cycles": "Cycles sélectionnés (solides) — afficher/masquer", + "compare_selected_cycles": "Cycles sélectionnés (solides) – afficher/masquer", "consider_new_profile": "Pensez à créer un nouveau profil.", "coverage_gap": "les cycles récents n'ont pas de profil correspondant ({pct} % des 30 derniers).", - "coverage_gap_similar_cycles": "{count} cycles similaires non étiquetés trouvés — créez un profil pour commencer à les identifier.", + "coverage_gap_similar_cycles": "{count} cycles similaires non étiquetés trouvés – créez un profil pour commencer à les identifier.", "cycles_deleted": "{count} cycle(s) supprimé(s)", "enough_data": "Suffisamment de données pour apprendre ({current}/{min} cycles).", "export_description": "Exportez tous les profils et cycles vers JSON, ou restaurez à partir d'une exportation précédente.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modèles adaptés à cette machine.", "ml_loading": "chargement de ML…", "ml_settings_intro": "Deux commutateurs indépendants : l'un applique les modèles pendant l'exécution d'un cycle, l'autre permet à WashData de les ajuster avec précision à votre machine au fil du temps.", - "name_first_program": "Vous avez assez de cycles — nommez votre premier programme pour lancer la correspondance.", + "name_first_program": "Vous avez assez de cycles – nommez votre premier programme pour lancer la correspondance.", "near_duplicate_cluster": "Cluster de profils quasi-dupliqué détecté. Le regroupement permet de choisir de manière fiable entre des sosies (par exemple, le même programme à différentes températures/essorages).", "no_cycles_match": "Aucun cycle ne correspond au filtre actuel.", "no_cycles_profile": "Aucun cycle pour ce profil.", @@ -713,7 +718,7 @@ "notify_services_hint": "Utilisez les IDs de service {entity} (séparés par des virgules pour plusieurs). Variables de gabarit : {vars}.", "old_actions_warning": "Configuré avec l'ancien éditeur d'actions (maintenant supprimé). Ils se déclenchent toujours sur les événements du cycle mais ne peuvent plus être modifiés ici. Convertissez-les en une automatisation normale ou supprimez-les.", "onboarding_progress": "{n} / 3 cycles observés", - "onboarding_watching": "Utilisez votre appareil normalement — WashData observe. Après 3 cycles, la correspondance des programmes commencera.", + "onboarding_watching": "Utilisez votre appareil normalement – WashData observe. Après 3 cycles, la correspondance des programmes commencera.", "pending_feedback": "En attente de retour de détection", "performance_trend": "Tendance des performances ({n} cycles)", "pg_analysis_empty": "Chargez un cycle pour voir l'analyse de correspondance.", @@ -763,7 +768,7 @@ "setting_changed": "Modifié de {old} à {new} le {date}", "settings_basic_note": "Affichage des paramètres essentiels. Passez à Avancé pour la liste complète.", "shape_drift_advisory": "⚠ Dérive de forme", - "shape_drift_detail": "Le profil de puissance a évolué au fil du temps — usure possible de l'appareil ou entretien nécessaire (p. ex. détartrage, nettoyage du filtre).", + "shape_drift_detail": "Le profil de puissance a évolué au fil du temps – usure possible de l'appareil ou entretien nécessaire (p. ex. détartrage, nettoyage du filtre).", "show_all_settings": "Afficher tous les paramètres", "showing_suggestions": "Affichage du paramètre {count} avec des suggestions.", "split_intro": "Cliquez sur le graphique pour ajouter ou supprimer un point de partage, ou détecter automatiquement les espaces inactifs. Chaque segment résultant peut avoir son propre profil.", @@ -857,7 +862,9 @@ "share_guidelines_title": "Avant de partager", "store_download_device_intro": "Adoptez chaque programme partagé et ses cycles de référence sur votre appareil. Vos propres cycles enregistrés et statistiques ne sont pas affectés.", "store_share_device_intro": "Envoyez {brand} {model} avec les cycles de référence que vous sélectionnez. D'autres utilisateurs du même appareil peuvent adopter vos programmes. Les entrées sont examinées avant d'apparaître publiquement.", - "share_profile_no_cycles": "Aucun cycle de référence -- marquez un cycle comme ⭐ dans l'onglet Cycles pour inclure ce profil" + "share_profile_no_cycles": "Aucun cycle de référence -- marquez un cycle comme ⭐ dans l'onglet Cycles pour inclure ce profil", + "advisory_phase_inconsistent": "'{name}' semble mélanger différents programmes ou températures - ses cycles chauffent pendant des durées très variables. Le diviser en profils distincts (p. ex. par température) améliorera la reconnaissance et les estimations de temps.", + "advisory_phase_inconsistent_title": "⚠ Programmes peut-être mélangés" }, "pg_desc": { "abrupt_drop_watts": "Chute soudaine traitée comme une fin immédiate", @@ -869,12 +876,19 @@ "start_duration_threshold": "Secondes au-dessus du seuil pour confirmer le démarrage", "start_threshold_w": "Watts minimum pour être compté comme démarré", "stop_threshold_w": "En dessous, la machine est comptée comme éteinte", - "dtw_bandwidth": "Quantité de déformation temporelle autorisée par la correspondance de forme", - "corr_weight": "Poids de la forme de la courbe par rapport au niveau de puissance dans la correspondance", - "duration_weight": "Influence de la concordance de durée sur la correspondance", - "energy_weight": "Influence de la concordance d'énergie sur la correspondance", - "profile_match_min_duration_ratio": "Cycle le plus court (par rapport au profil) encore autorisé à correspondre", - "profile_match_max_duration_ratio": "Cycle le plus long (par rapport au profil) encore autorisé à correspondre" + "dtw_bandwidth": "Étape 3 : bande de déformation Sakoe-Chiba (0 = DTW désactivé ; défaut 0,2 = 20 % de la longueur du cycle)", + "corr_weight": "Étape 2 : équilibre entre la forme de la courbe (corrélation) et le niveau de puissance (MAE) ; défaut 0,45", + "duration_weight": "Étape 4 : force avec laquelle la concordance de durée affecte le score final (défaut 0,22)", + "energy_weight": "Étape 4 : force avec laquelle la concordance d'énergie affecte le score final (défaut 0,22)", + "profile_match_min_duration_ratio": "Étape 1 : durée de cycle minimale en fraction de la durée du profil ; défaut 0,1 (10 %)", + "profile_match_max_duration_ratio": "Étape 1 : durée de cycle maximale en fraction de la durée du profil ; défaut 1,5 (150 %)", + "keep_min_score": "Étape 2 : score plancher pour rester en lice ; le défaut 0,1 admet les candidats faibles, les étapes suivantes sélectionnent le meilleur", + "dtw_blend": "Étape 3 : 0 = score central uniquement, 1 = score DTW uniquement, 0,5 = mélange égal (défaut)", + "dtw_ensemble_w": "Étape 3 (mode ensemble) : poids sur L1 normalisé vs DTW dérivé ; le défaut 0,7 favorise la sensibilité au niveau", + "dtw_ddtw_scale": "Étape 3 : distance de demi-saturation DDTW ; plus petit = plus sensible à la forme (défaut 30)", + "dtw_refine_top_n": "Étape 3 : candidats réévalués par DTW ; augmentez à 7-9 si le profil correct se classe 4e ou 5e (défaut 5)", + "duration_scale": "Étape 4 : log-ratio où la concordance de durée est divisée par deux ; plus petit = pénalité plus stricte (défaut 0,175)", + "energy_scale": "Étape 4 : log-ratio où la concordance d'énergie est divisée par deux ; plus petit = pénalité plus stricte (défaut 0,25)" }, "phase_desc": { "anti_crease": "Courts culbutages occasionnels après completion pour réduire le froissement du linge.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Signaux externes optionnels : déclencheur de fin, capteur de porte, interrupteur de pause et rappel de déchargement.", "label": "Déclencheurs et portes" + }, + "phase_eta": { + "label": "Temps restant", + "intro": "Temps restant tenant compte des phases, pour les machines dont la durée de cycle dépend de la température ou de l'essorage." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Prix ​​fixe par kWh utilisé pour les chiffres de coût lorsqu'aucune entité de prix en direct n'est définie ci-dessus.", "label": "Prix de l'énergie statique (par kWh)" }, + "energy_sensor": { + "doc": "Compteur d'énergie cumulé facultatif (total_increasing en kWh/Wh, par ex. le compteur de consommation totale de la prise elle-même). Lorsqu'il est défini, l'énergie indiquée pour chaque cycle provient de la différence de ce compteur entre le début et la fin, ce qui évite le sous-comptage provoqué par l'intégration d'un capteur de puissance qui se met à jour lentement. Revient à la valeur intégrée si la lecture est absente, si son unité est inconnue ou si le delta n'est pas positif. Laissez vide pour continuer à intégrer le capteur de puissance.", + "label": "Entité du compteur d'énergie" + }, "expose_debug_entities": { "doc": "Publiez des entités HA de diagnostic supplémentaires (correspondance à la confiance, à l'ambiguïté, aux éléments internes de l'état). Désactivé maintient la liste des entités propre pour une utilisation normale.", "label": "Exposer les entités de débogage" @@ -1323,6 +1345,38 @@ "show_contributor": { "label": "Afficher les noms des contributeurs", "doc": "Afficher l'attribution « par » sur les appareils de la communauté et les cycles de référence." + }, + "enable_phase_matching": { + "label": "Temps restant tenant compte des phases", + "doc": "Découpe chaque cycle en cours en phases (chauffage, lavage, essorage) et répartit le temps restant par phase, mélangé à l'estimation classique - en s'appuyant sur la répartition par phases en début de cycle et sur l'estimation classique vers la fin. Cela personnalise le compte à rebours en fonction de la durée réelle de chauffage et de fonctionnement de votre machine, ce qui est le plus perceptible dans la première moitié du cycle. Désactivé = uniquement l'estimation classique. Seul l'affichage du temps restant est concerné ; la reconnaissance des programmes et la détection des cycles restent inchangées." + }, + "keep_min_score": { + "label": "Score min de correspondance", + "doc": "Score de similarité minimum qu'un candidat doit atteindre pour rester dans la course. Le défaut 0,1 est délibérément permissif - il admet même les candidats faibles et s'appuie sur les étapes suivantes pour trouver la meilleure correspondance. Augmentez-le pour éliminer les profils improbables plus tôt ; réduisez-le pour élargir le pool de candidats initial." + }, + "dtw_blend": { + "label": "Fusion Warp", + "doc": "Dans quelle mesure le score d'alignement par déformation temporelle (DTW) remplace le score central de l'étape 2. 0 = utiliser uniquement le score de l'étape 2, 1 = utiliser uniquement le score DTW, 0,5 (défaut) = mélange égal. Augmentez-le pour vous fier davantage à l'alignement Warp lorsque les programmes ont des niveaux de puissance similaires mais des schémas temporels différents." + }, + "dtw_ensemble_w": { + "label": "Mix Warp ensemble", + "doc": "En mode DTW ensemble (le défaut), le poids sur L1 normalisé par rapport au DTW dérivé (DDTW). 1,0 = tout L1 normalisé, 0 = tout DDTW, 0,7 = défaut. La variante L1 normalisée compare les niveaux de puissance ; la variante dérivée réagit à la façon dont la puissance évolue dans le temps. Ensemble mélange les deux." + }, + "dtw_ddtw_scale": { + "label": "Échelle Warp dérivé", + "doc": "Distance de demi-saturation pour la notation DTW dérivé. Le score est divisé par deux quand la distance DDTW atteint cette valeur. Plus petit = plus sensible aux différences de forme. Le défaut 30 est calibré sur les traces de puissance typiques des appareils. N'affecte que les modes DTW ensemble et ddtw." + }, + "dtw_refine_top_n": { + "label": "Nb candidats affinés Warp", + "doc": "Nombre de candidats de l'étape 2 réévalués par DTW. DTW est plus coûteux, il n'est donc appliqué qu'aux N meilleurs candidats. Défaut 5. Augmentez-le (à 7-9) si le profil correct n'atteint parfois que la 4e ou 5e place après l'étape 2 - DTW peut le sauver. Baisser accélère légèrement la mise en correspondance." + }, + "duration_scale": { + "label": "Précision durée", + "doc": "Précision de la pénalité de concordance de durée à l'étape 4. C'est le log-ratio auquel le score de concordance est divisé par deux. Plus petit = plus strict : un écart de durée pénalise davantage. Le défaut 0,175 correspond à environ 18 % de tolérance de durée à mi-poids. À combiner avec la Pondération de durée." + }, + "energy_scale": { + "label": "Précision énergie", + "doc": "Précision de la pénalité de concordance d'énergie. Plus petit = plus strict : un écart d'énergie pénalise davantage. Le défaut 0,25 est intentionnellement plus indulgent que la Précision de durée, car l'énergie varie avec la charge. À combiner avec la Pondération d'énergie." } }, "setting_group": { @@ -1571,24 +1625,24 @@ "lower": "Étiquette plus de cycles automatiquement ; certains peuvent être mal identifiés" }, "duration_tolerance": { - "higher": "Accepte une plage de durée plus large — correspondance plus flexible", - "lower": "Exige une durée plus proche — plus strict, peut rejeter des cycles inhabituels" + "higher": "Accepte une plage de durée plus large – correspondance plus flexible", + "lower": "Exige une durée plus proche – plus strict, peut rejeter des cycles inhabituels" }, "end_energy_threshold": { "higher": "Clôture uniquement les cycles ayant consommé une énergie significative", "lower": "Clôture aussi les cycles courts ou à faible consommation" }, "end_repeat_count": { - "higher": "Exige que le signal de fin se répète davantage — plus robuste, légèrement plus lent", - "lower": "Termine après moins de répétitions — détection plus rapide, risque de fausse fin accru" + "higher": "Exige que le signal de fin se répète davantage – plus robuste, légèrement plus lent", + "lower": "Termine après moins de répétitions – détection plus rapide, risque de fausse fin accru" }, "learning_confidence": { - "higher": "N'apprend que des correspondances très confiantes — plus lent mais plus fiable", + "higher": "N'apprend que des correspondances très confiantes – plus lent mais plus fiable", "lower": "Apprend à partir de plus de cycles ; le modèle peut être moins précis" }, "min_off_gap": { "higher": "Repos plus long requis avant le démarrage d'un nouveau cycle", - "lower": "Un bref repos déclenche un nouveau cycle — des cycles successifs peuvent être scindés" + "lower": "Un bref repos déclenche un nouveau cycle – des cycles successifs peuvent être scindés" }, "min_power": { "higher": "Moins sensible à la consommation au repos ; peut manquer des programmes très silencieux", @@ -1599,24 +1653,24 @@ "lower": "Arrête un cycle bloqué plus tôt" }, "off_delay": { - "higher": "Plus de temps pour les pauses de l'appareil — gère les longues phases de trempage ou de séchage", - "lower": "Détection de fin plus rapide — adapté aux appareils marche/arrêt nets" + "higher": "Plus de temps pour les pauses de l'appareil – gère les longues phases de trempage ou de séchage", + "lower": "Détection de fin plus rapide – adapté aux appareils marche/arrêt nets" }, "profile_match_threshold": { "higher": "Ne valide que les correspondances très confiantes ; plus de cycles restent non étiquetés", "lower": "Fait correspondre plus de cycles ; certains peuvent être légèrement erronés" }, "running_dead_zone": { - "higher": "Ignore les brèves pointes de puissance au repos — moins sensible au bruit", - "lower": "Réagit à des impulsions plus courtes — capte les activités transitoires rapides" + "higher": "Ignore les brèves pointes de puissance au repos – moins sensible au bruit", + "lower": "Réagit à des impulsions plus courtes – capte les activités transitoires rapides" }, "start_threshold_w": { "higher": "Évite les faux démarrages dus à de brèves pointes de puissance", "lower": "Capte les programmes à faible puissance ; peut se déclencher sur du bruit" }, "stop_threshold_w": { - "higher": "Termine le cycle plus vite — peut se clôturer lors d'une pause", - "lower": "Attend plus longtemps pour terminer — moins de fins prématurées" + "higher": "Termine le cycle plus vite – peut se clôturer lors d'une pause", + "lower": "Attend plus longtemps pour terminer – moins de fins prématurées" }, "watchdog_interval": { "higher": "Phases silencieuses plus longues autorisées ; moins de faux arrêts forcés", @@ -1767,6 +1821,69 @@ }, "pg_sweep": { "optimize": "Optimiser : {param}" + }, + "pg_detail": { + "simulate": "Simuler le cycle" + }, + "split": { + "apply": "Division du cycle" + }, + "trim": { + "apply": "Rognage du cycle" + }, + "merge": { + "apply": "Fusion des cycles" + }, + "rebuild": { + "envelopes": "Reconstruction des enveloppes" + }, + "cancelling": "Annulation..." + }, + "setup": { + "hdr": { + "card": "Configuration de l'appareil", + "healthy_chip": "Configuration terminée" + }, + "phase0": { + "washer": "WashData détecte déjà vos cycles. Enregistrez votre premier cycle pour activer les noms de programmes et les estimations de durée.", + "dishwasher": "WashData surveille. Les lave-vaisselle ont des cycles complexes – l'enregistrement du premier cycle est fortement recommandé. Si un cycle détecté dure trop longtemps, utilisez l'éditeur de cycles pour le rogner avant de l'enregistrer comme profil.", + "generic": "WashData surveille. Enregistrez ou étiquetez un cycle détecté pour commencer à créer des profils." + }, + "phase1a": { + "labelled": "Bon début – votre premier programme est enregistré. Pour des données optimales, envisagez d'enregistrer votre prochain cycle avec le widget d'enregistrement." + }, + "phase1b": { + "recorded": "Votre enregistrement a été sauvegardé sous {profile_name}. Enregistrez ou étiquetez maintenant vos autres programmes courants pour améliorer la couverture." + }, + "phase1c": { + "verify": "Vous avez {count} programmes de la communauté. Lancez un cycle pour vérifier que WashData le reconnaît correctement – la correspondance s'améliorera au fil de l'historique de votre appareil." + }, + "phase2": { + "cluster": "WashData a observé {count} cycles qui ne correspondent à aucun programme enregistré – ils se ressemblent entre eux. Voulez-vous créer un nouveau profil pour eux ?", + "unmatched": "Votre dernier cycle ne correspond à aucun programme enregistré. S'agit-il d'un nouveau programme ?" + }, + "phase3": { + "suggestions": "WashData a des recommandations de paramètres basées sur votre historique de cycles – examinez-les pour améliorer la précision de détection.", + "groups": "Certains de vos profils ressemblent au même programme à différentes températures. Organisez-les en groupe pour une meilleure correspondance.", + "phases": "Ajoutez des phases de programme à {profile_name} pour des estimations de temps restant plus précises." + }, + "phase4": { + "healthy": "Cet appareil est entièrement configuré ({profile_count} profils)." + }, + "cta": { + "start_recording": "Démarrer l'enregistrement", + "label_detected_cycle": "J'ai déjà un cycle détecté – l'étiqueter à la place", + "browse_cycles": "Parcourir les cycles pour en étiqueter un autre", + "view_profiles": "Voir vos profils", + "create_from_cluster": "Créer un profil à partir de ces cycles", + "create_profile": "Créer un profil pour ce programme", + "review_suggestions": "Examiner les suggestions", + "organise_profiles": "Organiser les profils", + "configure_phases": "Configurer les phases", + "skip_step": "Ignorer cette étape", + "skip_forever": "Ne plus afficher", + "hide_guidance": "Masquer le guide", + "show_guidance": "Afficher le guide" } } } diff --git a/custom_components/ha_washdata/translations/panel/hr.json b/custom_components/ha_washdata/translations/panel/hr.json index 6ec8a0c..1c56cbf 100644 --- a/custom_components/ha_washdata/translations/panel/hr.json +++ b/custom_components/ha_washdata/translations/panel/hr.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "Otkrivene su {n} anomalije (npr. vrata otvorena usred ciklusa) — otvorite da ih vidite na grafikonu", + "artifact_tip": "Otkrivene su {n} anomalije (npr. vrata otvorena usred ciklusa) – otvorite da ih vidite na grafikonu", "auto": "(automatski otkriveno)", "built_in_tag": "ugrađeni", "declining": "↘ u opadanju", "energy_low": "Manja energija nego inače", "energy_spike": "Veća energija nego inače", "fair_fit": "prihvatljiva kvaliteta", - "fair_fit_tip": "Umjerena dosljednost podudaranja — neki ciklusi dodijeljeni ovom profilu imaju niže rezultate pouzdanosti. Označite više ciklusa ili ponovno snimite profil kako biste poboljšali točnost.", + "fair_fit_tip": "Umjerena dosljednost podudaranja – neki ciklusi dodijeljeni ovom profilu imaju niže rezultate pouzdanosti. Označite više ciklusa ili ponovno snimite profil kako biste poboljšali točnost.", "feedback_requested": "Zatražene povratne informacije", "golden_cycle": "Snimljeni referentni ciklus", "improving": "↗ poboljšanje", @@ -87,7 +87,7 @@ "on_cycle_finished": "Na ciklusu završen", "on_cycle_started": "Na početku ciklusa", "pause_cycle": "Pauza", - "pause_cycle_tip": "Pauziraj pokrenuti ciklus — uređaj će se nastaviti od mjesta na kojem je stao", + "pause_cycle_tip": "Pauziraj pokrenuti ciklus – uređaj će se nastaviti od mjesta na kojem je stao", "pg_apply_device": "Primijeni na uređaj", "pg_autofill": "Automatski popuni", "play": "Reproduciraj", @@ -97,8 +97,8 @@ "process_tip": "Spremi snimljeni trag kao novi ili postojeći profil", "rebuild": "Ponovno izgradite omotnice", "rebuild_envelope": "Obnovite omotnicu", - "rebuild_tip": "Ponovo izračunaj očekivanu omotnicu snage (min/maks pojas) za sve profile iz njihovih označenih ciklusa — pokrenite nakon označavanja novih ciklusa ili ispravljanja starih", - "rec_start_tip": "Počni snimanje traga snage uređaja — pokrenite neposredno prije pokretanja ciklusa", + "rebuild_tip": "Ponovo izračunaj očekivanu omotnicu snage (min/maks pojas) za sve profile iz njihovih označenih ciklusa – pokrenite nakon označavanja novih ciklusa ili ispravljanja starih", + "rec_start_tip": "Počni snimanje traga snage uređaja – pokrenite neposredno prije pokretanja ciklusa", "rec_stop": "Zaustavi", "rec_stop_tip": "Zaustavi snimanje i zadrži snimljeni trag radi pregleda", "record": "Započnite snimanje", @@ -231,7 +231,7 @@ "interval": "Trebao bi biti najmanje 2× Interval uzorkovanja ({si} s)", "sampling": "Interval uzorkovanja trebao bi biti najviše pola Intervala nadzora ({wi} s)" }, - "settings_banner": "{n} sukob{s} postavki — provjerite istaknute odjeljke i ispravite prije pohrane.", + "settings_banner": "{n} sukob{s} postavki – provjerite istaknute odjeljke i ispravite prije pohrane.", "settings_banner_btn": "Idi na prvi" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Prikaži očekivani sloj krivulje (podudarni profil, narančasto)", "show_raw": "Prikaži prekidač sirovih očitanja utičnice u živom grafu snage", "sparkline": "Nedavni trend trajanja ciklusa", - "stage2": "Faza 2 — osnovna sličnost", - "stage3": "Faza 3 — DTW", - "stage4": "Faza 4 — podudaranje", + "stage2": "Faza 2 – osnovna sličnost", + "stage3": "Faza 3 – DTW", + "stage4": "Faza 4 – podudaranje", "status": "Status", "tail_trim": "Podrezivanje repa", "timer_auto_pause": "Automatska pauza", @@ -561,7 +561,12 @@ "flags": "Oznake", "include_phase_map": "Uključi kartu faza", "include_settings": "Uključi postavke", - "show_contributor": "Prikaži suradnika" + "show_contributor": "Prikaži suradnika", + "task_pg_detail": "Simuliraj ciklus", + "task_split": "Dijeljenje ciklusa", + "task_trim": "Obrezivanje ciklusa", + "task_merge": "Spajanje ciklusa", + "task_rebuild": "Ponovna izgradnja ovojnica" }, "log": { "all_levels": "Sve razine", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Palo ispod uobičajenog pojasa snage ~{n}s.", "artifact_footer": "Istaknuto na gornjem grafikonu. To su prolazni artefakti (npr. vrata su se otvorila usred ciklusa), a ne nužno problemi.", "artifact_header": "Tijekom ovog ciklusa otkriveno je {n} anomalija", - "artifact_pause_detail": "Snaga je pala na gotovo nulu za ~{n}s, a zatim se nastavila — vjerojatno su vrata otvorena usred ciklusa ili je ciklus bio pauziran.", + "artifact_pause_detail": "Snaga je pala na gotovo nulu za ~{n}s, a zatim se nastavila – vjerojatno su vrata otvorena usred ciklusa ili je ciklus bio pauziran.", "artifact_spike_detail": "Poraslo iznad uobičajenog pojasa snage ~{n}s.", "auto_label_intro": "Dodijelite profile neoznačenim ciklusima čija pouzdanost podudaranja prelazi prag.", "automations_intro": "WashData pokreće događaje {start} / {end} i izlaže entitete, pa je obavijesti i radnje najbolje graditi kao normalne automatizacije Home Assistanta. Automatizacije koje koriste ovaj uređaj prikazane su ispod.", @@ -654,10 +659,10 @@ "collecting_data": "Prikupljanje podataka: još {need} ciklusa prije početka finog podešavanja ({current}/{min}).", "compare_overlay_profiles": "Prekrivajući profili (slabo)", "compare_profiles_tip": "Prekrijte druge omotnice profila na gornjoj tablici kako biste vidjeli koja najbolje odgovara ovom ciklusu.", - "compare_selected_cycles": "Odabrani ciklusi (puno) — prikaži / sakrij", + "compare_selected_cycles": "Odabrani ciklusi (puno) – prikaži / sakrij", "consider_new_profile": "Razmislite o stvaranju novog profila.", "coverage_gap": "nedavni ciklusi nemaju odgovarajući profil ({pct}% od zadnjih 30).", - "coverage_gap_similar_cycles": "Pronađeno {count} sličnih neoznačenih ciklusa — izradite profil da biste ih počeli podudarati.", + "coverage_gap_similar_cycles": "Pronađeno {count} sličnih neoznačenih ciklusa – izradite profil da biste ih počeli podudarati.", "cycles_deleted": "{count} ciklus(a) izbrisano", "enough_data": "Dovoljno podataka za učenje ({current}/{min} ciklusa).", "export_description": "Izvezite sve profile i cikluse u JSON ili vratite iz prethodnog izvoza.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modeli prilagođeni ovom stroju.", "ml_loading": "učitavanje ML-a…", "ml_settings_intro": "Dva neovisna prekidača: jedan primjenjuje modele dok ciklus radi, drugi omogućuje WashData da ih fino prilagodi vašem stroju tijekom vremena.", - "name_first_program": "Imate dovoljno ciklusa — imenujte svoj prvi program da započnete podudaranje.", + "name_first_program": "Imate dovoljno ciklusa – imenujte svoj prvi program da započnete podudaranje.", "near_duplicate_cluster": "otkriven je skoro duplikat profila. Grupiranje omogućuje uparivanje pouzdanog odabira sličnih programa (npr. isti program na različitoj temperaturi/centrifugi).", "no_cycles_match": "Nijedan ciklus ne odgovara trenutnom filtru.", "no_cycles_profile": "Nema ciklusa za ovaj profil.", @@ -696,7 +701,7 @@ "no_devices": "Još nije konfiguriran nijedan WashData uređaj.", "no_envelope": "Još nema omotnice - obnovite nakon ciklusa označavanja.", "no_envelope_overlay": "Nema dostupnih omotnica za preklapanje.", - "no_fine_tuned": "Još ništa fino podešeno — WashData koristi svoje ugrađene modele.", + "no_fine_tuned": "Još ništa fino podešeno – WashData koristi svoje ugrađene modele.", "no_logs": "Još nema zapisa u međuspremniku.", "no_maintenance": "Još nema zabilježenog održavanja.", "no_match_yet": "Još nema pokušaja podudaranja - ovo se popunjava tijekom ciklusa izvođenja.", @@ -713,7 +718,7 @@ "notify_services_hint": "Koristite ID-ove servisa {entity} (odvojene zarezima za više servisa). Varijable predloška: {vars}.", "old_actions_warning": "Konfiguriran sa starim uređivačem akcija (sada uklonjen). I dalje se aktiviraju na događaje ciklusa, ali se više ne mogu uređivati ​​ovdje. Pretvorite ih u normalnu automatizaciju ili ih uklonite.", "onboarding_progress": "{n} / 3 ciklusa promatrano", - "onboarding_watching": "Koristite uređaj uobičajeno — WashData promatra. Nakon 3 ciklusa započet će podudaranje programa.", + "onboarding_watching": "Koristite uređaj uobičajeno – WashData promatra. Nakon 3 ciklusa započet će podudaranje programa.", "pending_feedback": "Povratne informacije o otkrivanju na čekanju", "performance_trend": "Trend izvedbe ({n} ciklusa)", "pg_analysis_empty": "Učitajte ciklus za prikaz analize podudaranja.", @@ -763,7 +768,7 @@ "setting_changed": "Promijenjeno s {old} na {new} dana {date}", "settings_basic_note": "Prikazuju se osnovne postavke. Prebacite na Napredno za cijeli popis.", "shape_drift_advisory": "⚠ Oblik se mijenja", - "shape_drift_detail": "Uzorak snage ovog profila promijenio se s vremenom — moguće habanje uređaja ili potrebno održavanje (npr. uklanjanje kamenca, čišćenje filtera).", + "shape_drift_detail": "Uzorak snage ovog profila promijenio se s vremenom – moguće habanje uređaja ili potrebno održavanje (npr. uklanjanje kamenca, čišćenje filtera).", "show_all_settings": "Prikaži sve postavke", "showing_suggestions": "Prikazuje se {count} postavka s prijedlozima.", "split_intro": "Pritisnite grafikon da biste dodali ili uklonili točku razdvajanja ili automatsko otkrivanje praznina u mirovanju. Svaki rezultirajući segment može dobiti svoj profil.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Vraćanje neuspješno: {error}", "toast_reverted": "{key} vraćeno", "toast_save_failed": "Spremanje neuspješno: {error}", - "trend_duration_longer": "Dulje trajanje ({pct}%/ciklus) — nedavni prosjek {avg}", - "trend_duration_shorter": "Trajanje je sve kraće ({pct}%/ciklus) — nedavni prosjek {avg}", + "trend_duration_longer": "Dulje trajanje ({pct}%/ciklus) – nedavni prosjek {avg}", + "trend_duration_shorter": "Trajanje je sve kraće ({pct}%/ciklus) – nedavni prosjek {avg}", "trend_energy_down": "Energija opada ({pct}%/ciklus)", - "trend_energy_up": "Energija raste ({pct}%/ciklus) — nedavni prosjek {avg}", + "trend_energy_up": "Energija raste ({pct}%/ciklus) – nedavni prosjek {avg}", "trim_intro": "Povucite crvene ručice ili unesite vrijednosti. Sve izvan prozora je uklonjeno.", "tuning_suggestions_available": "{count} prijedlog podešavanja dostupan iz promatranih ciklusa. Pojavljuju se pored relevantnih polja.", "unsure_detected_prefix": "WashData nije siguran da je otkriven", @@ -857,7 +862,9 @@ "share_guidelines_title": "Prije dijeljenja", "store_download_device_intro": "Preuzmite postavke uređaja iz zajednice i primijenite ih na novi ili postojeći uređaj", "store_share_device_intro": "Podijelite programe svog uređaja (profile + referentne cikluse) sa zajednicom. Postavke su opcionalne.", - "share_profile_no_cycles": "Profil '{p}' nema ⭐ referentnih ciklusa -- bit će preskočen osim ako nemate {n}+ potvrđenih pokretanja" + "share_profile_no_cycles": "Profil '{p}' nema ⭐ referentnih ciklusa -- bit će preskočen osim ako nemate {n}+ potvrđenih pokretanja", + "advisory_phase_inconsistent": "Čini se da '{name}' miješa različite programe ili temperature - njegovi se ciklusi griju vrlo različito dugo. Podjela na zasebne profile (npr. po temperaturi) poboljšat će podudaranje i procjene vremena.", + "advisory_phase_inconsistent_title": "⚠ Možda pomiješani programi" }, "phase_desc": { "anti_crease": "Povremena kratka prevrtanja nakon završetka kako bi se smanjile bore.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Neobavezni vanjski signali: okidač završetka, senzor vrata, prekidač za pauzu i podsjetnik za istovar.", "label": "Okidači i vrata" + }, + "phase_eta": { + "label": "Preostalo vrijeme", + "intro": "Preostalo vrijeme koje uzima u obzir faze, za uređaje čija duljina ciklusa ovisi o temperaturi ili centrifugi." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Fiksna cijena po kWh koja se koristi za podatke o troškovima kada iznad nije postavljen entitet stvarne cijene.", "label": "Statička cijena energije (po kWh)" }, + "energy_sensor": { + "doc": "Neobavezno kumulativno brojilo energije (total_increasing kWh/Wh, npr. vlastito brojilo ukupne potrošnje utičnice). Kada je postavljeno, energija prijavljena za svaki ciklus uzima se iz razlike očitanja ovog brojila između početka i kraja, čime se izbjegava podbrojavanje koje nastaje integriranjem sporo izvještavajućeg senzora snage. Vraća se na integriranu vrijednost ako očitanje nedostaje, njegova je jedinica nepoznata ili razlika nije pozitivna. Ostavite prazno da nastavite integrirati senzor snage.", + "label": "Entitet brojila energije" + }, "expose_debug_entities": { "doc": "Objavite dodatne dijagnostičke HA entitete (povjerenje podudaranja, dvosmislenost, interno stanje). Isključeno održava popis entiteta čistim za normalnu upotrebu.", "label": "Izloži entitete za otklanjanje pogrešaka" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Prikaži ime suradnika na profilima preuzetim iz trgovine zajednice" + }, + "enable_phase_matching": { + "label": "Preostalo vrijeme prema fazama", + "doc": "Svaki aktivni ciklus dijeli na faze (grijanje, pranje, centrifuga) i raspoređuje preostalo vrijeme po fazama, spojeno s klasičnom procjenom - na početku ciklusa oslanja se na proračun faza, a pri kraju na klasičnu procjenu. To prilagođava odbrojavanje tome koliko se vaš uređaj stvarno grije i radi, što je najuočljivije u prvoj polovici ciklusa. Isključeno = samo klasična procjena. Utječe samo na prikaz preostalog vremena; podudaranje programa i otkrivanje ciklusa ostaju nepromijenjeni." + }, + "keep_min_score": { + "label": "Min. ocjena podudaranja", + "doc": "Minimalna ocjena sličnosti potrebna kandidatu za ostanak u utrci podudaranja. Zadana vrijednost 0,1 je namjerno blaga - dopušta čak i slabe kandidate i oslanja se na kasnijе faze za pronalazak najboljeg podudaranja. Povećajte za ranije odbacivanje neprobabilnih profila; smanjite za proširenje početnog skupa kandidata." + }, + "dtw_blend": { + "label": "Miješanje deformacije", + "doc": "Koliko DTW ocjena poravnanja zamjenjuje osnovnu ocjenu Faze 2. 0 = koristi samo ocjenu Faze 2, 1 = koristi samo DTW ocjenu, 0,5 (zadano) = jednako miješanje. Povećajte za veće oslanjanje na warp poravnanje kada programi imaju slične razine snage ali različite obrasce tajminga." + }, + "dtw_ensemble_w": { + "label": "Miks kombinacije DTW", + "doc": "U kombiniranom DTW načinu (zadanom), težina skaliranog L1 nasuprot derivatnog DTW (DDTW). 1,0 = sve skalirano L1, 0 = sve DDTW, 0,7 = zadano. Skalirana L1 varijanta uspoređuje razine snage; derivatna varijanta reagira na promjene snage kroz vrijeme. Kombinirani način spaja oboje." + }, + "dtw_ddtw_scale": { + "label": "Skala derivatne deformacije", + "doc": "Udaljenost polu-zasićenja za ocjenjivanje derivatnim DTW. Ocjena se prepolovi kada je DDTW udaljenost jednaka ovoj vrijednosti. Manje = osjetljivije na razlike u obliku. Zadana vrijednost 30 je kalibrirana za tipične tragove snage kućanskih aparata. Utječe samo na kombinirani i ddtw DTW način." + }, + "dtw_refine_top_n": { + "label": "Broj preciziranih kandidata", + "doc": "Koliko prvih kandidata Faze 2 DTW ponovo ocjenjuje. DTW je skuplji pa se primjenjuje samo na N najboljih kandidata. Zadano 5. Povećajte (na 7-9) ako ispravni profil ponekad dostigne samo 4. ili 5. mjesto nakon Faze 2 - DTW ga može spasiti. Smanjenje malo ubrzava podudaranje." + }, + "duration_scale": { + "label": "Oštrість trajanja", + "doc": "Oštrina kazne za nepodudaranje trajanja u Fazi 4. Ovo je omjer logaritma pri kojemu se ocjena slaganja prepolovi. Manje = strože: nepodudaranje trajanja više snižava ocjenu. Zadana vrijednost 0,175 odgovara toleranciji trajanja od oko 18% pri polu-težini. Koristite zajedno s Težinom trajanja." + }, + "energy_scale": { + "label": "Oštrina energije", + "doc": "Oštrina kazne za nepodudaranje energije u Fazi 4. Manje = strože: nepodudaranje energije više snižava ocjenu. Zadana vrijednost 0,25 je namjerno blaža od Oštrine trajanja jer energija varira s opterećenjem. Koristite zajedno s Težinom energije." } }, "setting_group": { @@ -1424,24 +1471,24 @@ "lower": "Više ciklusa se automatski označava; neki mogu biti pogrešno identificirani" }, "duration_tolerance": { - "higher": "Prihvaća širi raspon trajanja — fleksibilnije podudaranje", - "lower": "Zahtijeva bliže podudaranje trajanja — strože, ali može odbiti neobične cikluse" + "higher": "Prihvaća širi raspon trajanja – fleksibilnije podudaranje", + "lower": "Zahtijeva bliže podudaranje trajanja – strože, ali može odbiti neobične cikluse" }, "end_energy_threshold": { "higher": "Zatvara samo cikluse sa značajnom potrošnjom energije", "lower": "Zatvara i kraće ili manje energetske cikluse" }, "end_repeat_count": { - "higher": "Zahtijeva više ponavljanja signala kraja — pouzdanije, malo sporije", - "lower": "Završava nakon manje ponavljanja — brže otkrivanje, veći rizik od lažnog kraja" + "higher": "Zahtijeva više ponavljanja signala kraja – pouzdanije, malo sporije", + "lower": "Završava nakon manje ponavljanja – brže otkrivanje, veći rizik od lažnog kraja" }, "learning_confidence": { - "higher": "Uči samo iz visoko pouzdanih podudaranja — sporije, ali pouzdanije", + "higher": "Uči samo iz visoko pouzdanih podudaranja – sporije, ali pouzdanije", "lower": "Uči iz više ciklusa; model može biti manje precizan" }, "min_off_gap": { "higher": "Potreban je duži odmor prije početka novog ciklusa", - "lower": "Kratki odmor pokreće novi ciklus — uzastopni ciklusi se mogu razdijeliti" + "lower": "Kratki odmor pokreće novi ciklus – uzastopni ciklusi se mogu razdijeliti" }, "min_power": { "higher": "Manje osjetljiv na potrošnju u mirovanju; može propustiti tihe programe", @@ -1452,24 +1499,24 @@ "lower": "Zaglavljeni ciklus se prisilno zaustavlja ranije" }, "off_delay": { - "higher": "Više vremena za pauze uređaja — prikladno za duge faze namakanja ili sušenja", - "lower": "Brže otkrivanje kraja — dobro za uređaje s čistim uključivanjem/isključivanjem" + "higher": "Više vremena za pauze uređaja – prikladno za duge faze namakanja ili sušenja", + "lower": "Brže otkrivanje kraja – dobro za uređaje s čistim uključivanjem/isključivanjem" }, "profile_match_threshold": { "higher": "Potvrđuje samo visoko pouzdana podudaranja; više ciklusa ostaje neoznačeno", "lower": "Podudara više ciklusa; neki mogu biti blago pogrešni" }, "running_dead_zone": { - "higher": "Zanemaruje kratke skokove snage u mirovanju — manje osjetljiv na šum", - "lower": "Reagira na kraće impulse snage — otkriva brzu prolaznu aktivnost" + "higher": "Zanemaruje kratke skokove snage u mirovanju – manje osjetljiv na šum", + "lower": "Reagira na kraće impulse snage – otkriva brzu prolaznu aktivnost" }, "start_threshold_w": { "higher": "Sprječava lažne startove od kratkih skokova snage", "lower": "Otkriva programe s niskom snagom; može reagirati na šum" }, "stop_threshold_w": { - "higher": "Završava ciklus brže — može zatvoriti za vrijeme pauze", - "lower": "Dulje čeka na završetak — manje prijevremenih završetaka" + "higher": "Završava ciklus brže – može zatvoriti za vrijeme pauze", + "lower": "Dulje čeka na završetak – manje prijevremenih završetaka" }, "watchdog_interval": { "higher": "Dopušta dulje tihe faze; manje lažnih prisilnih zaustavljanja", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Sekunde iznad praga za potvrdu pokretanja", "start_threshold_w": "Najmanja snaga u vatima da se broji kao pokrenuto", "stop_threshold_w": "Ispod ovoga uređaj se broji kao isključen", - "dtw_bandwidth": "Koliko vremenskog iskrivljenja dopušta podudaranje oblika", - "corr_weight": "Težina oblika krivulje u odnosu na razinu snage pri podudaranju", - "duration_weight": "Koliko slaganje trajanja utječe na podudaranje", - "energy_weight": "Koliko slaganje potrošnje energije utječe na podudaranje", - "profile_match_min_duration_ratio": "Najkraće izvođenje (u odnosu na profil) koje se još može podudarati", - "profile_match_max_duration_ratio": "Najdulje izvođenje (u odnosu na profil) koje se još može podudarati" + "dtw_bandwidth": "Faza 3: warp opseg Sakoe-Chiba (0 = DTW isključen; zadano 0,2 = 20% duljine ciklusa)", + "corr_weight": "Faza 2: ravnoteža između oblika krivulje (korelacija) i razine snage (MAE); zadano 0,45", + "duration_weight": "Faza 4: koliko jako slaganje trajanja utječe na konačnu ocjenu (zadano 0,22)", + "energy_weight": "Faza 4: koliko jako slaganje energije utječe na konačnu ocjenu (zadano 0,22)", + "profile_match_min_duration_ratio": "Faza 1: minimalna duljina ciklusa kao udio trajanja profila; zadano 0,1 (10%)", + "profile_match_max_duration_ratio": "Faza 1: maksimalna duljina ciklusa kao udio trajanja profila; zadano 1,5 (150%)", + "keep_min_score": "Faza 2: minimalna ocjena za ostanak u utrci; zadano 0,1 dopušta slabe podudarnosti, kasniji koraci biraju najboljeg", + "dtw_blend": "Faza 3: 0 = samo osnovna ocjena, 1 = samo DTW ocjena, 0,5 = jednako miješanje (zadano)", + "dtw_ensemble_w": "Faza 3 (kombinirani način): težina skaliranog L1 nasuprot derivatnog DTW; zadano 0,7 favorizira razinu", + "dtw_ddtw_scale": "Faza 3: udaljenost polu-zasićenja DDTW; manja = osjetljivija na oblik (zadano 30)", + "dtw_refine_top_n": "Faza 3: kandidati koje DTW ponovo ocjenjuje; povećajte na 7-9 ako ispravni profil zauzme 4.-5. mjesto (zadano 5)", + "duration_scale": "Faza 4: omjer logaritma gdje se slaganje trajanja prepolovi; manje = stroža kazna (zadano 0,175)", + "energy_scale": "Faza 4: omjer logaritma gdje se slaganje energije prepolovi; manje = stroža kazna (zadano 0,25)" }, "col": { "profile_tip": "Naziv podudarnog programa. Bez oznake znači da se na kraju ciklusa nijedan profil nije podudarao.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimizacija: {param}" + }, + "pg_detail": { + "simulate": "Simuliraj ciklus" + }, + "split": { + "apply": "Dijeljenje ciklusa" + }, + "trim": { + "apply": "Obrezivanje ciklusa" + }, + "merge": { + "apply": "Spajanje ciklusa" + }, + "rebuild": { + "envelopes": "Ponovna izgradnja ovojnica" + }, + "cancelling": "Otkazujem..." + }, + "setup": { + "hdr": { + "card": "Postavljanje uređaja", + "healthy_chip": "Postavljanje dovršeno" + }, + "phase0": { + "washer": "WashData već otkriva vaše cikluse. Snimite prvi ciklus kako biste omogućili nazive programa i procjene vremena.", + "dishwasher": "WashData prati. Perilice posuđa imaju složene cikluse – snimanje prvog ciklusa je izrazito preporučeno. Ako otkriveni ciklus traje predugo, upotrijebite uređivač ciklusa za obrezivanje prije pohrane kao profila.", + "generic": "WashData prati. Snimite ili označite otkriveni ciklus kako biste počeli graditi profile." + }, + "phase1a": { + "labelled": "Dobar početak – vaš prvi program je pohranjen. Za najčišće podatke razmislite o snimanju sljedećeg ciklusa pomoću widgeta snimača." + }, + "phase1b": { + "recorded": "Vaše snimanje pohranjeno je kao {profile_name}. Sada snimite ili označite ostale uobičajene programe za izgradnju pokrivenosti." + }, + "phase1c": { + "verify": "Imate {count} programa od zajednice. Pokrenite ciklus kako biste provjerili prepoznaje li ih WashData ispravno – podudaranje će se poboljšati kako vaš uređaj gradi vlastitu povijest." + }, + "phase2": { + "cluster": "WashData je vidio {count} ciklusa koji ne odgovaraju nijednom pohranjenome programu – nalikuju jedni drugima. Želite li za njih stvoriti novi profil?", + "unmatched": "Vaš zadnji ciklus nije odgovarao nijednom pohranjenome programu. Je li to novi program?" + }, + "phase3": { + "suggestions": "WashData ima preporuke postavki temeljene na vašoj povijesti ciklusa – pregledajte ih kako biste poboljšali točnost otkrivanja.", + "groups": "Neki vaši profili izgledaju kao isti program pri različitim temperaturama. Organizirajte ih u grupu za bolje podudaranje.", + "phases": "Dodajte faze programa u {profile_name} za točnije procjene preostalog vremena." + }, + "phase4": { + "healthy": "Ovaj uređaj je potpuno postavljen ({profile_count} profila)." + }, + "cta": { + "start_recording": "Pokreni snimanje", + "label_detected_cycle": "Već imam otkriven ciklus – umjesto toga ga označi", + "browse_cycles": "Pregledaj cikluse za označavanje sljedećeg", + "view_profiles": "Prikaži svoje profile", + "create_from_cluster": "Stvori profil od ovih ciklusa", + "create_profile": "Stvori za njega profil", + "review_suggestions": "Preglej prijedloge", + "organise_profiles": "Organiziraj profile", + "configure_phases": "Konfiguriraj faze", + "skip_step": "Preskoči ovaj korak", + "skip_forever": "Ne prikazuj više", + "hide_guidance": "Sakrij upute", + "show_guidance": "Prikaži upute" } } } diff --git a/custom_components/ha_washdata/translations/panel/hu.json b/custom_components/ha_washdata/translations/panel/hu.json index 4c0391d..8bb13e7 100644 --- a/custom_components/ha_washdata/translations/panel/hu.json +++ b/custom_components/ha_washdata/translations/panel/hu.json @@ -87,7 +87,7 @@ "on_cycle_finished": "A ciklus végén", "on_cycle_started": "Beindult a ciklus", "pause_cycle": "Szünet", - "pause_cycle_tip": "Szüneteltesse a futó ciklust — a készülék onnan folytatja, ahol abbahagyta", + "pause_cycle_tip": "Szüneteltesse a futó ciklust – a készülék onnan folytatja, ahol abbahagyta", "pg_apply_device": "Alkalmazás az eszközre", "pg_autofill": "Automatikus kitöltés", "play": "Lejátszás", @@ -97,8 +97,8 @@ "process_tip": "Mentse el a rögzített nyomot új vagy meglévő profilként", "rebuild": "Borítékok újjáépítése", "rebuild_envelope": "Boríték újraépítése", - "rebuild_tip": "Számítsa újra az elvárt energiaburkolót (min/max sáv) az összes profilhoz a felcímkézett ciklusaik alapján — futtassa új ciklusok felcímkézése vagy régiek javítása után", - "rec_start_tip": "Kezdje el rögzíteni a készülék teljesítménynyomát — indítsa közvetlenül a ciklus futtatása előtt", + "rebuild_tip": "Számítsa újra az elvárt energiaburkolót (min/max sáv) az összes profilhoz a felcímkézett ciklusaik alapján – futtassa új ciklusok felcímkézése vagy régiek javítása után", + "rec_start_tip": "Kezdje el rögzíteni a készülék teljesítménynyomát – indítsa közvetlenül a ciklus futtatása előtt", "rec_stop": "Leállítás", "rec_stop_tip": "Állítsa le a rögzítést, és tartsa fenn a rögzített nyomot áttekintésre", "record": "Indítsa el a Felvételt", @@ -182,7 +182,7 @@ }, "attn_sub": "Mentés előtt javítsa az ütközéseket", "attn_title": "{n} beállítási ütközés{s}", - "settings_banner": "{n} beállítási ütközés{s} — ellenőrizze a kijelölt szakaszokat és javítsa ki mentés előtt.", + "settings_banner": "{n} beállítási ütközés{s} – ellenőrizze a kijelölt szakaszokat és javítsa ki mentés előtt.", "settings_banner_btn": "Ugrás az elsőhöz", "confidence": { "auto": "Legalább az Egyezési küszöb értékén kell lennie ({match})", @@ -451,9 +451,9 @@ "show_expected": "Várható görbefedvény megjelenítése (egyező profil, narancssárga)", "show_raw": "Nyers csatlakozó kapcsoló megjelenítése az élő teljesítménygrafikonon", "sparkline": "Legutóbbi ciklusidőtartam-trend", - "stage2": "2. szakasz — alapvető hasonlóság", - "stage3": "3. szakasz — DTW", - "stage4": "4. szakasz — egyezés", + "stage2": "2. szakasz – alapvető hasonlóság", + "stage3": "3. szakasz – DTW", + "stage4": "4. szakasz – egyezés", "status": "Állapot", "tail_trim": "Farokszegély(ek)", "timer_auto_pause": "Automatikus szünet", @@ -561,7 +561,12 @@ "flags": "Jelzők", "include_phase_map": "Fázistérkép belefoglalása", "include_settings": "Érzékelési és párosítási beállítások belefoglalása", - "show_contributor": "Közreműködők nevének megjelenítése" + "show_contributor": "Közreműködők nevének megjelenítése", + "task_pg_detail": "Ciklus szimulálása", + "task_split": "Ciklus felosztása", + "task_trim": "Ciklus levágása", + "task_merge": "Ciklusok egyesítése", + "task_rebuild": "Burkológörbék újraépítése" }, "log": { "all_levels": "Minden szinten", @@ -645,7 +650,7 @@ "artifact_dip_detail": "~{n}s-re a szokásos energiasáv alá esett.", "artifact_footer": "A fenti grafikonon kiemelve. Ezek átmeneti műtermékek (pl. a ciklus közepén kinyílt ajtó), nem feltétlenül problémák.", "artifact_header": "{n} rendellenesség észlelve ebben a ciklusban", - "artifact_pause_detail": "A teljesítmény ~{n}s-re közel nullára esett, majd visszatért — valószínűleg kinyílt az ajtó a ciklus közben, vagy a ciklus szünetelt.", + "artifact_pause_detail": "A teljesítmény ~{n}s-re közel nullára esett, majd visszatért – valószínűleg kinyílt az ajtó a ciklus közben, vagy a ciklus szünetelt.", "artifact_spike_detail": "~{n}s-re a szokásos energiasáv felett volt.", "auto_label_intro": "Rendeljen hozzá profilokat címkézetlen ciklusokhoz, amelyek egyezési megbízhatósága törli a küszöböt.", "automations_intro": "A WashData {start} / {end} eseményeket küld és entitásokat tesz elérhetővé, ezért az értesítések és műveletek legjobban normál Home Assistant automatizálásként építhetők fel. Az ezt az eszközt használó automatizálások alább jelennek meg.", @@ -654,10 +659,10 @@ "collecting_data": "Adatgyűjtés folyamatban - még {need} ciklus szükséges a finomhangolás megkezdéséhez ({current}/{min}).", "compare_overlay_profiles": "Fedőprofilok (halvány)", "compare_profiles_tip": "Fedjen rá más profilborítékokat a fenti diagramra, hogy megtudja, melyik illik legjobban ehhez a ciklushoz.", - "compare_selected_cycles": "Kiválasztott ciklusok (folytonos) — megjelenítés/elrejtés", + "compare_selected_cycles": "Kiválasztott ciklusok (folytonos) – megjelenítés/elrejtés", "consider_new_profile": "Fontolja meg egy új profil létrehozását.", "coverage_gap": "a legutóbbi ciklusoknak nincs megfelelő profilja (az utolsó 30 {pct}%-a).", - "coverage_gap_similar_cycles": "{count} hasonló felcímkézetlen ciklus található — hozzon létre egy profilt az egyeztetés megkezdéséhez.", + "coverage_gap_similar_cycles": "{count} hasonló felcímkézetlen ciklus található – hozzon létre egy profilt az egyeztetés megkezdéséhez.", "cycles_deleted": "{count} ciklus törölve", "enough_data": "Elegendő adat a tanuláshoz ({current}/{min} ciklus).", "export_description": "Exportálja az összes profilt és ciklust JSON-ba, vagy állítsa vissza az előző exportálásból.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Ehhez a géphez finomhangolt modellek.", "ml_loading": "ML betöltése…", "ml_settings_intro": "Két független kapcsoló: az egyik a ciklus futása közben alkalmazza a modelleket, a másik lehetővé teszi, hogy a WashData idővel finomhangolja őket a gépére.", - "name_first_program": "Elég ciklusa van — nevezze el az első programját az egyeztetés elindításához.", + "name_first_program": "Elég ciklusa van – nevezze el az első programját az egyeztetés elindításához.", "near_duplicate_cluster": "majdnem ismétlődő profilfürt észlelve. A csoportosítás lehetővé teszi, hogy a párosítás megbízhatóan válasszon a hasonlók között (pl. ugyanaz a program különböző hőmérsékleten/centrifugáláson).", "no_cycles_match": "Egyetlen ciklus sem felel meg az aktuális szűrőnek.", "no_cycles_profile": "Nincs ciklus ehhez a profilhoz.", @@ -713,7 +718,7 @@ "notify_services_hint": "Használja a(z) {entity} szolgáltatásazonosítókat (több esetén vesszővel elválasztva). Sablonváltozók: {vars}.", "old_actions_warning": "A régi műveletszerkesztővel konfigurálva (most eltávolítva). Továbbra is aktiválják a cikluseseményeket, de már nem szerkeszthetők itt. Alakítsa át őket normál automatizálássá, vagy távolítsa el őket.", "onboarding_progress": "{n} / 3 ciklus megfigyelve", - "onboarding_watching": "Használja a készülékét a szokásos módon — a WashData figyel. 3 ciklus után elindul a programegyeztetés.", + "onboarding_watching": "Használja a készülékét a szokásos módon – a WashData figyel. 3 ciklus után elindul a programegyeztetés.", "pending_feedback": "Függőben lévő észlelési visszajelzés", "performance_trend": "Teljesítménytrend ({n} ciklus)", "pg_analysis_empty": "Töltsön be egy ciklust az egyezéselemzés megtekintéséhez.", @@ -763,7 +768,7 @@ "setting_changed": "Módosítva: {old} → {new} ekkor: {date}", "settings_basic_note": "Az alapvető beállítások láthatók. Váltson Fejlett nézetre a teljes listához.", "shape_drift_advisory": "⚠ Alakváltás", - "shape_drift_detail": "Ennek a profilnak a teljesítménymintázata idővel megváltozott — lehetséges készülékhasználat okozta kopás vagy szükséges karbantartás (pl. vízkőmentesítés, szűrőtisztítás).", + "shape_drift_detail": "Ennek a profilnak a teljesítménymintázata idővel megváltozott – lehetséges készülékhasználat okozta kopás vagy szükséges karbantartás (pl. vízkőmentesítés, szűrőtisztítás).", "show_all_settings": "Minden beállítás megjelenítése", "showing_suggestions": "{count} beállítás megjelenítése javaslatokkal.", "split_intro": "Kattintson a diagramra egy osztási pont hozzáadásához vagy eltávolításához, vagy az üresjárati hézagok automatikus észleléséhez. Minden eredményül kapott szegmens saját profilt kaphat.", @@ -857,7 +862,9 @@ "share_guidelines_title": "Megosztás előtt", "store_download_device_intro": "Vegyen át minden megosztott programot és annak referenciaciklusait az eszközére. A saját rögzített ciklusait és statisztikáit nem érinti.", "store_share_device_intro": "Töltse fel a(z) {brand} {model} eszközt a kiválasztott referenciaciklusokkal. Más, ugyanolyan készülékkel rendelkezők átvehetik programjait. A bejegyzések felülvizsgálatra kerülnek nyilvánossá válás előtt.", - "share_profile_no_cycles": "Nincsenek referenciaciklusok -- jelöljön meg egy ciklust ⭐-gal a Ciklusok lapon, hogy belefoglalhassa ezt a profilt" + "share_profile_no_cycles": "Nincsenek referenciaciklusok -- jelöljön meg egy ciklust ⭐-gal a Ciklusok lapon, hogy belefoglalhassa ezt a profilt", + "advisory_phase_inconsistent": "Úgy tűnik, hogy a(z) '{name}' különböző programokat vagy hőmérsékleteket kever - ciklusai nagyon eltérő ideig fűtenek. Ha külön profilokra bontja (pl. hőmérsékletenként), az javítja a párosítást és az időbecsléseket.", + "advisory_phase_inconsistent_title": "⚠ Esetleg kevert programok" }, "phase_desc": { "anti_crease": "Alkalmankénti rövid bukdácsolás a befejezés után a ráncok csökkentése érdekében.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Opcionális külső jelek: záró trigger, ajtóérzékelő, szünetkapcsoló és a kirakodási emlékeztető.", "label": "Triggerek és ajtók" + }, + "phase_eta": { + "label": "Hátralévő idő", + "intro": "Fázistudatos hátralévő idő olyan gépekhez, amelyek ciklushossza a hőmérséklettől vagy a centrifugálástól függ." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Rögzített kWh-nkénti ár a költségadatokhoz használatos, ha nincs fent beállítva élő ár entitás.", "label": "Statikus energiaár (kWh-nként)" }, + "energy_sensor": { + "doc": "Opcionális kumulatív energiaszámláló (total_increasing kWh/Wh, pl. a konnektor saját élettartam-mérője). Ha be van állítva, minden ciklus jelentett energiája ennek a számlálónak a kezdő- és záróértéke közötti különbségéből származik, ami elkerüli azt az alulmérést, amit egy lassan jelentő teljesítményérzékelő integrálásából kapna. Visszavált az integrált értékre, ha a leolvasás hiányzik, mértékegysége ismeretlen, vagy a különbség nem pozitív. Hagyja üresen, hogy továbbra is a teljesítményérzékelőt integrálja.", + "label": "Energiamérő entitás" + }, "expose_debug_entities": { "doc": "Extra diagnosztikai HA entitások közzététele (egyezési megbízhatóság, kétértelműség, belső állapot). Az Off (Kikapcsolva) funkció tisztán tartja az entitáslistát normál használathoz.", "label": "Hibakeresési entitások közzététele" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Jelenítse meg a \" által\" jelölést a közösségi készülékeken és referenciaciklusokon." + }, + "enable_phase_matching": { + "label": "Fázistudatos hátralévő idő", + "doc": "Bontsa minden futó ciklust fázisokra (fűtés, mosás, centrifugálás), és a hátralévő időt fázisonként ossza be, a klasszikus becsléssel keverve - a ciklus elején a fázisbeosztásra, a vége felé pedig a klasszikus becslésre támaszkodva. Ez a visszaszámlálót ahhoz igazítja, hogy a gépe ténylegesen mennyi ideig fűt és működik, ami leginkább a ciklus első felében figyelhető meg. Ki = csak a klasszikus becslés. Csak a hátralévő idő kijelzését érinti; a program párosítása és a ciklusérzékelés változatlan marad." + }, + "keep_min_score": { + "label": "Min. Egyezési Pontszám", + "doc": "A minimális hasonlósági pontszám, amelyre egy jelöltnek szüksége van az egyezési versenyben való maradáshoz. Az alapértelmezett 0.1 szándékosan megengedő - még a gyenge jelölteket is beengedi, és a következő szakaszokra bízza a legjobb egyezés megtalálását. Növelje a valószínűtlen profilok korábbi kizárásához; csökkentse a kezdeti jelöltállomány bővítéséhez." + }, + "dtw_blend": { + "label": "Torzítás Keverék", + "doc": "Mennyire helyettesíti az időtorzítás (DTW) igazítási pontszám a 2. szakasz alappontszámát. 0 = csak a 2. szakasz pontszáma, 1 = csak a DTW-pontszám, 0.5 (alapértelmezett) = egyenlő keverék. Növelje, hogy jobban támaszkodjon a torzítási igazításra, ha a programoknak hasonló a teljesítményszintjük, de eltérő az időbeli mintájuk." + }, + "dtw_ensemble_w": { + "label": "Torzítás Együttes Mix", + "doc": "Együttes DTW módban (az alapértelmezett), a súly a skálázott L1 és a derivált DTW (DDTW) között. 1.0 = kizárólag skálázott-L1, 0 = kizárólag DDTW, 0.7 = alapértelmezett. A skálázott-L1 változat a teljesítményszinteket hasonlítja össze; a derivált változat arra reagál, ahogyan a teljesítmény változik az idő során. Az együttes mindkettőt kombinálja." + }, + "dtw_ddtw_scale": { + "label": "Derivált Torzítás Skála", + "doc": "Féltelítési távolság a derivált DTW pontozáshoz. A pontszám megfeleződik, ha a DDTW-távolság eléri ezt az értéket. Kisebb = érzékenyebb az alakkülönbségekre. Az alapértelmezett 30 a tipikus háztartási gépek teljesítménynyomaihoz van kalibrálva. Csak az együttes és ddtw DTW-módokat érinti." + }, + "dtw_refine_top_n": { + "label": "Torzítás Finomítás Szám", + "doc": "Hány legjobb 2. szakasz jelöltet pontozza újra a DTW. A DTW számításigényesebb, ezért csak a legjobb N jelöltre alkalmazzák. Alapértelmezett 5. Növelje (7-9-re), ha a helyes profil néha csak a 4. vagy 5. helyen végez a 2. szakasz után - a DTW meg tudja menteni. A csökkentés enyhén felgyorsítja az egyeztetést." + }, + "duration_scale": { + "label": "Időtartam Élesség", + "doc": "A 4. szakasz időtartam-egyezési büntetésének élessége. Ez az a logaritmikus arány, amelynél az egyezési pontszám megfeleződik. Kisebb = szigorúbb: egy időtartam-eltérés jobban büntet. Az alapértelmezett 0.175 nagyjából 18%-os időtartam-toleranciának felel meg a félsúlynál. Párosítsa az Időtartam Súlyával." + }, + "energy_scale": { + "label": "Energia Élesség", + "doc": "A 4. szakasz energia-egyezési büntetésének élessége. Kisebb = szigorúbb: egy energia-eltérés jobban büntet. Az alapértelmezett 0.25 szándékosan megengedőbb az Időtartam Élességénél, mivel az energia változik a terheléssel. Párosítsa az Energia Súlyával." } }, "setting_group": { @@ -1553,24 +1600,24 @@ "lower": "Automatikusan több ciklust jelöl; néhány félre lehet azonosítva" }, "duration_tolerance": { - "higher": "Szélesebb időtartomány elfogadása — rugalmasabb egyeztetés", - "lower": "Pontosabb időtartam-egyezést igényel — szigorúbb, de szokatlan ciklusokat elutasíthat" + "higher": "Szélesebb időtartomány elfogadása – rugalmasabb egyeztetés", + "lower": "Pontosabb időtartam-egyezést igényel – szigorúbb, de szokatlan ciklusokat elutasíthat" }, "end_energy_threshold": { "higher": "Csak jelentős energiát felhasználó ciklusokat zár be", "lower": "Rövidebb vagy alacsony energiájú ciklusokat is bezár" }, "end_repeat_count": { - "higher": "A befejezési jelet több ismétlés után fogadja el — megbízhatóbb, kissé lassabb", - "lower": "Kevesebb ismétlés után ér véget — gyorsabb érzékelés, több hamis befejezési kockázat" + "higher": "A befejezési jelet több ismétlés után fogadja el – megbízhatóbb, kissé lassabb", + "lower": "Kevesebb ismétlés után ér véget – gyorsabb érzékelés, több hamis befejezési kockázat" }, "learning_confidence": { - "higher": "Csak nagyon magabiztos egyezésekből tanul — lassabb, de megbízhatóbb", + "higher": "Csak nagyon magabiztos egyezésekből tanul – lassabb, de megbízhatóbb", "lower": "Több ciklusból tanul; a modell zajosabb lehet" }, "min_off_gap": { "higher": "Hosszabb tétlenség szükséges az új ciklus indulása előtt", - "lower": "Rövid tétlenség új ciklust indít — egymást követő futások szétválhatnak" + "lower": "Rövid tétlenség új ciklust indít – egymást követő futások szétválhatnak" }, "min_power": { "higher": "Kevésbé érzékeny a tétlenségi fogyasztásra; nagyon csendes programokat elszalaszthat", @@ -1581,24 +1628,24 @@ "lower": "Elakadt ciklust gyorsabban kényszerít leállítani" }, "off_delay": { - "higher": "Több idő a készülék szüneteire — hosszú áztatás vagy szárítás kezelése", - "lower": "Gyorsabb vége érzékelés — egyértelmű be/ki készülékekhez jól működik" + "higher": "Több idő a készülék szüneteire – hosszú áztatás vagy szárítás kezelése", + "lower": "Gyorsabb vége érzékelés – egyértelmű be/ki készülékekhez jól működik" }, "profile_match_threshold": { "higher": "Csak nagy magabiztosságú egyezéseket erősít meg; több ciklus marad jelöletlen", "lower": "Több ciklust egyeztet; néhány kissé helytelen lehet" }, "running_dead_zone": { - "higher": "Figyelmen kívül hagyja a rövid teljesítménycsúcsokat tétlenség közben — zajra kevésbé érzékeny", - "lower": "Rövidebb teljesítménylökésekre reagál — gyors átmeneti aktivitást észlel" + "higher": "Figyelmen kívül hagyja a rövid teljesítménycsúcsokat tétlenség közben – zajra kevésbé érzékeny", + "lower": "Rövidebb teljesítménylökésekre reagál – gyors átmeneti aktivitást észlel" }, "start_threshold_w": { "higher": "Elkerüli a rövid teljesítménycsúcsokból eredő hamis indításokat", "lower": "Kis teljesítményű programokat is érzékel; zajra is aktiválódhat" }, "stop_threshold_w": { - "higher": "Gyorsabban fejezi be a ciklust — szünet közben is zárhat", - "lower": "Hosszabb ideig vár a befejezésre — kevesebb idő előtti befejezés" + "higher": "Gyorsabban fejezi be a ciklust – szünet közben is zárhat", + "lower": "Hosszabb ideig vár a befejezésre – kevesebb idő előtti befejezés" }, "watchdog_interval": { "higher": "Hosszabb csendes fázisok megengedett; kevesebb hamis kényszeres leállítás", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "A küszöb feletti másodpercek az indulás megerősítéséhez", "start_threshold_w": "Minimális wattérték az indulás elismeréséhez", "stop_threshold_w": "Ez alatt a gép kikapcsoltnak számít", - "dtw_bandwidth": "Mennyi időbeli torzítást engedélyez az alakillesztés", - "corr_weight": "A görbe alakjának és a teljesítményszintnek a súlya az illesztésben", - "duration_weight": "Mennyire befolyásolja az időtartam egyezése az illesztést", - "energy_weight": "Mennyire befolyásolja az energia egyezése az illesztést", - "profile_match_min_duration_ratio": "A legrövidebb ciklus (a profilhoz képest), amely még egyezhet", - "profile_match_max_duration_ratio": "A leghosszabb ciklus (a profilhoz képest), amely még egyezhet" + "dtw_bandwidth": "3. szakasz: Sakoe-Chiba torzítási sáv (0 = DTW kikapcsolva; alapértelmezett 0.2 = a ciklushossz 20%-a)", + "corr_weight": "2. szakasz: egyensúly a görbe alakja (korreláció) és a teljesítményszint (MAE) között; alapértelmezett 0.45", + "duration_weight": "4. szakasz: mennyire befolyásolja a futási idő egyezése a végső pontszámot (alapértelmezett 0.22)", + "energy_weight": "4. szakasz: mennyire befolyásolja az energia-egyezés a végső pontszámot (alapértelmezett 0.22)", + "profile_match_min_duration_ratio": "1. szakasz: minimális ciklushossz a profil időtartamának törtjeként; alapértelmezett 0.1 (10%)", + "profile_match_max_duration_ratio": "1. szakasz: maximális ciklushossz a profil időtartamának törtjeként; alapértelmezett 1.5 (150%)", + "keep_min_score": "2. szakasz: minimális pontszám a versenyben maradáshoz; az alapértelmezett 0.1 gyenge egyezéseket is beenged, a következő szakaszok választják ki a legjobbat", + "dtw_blend": "3. szakasz: 0 = csak alappontszám, 1 = csak DTW-pontszám, 0.5 = egyenlő keverék (alapértelmezett)", + "dtw_ensemble_w": "3. szakasz (együttes mód): súly a skálázott-L1 és derivált DTW között; alapértelmezett 0.7 a szintfüggőt részesíti előnyben", + "dtw_ddtw_scale": "3. szakasz: DDTW féltelítési távolság; kisebb = érzékenyebb az alakra (alapértelmezett 30)", + "dtw_refine_top_n": "3. szakasz: DTW által újrapontozandó jelöltek; növelje 7-9-re, ha a helyes profil a 4-5. helyen végez (alapértelmezett 5)", + "duration_scale": "4. szakasz: logaritmikus arány, ahol az időtartam-egyezés megfeleződik; kisebb = szigorúbb büntetés (alapértelmezett 0.175)", + "energy_scale": "4. szakasz: logaritmikus arány, ahol az energia-egyezés megfeleződik; kisebb = szigorúbb büntetés (alapértelmezett 0.25)" }, "col": { "profile_tip": "Az egyezést mutató program neve. A címkézetlen azt jelenti, hogy a ciklus végén egyetlen profil sem egyezett.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimalizálás: {param}" + }, + "pg_detail": { + "simulate": "Ciklus szimulálása" + }, + "split": { + "apply": "Ciklus felosztása" + }, + "trim": { + "apply": "Ciklus levágása" + }, + "merge": { + "apply": "Ciklusok egyesítése" + }, + "rebuild": { + "envelopes": "Burkológörbék újraépítése" + }, + "cancelling": "Megszakítás..." + }, + "setup": { + "hdr": { + "card": "Eszköz beállítása", + "healthy_chip": "Beállítás kész" + }, + "phase0": { + "washer": "A WashData már észleli a ciklusait. Rögzítse az első ciklust a programnevek és az időbecslések engedélyezéséhez.", + "dishwasher": "A WashData figyel. A mosogatógépek összetett ciklusokkal rendelkeznek – az első ciklus rögzítése erősen ajánlott. Ha egy észlelt ciklus túl hosszú ideig tart, a profilként való mentés előtt a ciklusszerkesztővel kurtítsa le.", + "generic": "A WashData figyel. Rögzítsen vagy jelöljön meg egy észlelt ciklust a profilok létrehozásának megkezdéséhez." + }, + "phase1a": { + "labelled": "Jó kezdés – az első programja el van mentve. A legtisztább adatokhoz érdemes a következő ciklust a rögzítő widgettel felvenni." + }, + "phase1b": { + "recorded": "A felvétele {profile_name} névvel lett elmentve. Most rögzítse vagy jelölje meg a többi gyakran használt programját a lefedettség növeléséhez." + }, + "phase1c": { + "verify": "Önnek {count} program van a közösségtől. Futtasson egy ciklust, hogy ellenőrizze, a WashData helyesen ismeri-e fel – az egyeztetés javul, ahogy az eszköz saját előzményeket gyűjt." + }, + "phase2": { + "cluster": "A WashData {count} ciklust látott, amelyek nem felelnek meg egyetlen mentett programnak sem – hasonlítanak egymásra. Szeretne létrehozni nekik egy új profilt?", + "unmatched": "Az utolsó ciklus nem felelt meg egyetlen mentett programnak sem. Ez egy új program?" + }, + "phase3": { + "suggestions": "A WashData a ciklustörténete alapján beállítási javaslatokkal rendelkezik – tekintse át ezeket az észlelési pontosság javítása érdekében.", + "groups": "Néhány profilja ugyanolyannak tűnik, mint ugyanaz a program különböző hőmérsékleteken. Rendezze csoportba a jobb egyeztetés érdekében.", + "phases": "Adjon programfázisokat a(z) {profile_name} profilhoz a pontosabb hátralévő idő becslések érdekében." + }, + "phase4": { + "healthy": "Ez az eszköz teljesen be van állítva ({profile_count} profil)." + }, + "cta": { + "start_recording": "Felvétel indítása", + "label_detected_cycle": "Már van egy észlelt ciklusom – inkább jelöljük meg", + "browse_cycles": "Ciklusok böngészése egy másik megjelöléséhez", + "view_profiles": "Profilok megtekintése", + "create_from_cluster": "Profil létrehozása ezekből a ciklusokból", + "create_profile": "Profil létrehozása hozzá", + "review_suggestions": "Javaslatok áttekintése", + "organise_profiles": "Profilok rendezése", + "configure_phases": "Fázisok konfigurálása", + "skip_step": "Lépés kihagyása", + "skip_forever": "Ne mutasd újra", + "hide_guidance": "Útmutatás elrejtése", + "show_guidance": "Útmutatás megjelenítése" } } } diff --git a/custom_components/ha_washdata/translations/panel/is.json b/custom_components/ha_washdata/translations/panel/is.json index 9dd69f3..e13ead3 100644 --- a/custom_components/ha_washdata/translations/panel/is.json +++ b/custom_components/ha_washdata/translations/panel/is.json @@ -1,6 +1,6 @@ { "badge": { - "artifact_tip": "{n} frávik fundust (t.d. hurð opnuð í miðjum lotu) — opnaðu til að sjá þau á línuritinu", + "artifact_tip": "{n} frávik fundust (t.d. hurð opnuð í miðjum lotu) – opnaðu til að sjá þau á línuritinu", "auto": "(sjálfvirkt greint)", "built_in_tag": "innbyggður", "declining": "↘ minnkandi", @@ -87,7 +87,7 @@ "on_cycle_finished": "Á hringrás lokið", "on_cycle_started": "Á hringrás byrjaði", "pause_cycle": "Gera hlé", - "pause_cycle_tip": "Gerðu hlé á gengandi lotu — tækið mun halda áfram þar sem það stoppaði", + "pause_cycle_tip": "Gerðu hlé á gengandi lotu – tækið mun halda áfram þar sem það stoppaði", "pg_apply_device": "Beita á tæki", "pg_autofill": "Fylla sjálfkrafa", "play": "Spila", @@ -97,8 +97,8 @@ "process_tip": "Vistaðu skráð sporið sem nýjan eða núverandi prófíl", "rebuild": "Endurbyggja umslög", "rebuild_envelope": "Endurbyggja umslag", - "rebuild_tip": "Endurreiknaðu vænt orkuumslag (min/max bil) fyrir öll snið úr merktu lotum þeirra — keyrðu eftir að hafa merkt nýjar lotur eða leiðrétt gamlar", - "rec_start_tip": "Byrjaðu að taka upp aflferil tækisins — byrjaðu rétt áður en lota er keyrð", + "rebuild_tip": "Endurreiknaðu vænt orkuumslag (min/max bil) fyrir öll snið úr merktu lotum þeirra – keyrðu eftir að hafa merkt nýjar lotur eða leiðrétt gamlar", + "rec_start_tip": "Byrjaðu að taka upp aflferil tækisins – byrjaðu rétt áður en lota er keyrð", "rec_stop": "Hættu", "rec_stop_tip": "Stöðvaðu upptöku og geymdu fangna ferilinn til skoðunar", "record": "Byrjaðu upptöku", @@ -231,7 +231,7 @@ "interval": "Ætti að vera að minnsta kosti 2x Sýnitakabil ({si} s)", "sampling": "Sýnitakabil ætti að vera í mesta lagi helmingur Gæsluabils ({wi} s)" }, - "settings_banner": "{n} stillingarárekstur{s} — skoðaðu auðkenndu hlutana og lagaðu þá áður en þú vistar.", + "settings_banner": "{n} stillingarárekstur{s} – skoðaðu auðkenndu hlutana og lagaðu þá áður en þú vistar.", "settings_banner_btn": "Fara á fyrsta" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Sýna væntanleg ferilyfirlag (samsvörun snið, appelsínugult)", "show_raw": "Sýna hráan innstunga-víxlara í rauntíma aflsriti", "sparkline": "Nýleg þróun lotulengdar", - "stage2": "Þrep 2 — kjarnalíkindi", - "stage3": "Þrep 3 — DTW", - "stage4": "Þrep 4 — samræmi", + "stage2": "Þrep 2 – kjarnalíkindi", + "stage3": "Þrep 3 – DTW", + "stage4": "Þrep 4 – samræmi", "status": "Staða", "tail_trim": "Snyrta enda (s)", "timer_auto_pause": "Sjálfvirk hlé", @@ -561,7 +561,12 @@ "flags": "Flagg", "include_phase_map": "Hafa fassakort með", "include_settings": "Hafa greiningar- og samsvörunarstillingar með", - "show_contributor": "Sýna nöfn framleggjenda" + "show_contributor": "Sýna nöfn framleggjenda", + "task_pg_detail": "Herma eftir lotu", + "task_split": "Skipti lotu upp", + "task_trim": "Klippi lotu til", + "task_merge": "Sameina lotur", + "task_rebuild": "Endurreikna aflhjúpa" }, "log": { "all_levels": "Öll stig", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Féll undir venjulegt aflband í ~{n}s.", "artifact_footer": "Auðkenndur á grafinu hér að ofan. Þetta eru skammvinnir gripir (t.d. hurðin opnuð í miðjum lotu), ekki endilega vandamál.", "artifact_header": "{n} frávik fundust í þessari lotu", - "artifact_pause_detail": "Afl féll nærri núlli í ~{n}s og hækkaði svo aftur — líklega var hurðin opnuð um miðja lotu eða lota sett í bið.", + "artifact_pause_detail": "Afl féll nærri núlli í ~{n}s og hækkaði svo aftur – líklega var hurðin opnuð um miðja lotu eða lota sett í bið.", "artifact_spike_detail": "Fór yfir venjulegt aflband í ~{n}s.", "auto_label_intro": "Úthlutaðu sniðum á ómerktar lotur þar sem samsvörun þeirra hreinsar þröskuldinn.", "automations_intro": "WashData sendir {start} / {end} atburði og birtir einingar, þannig að tilkynningar og aðgerðir eru best smíðaðar sem venjulegar Home Assistant sjálfvirkingar. Sjálfvirkingar sem nota þetta tæki birtast hér að neðan.", @@ -654,10 +659,10 @@ "collecting_data": "Safnar gögnum: {need} lota{plural} til viðbótar áður en fínstilling getur hafist ({current}/{min}).", "compare_overlay_profiles": "Yfirlagssnið (dauft)", "compare_profiles_tip": "Leggðu önnur prófílumslög yfir á töfluna hér að ofan til að sjá hver þeirra passar best við þessa lotu.", - "compare_selected_cycles": "Valdar lotur (fastar) — sýna / fela", + "compare_selected_cycles": "Valdar lotur (fastar) – sýna / fela", "consider_new_profile": "Íhugaðu að búa til nýjan prófíl.", "coverage_gap": "Nýlegar lotur hafa ekkert samsvarandi prófíl ({pct}% af síðustu 30).", - "coverage_gap_similar_cycles": "{count} svipaðar ómerktar lotur fundust — búðu til prófíl til að byrja að samstemma þær.", + "coverage_gap_similar_cycles": "{count} svipaðar ómerktar lotur fundust – búðu til prófíl til að byrja að samstemma þær.", "cycles_deleted": "{count} lotu(m) eytt", "enough_data": "Nóg gögn til að læra af ({current}/{min} lotur).", "export_description": "Flyttu út öll snið og lotur í JSON, eða endurheimtu frá fyrri útflutningi.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Módel fínstillt að þessari vél.", "ml_loading": "hleður ML…", "ml_settings_intro": "Tveir óháðir rofar: annar notar gerðir á meðan hringrás er í gangi, hinn gerir WashData kleift að fínstilla þær að vélinni þinni með tímanum.", - "name_first_program": "Þú ert með nægar lotur — gefðu fyrsta prógramminu nafn til að hefja samsvörun.", + "name_first_program": "Þú ert með nægar lotur – gefðu fyrsta prógramminu nafn til að hefja samsvörun.", "near_duplicate_cluster": "næstum tvítekinn prófílklasi fannst. Flokkun gerir samsvörun kleift að velja áreiðanlega á milli útlita (t.d. sama forritið við mismunandi hitastig/snúning).", "no_cycles_match": "Engar lotur passa við núverandi síu.", "no_cycles_profile": "Engar lotur fyrir þetta snið.", @@ -713,7 +718,7 @@ "notify_services_hint": "Notaðu {entity} þjónustuauðkenni (aðskilin með kommum fyrir mörg). Sniðmátsbreytur: {vars}.", "old_actions_warning": "Stillt með gamla aðgerðaritlinum (nú fjarlægður). Þeir virkjast enn við hringrásarviðburði en ekki er lengur hægt að breyta þeim hér. Umbreyttu þeim í venjulega sjálfvirkni eða fjarlægðu þau.", "onboarding_progress": "{n} / 3 lotur skoðaðar", - "onboarding_watching": "Notaðu tækið eins og venjulega — WashData fylgist með. Eftir 3 lotur hefst samsvörun prógramma.", + "onboarding_watching": "Notaðu tækið eins og venjulega – WashData fylgist með. Eftir 3 lotur hefst samsvörun prógramma.", "pending_feedback": "Bíður viðbrögð við uppgötvunum", "performance_trend": "Árangursþróun ({n} lotur)", "pg_analysis_empty": "Hladdu lotu til að sjá samsvörunargreiningu.", @@ -763,7 +768,7 @@ "setting_changed": "Breytt úr {old} í {new} þann {date}", "settings_basic_note": "Sýni nauðsynlegar stillingar. Skiptu yfir í Ítarlegri fyrir allan listann.", "shape_drift_advisory": "⚠ Lögun að breytast", - "shape_drift_detail": "Aflmynstur þessa prófíls hefur breyst yfir tíma — mögulegt slit á tæki eða viðhald þarf (t.d. kalkhreinsa, hreinsa síu).", + "shape_drift_detail": "Aflmynstur þessa prófíls hefur breyst yfir tíma – mögulegt slit á tæki eða viðhald þarf (t.d. kalkhreinsa, hreinsa síu).", "show_all_settings": "Sýna allar stillingar", "showing_suggestions": "Sýnir {count} stillingu með tillögum.", "split_intro": "Smelltu á línuritið til að bæta við eða fjarlægja skiptingarpunkt, eða greina sjálfvirkt með aðgerðalausum eyðum. Hver hluti sem myndast getur fengið sinn eigin prófíl.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Endurstilling mistókst: {error}", "toast_reverted": "{key} afturkallað", "toast_save_failed": "Vistun mistókst: {error}", - "trend_duration_longer": "Tímalengd í vændum lengri ({pct}%/lotu) — nýleg meðaltal {avg}", - "trend_duration_shorter": "Tímalengd styttist ({pct}%/lotu) — nýleg meðaltal {avg}", + "trend_duration_longer": "Tímalengd í vændum lengri ({pct}%/lotu) – nýleg meðaltal {avg}", + "trend_duration_shorter": "Tímalengd styttist ({pct}%/lotu) – nýleg meðaltal {avg}", "trend_energy_down": "Orka á niðurleið ({pct}%/lotu)", - "trend_energy_up": "Orka hækkar ({pct}%/lotu) — nýleg meðaltal {avg}", + "trend_energy_up": "Orka hækkar ({pct}%/lotu) – nýleg meðaltal {avg}", "trim_intro": "Dragðu rauðu handföngin eða sláðu inn gildi. Allt fyrir utan gluggann er fjarlægt.", "tuning_suggestions_available": "{count} uppástunga að stilla í boði frá athuguðum lotum. Þau birtast við hlið viðkomandi reita.", "unsure_detected_prefix": "WashData er ekki viss um að það hafi fundist", @@ -857,7 +862,9 @@ "share_guidelines_title": "Áður en þú deilir", "store_download_device_intro": "Taktu yfir öll deildu forritin og viðmiðunarlotir þeirra á tækið þitt. Þínar eigin skráðar lotir og tölfræði eru ekki fyrir áhrifum.", "store_share_device_intro": "Hlaðaðu upp {brand} {model} með viðmiðunarlotum sem þú velur. Aðrir með sama tæki geta tekið yfir forritin þín. Færslur eru yfirfarnar áður en þær birtast opinberlega.", - "share_profile_no_cycles": "Engar viðmiðunarlotir -- merktu lotu sem ⭐ í flipanum Lotir til að hafa þennan prófíl með" + "share_profile_no_cycles": "Engar viðmiðunarlotir -- merktu lotu sem ⭐ í flipanum Lotir til að hafa þennan prófíl með", + "advisory_phase_inconsistent": "'{name}' virðist blanda saman ólíkum forritum eða hitastigum - lotur þess hita mislengi. Ef þú skiptir því upp í aðskilin snið (t.d. eftir hitastigi) batnar samsvörun og tímamat.", + "advisory_phase_inconsistent_title": "⚠ Hugsanlega blönduð forrit" }, "pg_desc": { "abrupt_drop_watts": "Skyndilegt fall meðhöndlað sem tafarlaus endir", @@ -869,12 +876,19 @@ "start_duration_threshold": "Sekúndur yfir þröskuldi til að staðfesta ræsingu", "start_threshold_w": "Lágmarksvött til að teljast ræst", "stop_threshold_w": "Undir þessu telst vélin af", - "dtw_bandwidth": "Hversu mikla tímabjögun formsamsvörunin leyfir", - "corr_weight": "Vægi á ferilform á móti aflstigi í samsvöruninni", - "duration_weight": "Hversu mikið samræmi í keyrslutíma hefur áhrif á samsvörunina", - "energy_weight": "Hversu mikið samræmi í orkunotkun hefur áhrif á samsvörunina", - "profile_match_min_duration_ratio": "Stysta keyrsla (miðað við sniðið) sem enn má samsvara", - "profile_match_max_duration_ratio": "Lengsta keyrsla (miðað við sniðið) sem enn má samsvara" + "dtw_bandwidth": "Þrep 3: Sakoe-Chiba-tímabjögunarband (0 = DTW af; sjálfgefið 0,2 = 20% af lotulegð)", + "corr_weight": "Þrep 2: jafnvægi milli ferilforms (fylgni) og aflstigs (MAE); sjálfgefið 0,45", + "duration_weight": "Þrep 4: hversu sterklega samræmi í keyrslulengd hefur áhrif á lokaskorið (sjálfgefið 0,22)", + "energy_weight": "Þrep 4: hversu sterklega samræmi í orkunotkun hefur áhrif á lokaskorið (sjálfgefið 0,22)", + "profile_match_min_duration_ratio": "Þrep 1: stysta lota sem hlutfall af lotulegð sniðs; sjálfgefið 0,1 (10%)", + "profile_match_max_duration_ratio": "Þrep 1: lengsta lota sem hlutfall af lotulegð sniðs; sjálfgefið 1,5 (150%)", + "keep_min_score": "Þrep 2: lægsta einkunn til að vera eftir í samsvöruninni; sjálfgefið 0,1 leyfir veika frambjóðendur, síðari þrep velja best", + "dtw_blend": "Þrep 3: 0 = aðeins kjarnaskor, 1 = aðeins DTW-skor, 0,5 = jöfn blanda (sjálfgefið)", + "dtw_ensemble_w": "Þrep 3 (ensemble-hamur): þunginn á skaluðum L1 gagnvart afleiðu-DTW; sjálfgefið 0,7 hlynnar að stiga-meðvitund", + "dtw_ddtw_scale": "Þrep 3: DDTW hálf-mettunarfjarlægð; minna = meira formnæmt (sjálfgefið 30)", + "dtw_refine_top_n": "Þrep 3: frambjóðendur sem DTW endurgerir skor fyrir; hækkið í 7-9 ef rétt snið lendir á 4.-5. sæti (sjálfgefið 5)", + "duration_scale": "Þrep 4: log-hlutfall þar sem samræmi í keyrslulengd helmingast; minna = strangara viðurlög (sjálfgefið 0,175)", + "energy_scale": "Þrep 4: log-hlutfall þar sem samræmi í orkunotkun helmingast; minna = strangara viðurlög (sjálfgefið 0,25)" }, "phase_desc": { "anti_crease": "Stundum hrynur stutt eftir að því er lokið til að draga úr hrukkum.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Valfrjálsir ytri merkar: lokakveikin, dyraskynjari, hlébilaskipti og losunarminnir.", "label": "Kveikjar og dyr" + }, + "phase_eta": { + "label": "Tími sem eftir er", + "intro": "Tími sem eftir er sem tekur mið af fösum, fyrir vélar þar sem lengd lotu ræðst af hitastigi eða vindingu." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Fast verð á kWst notað fyrir kostnaðartölur þegar engin lifandi verðeining er stillt hér að ofan.", "label": "Fast orkuverð (á kWh)" }, + "energy_sensor": { + "doc": "Valfrjáls uppsafnaður orkuteljari (total_increasing í kWh/Wh, t.d. eigin líftímamælir innstungunnar). Þegar hann er stilltur er uppgefin orka hverrar lotu tekin úr mismun þessa teljara milli upphafs og enda, sem kemur í veg fyrir vantalninguna sem hlýst af því að heilda hægvirkan aflskynjara. Fer aftur í heildaða gildið ef mælingin vantar, eining hennar er óþekkt eða mismunurinn er ekki jákvæður. Skildu eftir autt til að halda áfram að heilda aflskynjarann.", + "label": "Orkumælieining" + }, "expose_debug_entities": { "doc": "Birtu auka greiningar HA einingar (samsvörun, tvíræðni, innri hluti ríkisins). Slökkt heldur einingarlistanum hreinum fyrir venjulega notkun.", "label": "Birta villuleitareiningar" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Sýnir tilvísunina 'eftir ' á samfélagstækjum og viðmiðunarlotum." + }, + "enable_phase_matching": { + "label": "Fasaskiptur tími sem eftir er", + "doc": "Skiptu hverri lotu í gangi upp í fasa (upphitun, þvottur, vinda) og áætlaðu tímann sem eftir er fyrir hvern fasa, blandað saman við klassíska matið - snemma í lotunni styðst það við fasaáætlunina og undir lokin við klassíska matið. Þetta sérsníður niðurtalninguna að því hversu lengi vélin þín hitar og gengur í raun, sem er mest áberandi í fyrri helmingi lotu. Slökkt = aðeins klassíska matið. Aðeins birting á tíma sem eftir er verður fyrir áhrifum; forritasamsvörun og lotugreining haldast óbreytt." + }, + "keep_min_score": { + "label": "Lágm. samsvörunareinkunn", + "doc": "Lægsta likhetseinkunn sem frambjóðandi þarf til að vera eftir í samsvöruninni. Sjálfgefið 0,1 er vísvitandi rýmt – það leyfir jafnvel veika frambjóðendur og treystir á síðari þrep til að finna besta samsvörun. Hækkið til að sía út ósennilegar sniðmát fyrr; lækkkið til að víkka upphaflega frambjóðendasafnið." + }, + "dtw_blend": { + "label": "DTW-blandning", + "doc": "Hversu mikið DTW-jöfnunarskorið kemur í stað kjarnaskorinnar frá Þrepi 2. 0 = notaðu aðeins Þreps 2 skor, 1 = notaðu aðeins DTW-skor, 0,5 (sjálfgefið) = jöfn blanda. Hækkið til að treystir meira á tímaröðun þegar forrit hafa svipaðar aflstigseiningar en ólík tímasetningarmynstur." + }, + "dtw_ensemble_w": { + "label": "DTW-ensemble-blanda", + "doc": "Í ensemble-DTW-ham (sjálfgefinn) er þetta þunginn á skaluðum L1 gagnvart afleiðu-DTW (DDTW). 1,0 = aðeins skalað L1, 0 = aðeins DDTW, 0,7 = sjálfgefið. Skalade útgáfan ber saman aflstig; afleiðuútgáfan bregst við því hvernig afl breytist með tíma. Ensemble blandar bæði." + }, + "dtw_ddtw_scale": { + "label": "Afleiðu-DTW-kvarði", + "doc": "Hálf-mettunarfjarlægð fyrir afleiðu-DTW-stigagjöf. Einkunnin helmingast þegar DDTW-fjarlægðin er jöfn þessu gildi. Minna = næmara fyrir formmunur. Sjálfgefið 30 er kvarðað fyrir dæmigerðar aflferil tækja. Hefur aðeins áhrif á ensemble- og ddtw-DTW-hama." + }, + "dtw_refine_top_n": { + "label": "DTW-fínstillingarfjöldi", + "doc": "Hversu marga toppframbjóðendur frá Þrepi 2 DTW endurgerir skor fyrir. DTW er dýrara svo það er aðeins beitt á bestu N frambjóðendurna. Sjálfgefið 5. Hækkið (í 7-9) ef rétt snið lendir stundum aðeins á 4. eða 5. sæti eftir Þrep 2 – DTW getur bjargað því. Lægra gildi hraðar samsvörun lítið." + }, + "duration_scale": { + "label": "Lengdarskerpa", + "doc": "Skerpa í viðurlögum Þreps 4 vegna samræmis í keyrslulengd. Þetta er log-hlutfallið þar sem samræmisskorið helmingast. Minna = strangara: frávik í keyrslulengd skemmir meira. Sjálfgefið 0,175 samsvarar um 18% þolmörkum í keyrslulengd við hálft þyngd. Notaðu með Keyrslulengdarþyngd." + }, + "energy_scale": { + "label": "Orkuskerpa", + "doc": "Skerpa í viðurlögum Þreps 4 vegna samræmis í orkunotkun. Minna = strangara: frávik í orkunotkun skemmir meira. Sjálfgefið 0,25 er vísvitandi rýmra en keyrslulengdarskerpan vegna þess að orka breytist með álagi. Notaðu með Orkuþyngd." } }, "setting_group": { @@ -1766,6 +1820,68 @@ }, "pg_sweep": { "optimize": "Bestun: {param}" + }, + "pg_detail": { + "simulate": "Herma eftir lotu" + }, + "split": { + "apply": "Skipti lotu upp" + }, + "trim": { + "apply": "Klippi lotu til" + }, + "merge": { + "apply": "Sameina lotur" + }, + "rebuild": { + "envelopes": "Endurreikna aflhjúpa" + } + }, + "setup": { + "hdr": { + "card": "Uppsetning tækis", + "healthy_chip": "Uppsetningu lokið" + }, + "phase0": { + "washer": "WashData greinir nú þegar lotur þínar. Taktu upp fyrstu lotuna til að virkja forritsheiti og tímamat.", + "dishwasher": "WashData fylgist með. Uppþvottavélar hafa flóknar lotur – mjög mælt er með að taka upp fyrstu lotuna. Ef greind lota keyrir of lengi skaltu nota lotu-ritilinn til að snyrta hana áður en hún er vistuð sem snið.", + "generic": "WashData fylgist með. Taktu upp eða merktu greinda lotu til að hefja uppbyggingu sniða." + }, + "phase1a": { + "labelled": "Gott upphaf – fyrsta forritið þitt er vistað. Fyrir hreinustu gögn skaltu íhuga að taka upp næstu lotu með upptökugræjunni." + }, + "phase1b": { + "recorded": "Upptakan þín var vistuð sem {profile_name}. Taktu nú upp eða merktu önnur algeng forrit þín til að byggja upp þekju." + }, + "phase1c": { + "verify": "Þú ert með {count} forrit úr samfélaginu. Keyrðu lotu til að staðfesta að WashData þekki hana rétt – samsvörun batnar eftir því sem tækið byggir upp sína eigin sögu." + }, + "phase2": { + "cluster": "WashData hefur séð {count} lotur sem samsvara ekki neinu vistuðu forriti – þær líkjast hvor annarri. Viltu búa til nýtt snið fyrir þær?", + "unmatched": "Síðasta lota þín samsvaraði ekki neinu vistuðu forriti. Er þetta nýtt forrit?" + }, + "phase3": { + "suggestions": "WashData hefur stillingatillögur byggðar á lotuferli þínum – farðu yfir þær til að bæta nákvæmni greiningar.", + "groups": "Sum snið þín líkjast sama forriti við mismunandi hitastig. Skiptu þeim í hóp til að fá betri samsvörun.", + "phases": "Bættu forritsfasum við {profile_name} fyrir nákvæmara mat á tíma sem eftir er." + }, + "phase4": { + "healthy": "Þetta tæki er að fullu sett upp ({profile_count} snið)." + }, + "cta": { + "start_recording": "Byrjaðu upptöku", + "label_detected_cycle": "Ég er nú þegar með greinda lotu – merkja hana í staðinn", + "browse_cycles": "Skoðaðu lotur til að merkja aðra", + "view_profiles": "Skoðaðu snið þín", + "create_from_cluster": "Búa til snið úr þessum lotum", + "create_profile": "Búðu til snið fyrir hana", + "review_suggestions": "Yfirfara tillögur", + "organise_profiles": "Skipuleggja snið", + "configure_phases": "Stilla fasa", + "skip_step": "Sleppa þessu skrefi", + "skip_forever": "Sýna ekki aftur", + "hide_guidance": "Fela leiðbeiningar", + "show_guidance": "Sýna leiðbeiningar" } } } diff --git a/custom_components/ha_washdata/translations/panel/it.json b/custom_components/ha_washdata/translations/panel/it.json index 0cf321f..61b6ea4 100644 --- a/custom_components/ha_washdata/translations/panel/it.json +++ b/custom_components/ha_washdata/translations/panel/it.json @@ -87,7 +87,7 @@ "on_cycle_finished": "Il ciclo è terminato", "on_cycle_started": "Il ciclo è iniziato", "pause_cycle": "Pausa", - "pause_cycle_tip": "Metti in pausa il ciclo in corso — l'elettrodomestico riprenderà da dove si era fermato", + "pause_cycle_tip": "Metti in pausa il ciclo in corso – l'elettrodomestico riprenderà da dove si era fermato", "pg_apply_device": "Applica al dispositivo", "pg_autofill": "Compilazione automatica", "play": "Riproduci", @@ -97,8 +97,8 @@ "process_tip": "Salva la traccia registrata come profilo nuovo o esistente", "rebuild": "Ricostruisci le buste", "rebuild_envelope": "Ricostruisci la busta", - "rebuild_tip": "Ricalcola la busta di potenza attesa (banda min/max) per tutti i profili dai loro cicli etichettati — eseguire dopo aver etichettato nuovi cicli o corretto quelli esistenti", - "rec_start_tip": "Avvia la registrazione della traccia di potenza dell'elettrodomestico — avvia appena prima di eseguire un ciclo", + "rebuild_tip": "Ricalcola la busta di potenza attesa (banda min/max) per tutti i profili dai loro cicli etichettati – eseguire dopo aver etichettato nuovi cicli o corretto quelli esistenti", + "rec_start_tip": "Avvia la registrazione della traccia di potenza dell'elettrodomestico – avvia appena prima di eseguire un ciclo", "rec_stop": "Fermare", "rec_stop_tip": "Ferma la registrazione e mantieni la traccia acquisita per la revisione", "record": "Inizia a registrare", @@ -182,7 +182,7 @@ }, "attn_sub": "Correggi i conflitti prima di salvare", "attn_title": "{n} conflitto{s} nelle impostazioni", - "settings_banner": "{n} conflitto{s} nelle impostazioni — controlla le sezioni evidenziate e correggile prima di salvare.", + "settings_banner": "{n} conflitto{s} nelle impostazioni – controlla le sezioni evidenziate e correggile prima di salvare.", "settings_banner_btn": "Vai al primo", "confidence": { "auto": "Deve essere maggiore o uguale alla Soglia di corrispondenza ({match})", @@ -451,9 +451,9 @@ "show_expected": "Mostra sovrapposizione curva prevista (profilo corrispondente, arancione)", "show_raw": "Mostra l'interruttore del segnale grezzo della presa nel grafico di potenza in tempo reale", "sparkline": "Andamento recente durata cicli", - "stage2": "Fase 2 — somiglianza di base", - "stage3": "Fase 3 — DTW", - "stage4": "Fase 4 — concordanza", + "stage2": "Fase 2 – somiglianza di base", + "stage3": "Fase 3 – DTW", + "stage4": "Fase 4 – concordanza", "status": "Stato", "tail_trim": "Taglio(i) di coda", "timer_auto_pause": "Pausa automatica", @@ -561,7 +561,12 @@ "flags": "Indicatori", "include_phase_map": "Includi mappa delle fasi", "include_settings": "Includi impostazioni di rilevamento e riconoscimento", - "show_contributor": "Mostra i nomi dei collaboratori" + "show_contributor": "Mostra i nomi dei collaboratori", + "task_pg_detail": "Simula ciclo", + "task_split": "Divisione del ciclo", + "task_trim": "Ritaglio del ciclo", + "task_merge": "Unione dei cicli", + "task_rebuild": "Ricostruzione degli inviluppi" }, "log": { "all_levels": "Tutti i livelli", @@ -645,7 +650,7 @@ "artifact_dip_detail": "È sceso sotto la banda di potenza normale per ~{n}s.", "artifact_footer": "Evidenziato nel grafico sopra. Si tratta di artefatti temporanei (ad esempio la porta aperta a metà ciclo), non necessariamente problemi.", "artifact_header": "{n} anomalie rilevate durante questo ciclo", - "artifact_pause_detail": "La potenza è scesa quasi a zero per ~{n}s poi è ripresa — probabilmente la porta è stata aperta a metà ciclo o il ciclo è stato messo in pausa.", + "artifact_pause_detail": "La potenza è scesa quasi a zero per ~{n}s poi è ripresa – probabilmente la porta è stata aperta a metà ciclo o il ciclo è stato messo in pausa.", "artifact_spike_detail": "Ha superato la banda di potenza normale per ~{n}s.", "auto_label_intro": "Assegnare profili a cicli senza etichetta la cui confidenza di corrispondenza supera la soglia.", "automations_intro": "WashData genera gli eventi {start} / {end} ed espone entità, quindi le notifiche e le azioni si costruiscono al meglio come normali automatizzazioni di Home Assistant. Le automatizzazioni che utilizzano questo dispositivo appaiono di seguito.", @@ -654,10 +659,10 @@ "collecting_data": "Raccolta dati - ancora {need} ciclo{plural} prima che l'ottimizzazione possa iniziare ({current}/{min}).", "compare_overlay_profiles": "Profili sovrapposti (debole)", "compare_profiles_tip": "Sovrapponi altri involucri del profilo sulla tabella sopra per vedere quale si adatta meglio a questo ciclo.", - "compare_selected_cycles": "Cicli selezionati (fissi) — mostra/nascondi", + "compare_selected_cycles": "Cicli selezionati (fissi) – mostra/nascondi", "consider_new_profile": "Valuta la possibilità di creare un nuovo profilo.", "coverage_gap": "i cicli recenti non hanno un profilo corrispondente ({pct}% degli ultimi 30).", - "coverage_gap_similar_cycles": "{count} cicli simili senza etichetta trovati — crea un profilo per iniziare a trovare corrispondenze.", + "coverage_gap_similar_cycles": "{count} cicli simili senza etichetta trovati – crea un profilo per iniziare a trovare corrispondenze.", "cycles_deleted": "{count} ciclo/i eliminato/i", "enough_data": "Dati sufficienti per apprendere ({current}/{min} cicli).", "export_description": "Esporta tutti i profili e i cicli in JSON o ripristina da un'esportazione precedente.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modelli ottimizzati per questa macchina.", "ml_loading": "caricamento ML...", "ml_settings_intro": "Due interruttori indipendenti: uno applica i modelli durante l'esecuzione di un ciclo, l'altro consente a WashData di adattarli alla tua macchina nel tempo.", - "name_first_program": "Hai cicli sufficienti — assegna un nome al tuo primo programma per iniziare l'abbinamento.", + "name_first_program": "Hai cicli sufficienti – assegna un nome al tuo primo programma per iniziare l'abbinamento.", "near_duplicate_cluster": "rilevato cluster di profili quasi duplicati. Il raggruppamento consente all'abbinamento di scegliere in modo affidabile tra sosia (ad esempio lo stesso programma a temperatura/centrifuga diversa).", "no_cycles_match": "Nessun ciclo corrisponde al filtro corrente.", "no_cycles_profile": "Nessun ciclo per questo profilo.", @@ -713,7 +718,7 @@ "notify_services_hint": "Usa gli ID servizio {entity} (separati da virgole per più servizi). Variabili del modello: {vars}.", "old_actions_warning": "Configurato con il vecchio editor delle azioni (ora rimosso). Si attivano ancora durante gli eventi del ciclo ma non possono più essere modificati qui. Convertili in una normale automazione o rimuovili.", "onboarding_progress": "{n} / 3 cicli osservati", - "onboarding_watching": "Usa l'elettrodomestico normalmente — WashData sta osservando. Dopo 3 cicli inizierà l'abbinamento dei programmi.", + "onboarding_watching": "Usa l'elettrodomestico normalmente – WashData sta osservando. Dopo 3 cicli inizierà l'abbinamento dei programmi.", "pending_feedback": "In attesa di feedback sul rilevamento", "performance_trend": "Andamento delle prestazioni ({n} cicli)", "pg_analysis_empty": "Carica un ciclo per vedere l'analisi di abbinamento.", @@ -763,7 +768,7 @@ "setting_changed": "Modificato da {old} a {new} il {date}", "settings_basic_note": "Vengono mostrate le impostazioni essenziali. Passa ad Avanzato per l'elenco completo.", "shape_drift_advisory": "⚠ Forma in deriva", - "shape_drift_detail": "Il profilo di potenza per questo profilo è cambiato nel tempo — possibile usura dell'apparecchio o manutenzione necessaria (es. decalcificazione, pulizia del filtro).", + "shape_drift_detail": "Il profilo di potenza per questo profilo è cambiato nel tempo – possibile usura dell'apparecchio o manutenzione necessaria (es. decalcificazione, pulizia del filtro).", "show_all_settings": "Mostra tutte le impostazioni", "showing_suggestions": "Visualizzazione dell'impostazione {count} con suggerimenti.", "split_intro": "Fare clic sul grafico per aggiungere o rimuovere un punto di divisione o per rilevare automaticamente gli spazi inattivi. Ogni segmento risultante può ottenere il proprio profilo.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Ripristino fallito: {error}", "toast_reverted": "{key} ripristinato", "toast_save_failed": "Salvataggio fallito: {error}", - "trend_duration_longer": "Durata tendenzialmente più lunga ({pct}%/ciclo) — media recente {avg}", - "trend_duration_shorter": "La durata tendenzialmente è più breve ({pct}%/ciclo) — media recente {avg}", + "trend_duration_longer": "Durata tendenzialmente più lunga ({pct}%/ciclo) – media recente {avg}", + "trend_duration_shorter": "La durata tendenzialmente è più breve ({pct}%/ciclo) – media recente {avg}", "trend_energy_down": "Energia in diminuzione ({pct}%/ciclo)", - "trend_energy_up": "Energia in aumento ({pct}%/ciclo) — media recente {avg}", + "trend_energy_up": "Energia in aumento ({pct}%/ciclo) – media recente {avg}", "trim_intro": "Trascina le maniglie rosse o inserisci i valori. Tutto fuori dalla finestra viene rimosso.", "tuning_suggestions_available": "{count} suggerimento di ottimizzazione disponibile dai cicli osservati. Appaiono accanto ai campi pertinenti.", "unsure_detected_prefix": "WashData non è sicuro di averlo rilevato", @@ -857,7 +862,9 @@ "share_guidelines_title": "Prima di condividere", "store_download_device_intro": "Adotta ogni programma condiviso e i suoi cicli di riferimento sul tuo dispositivo. I tuoi cicli registrati e le tue statistiche non vengono modificati.", "store_share_device_intro": "Carica {brand} {model} con i cicli di riferimento che selezioni. Altri con lo stesso elettrodomestico possono adottare i tuoi programmi. Le voci vengono revisionate prima di apparire pubblicamente.", - "share_profile_no_cycles": "Nessun ciclo di riferimento -- contrassegna un ciclo come ⭐ nella scheda Cicli per includere questo profilo" + "share_profile_no_cycles": "Nessun ciclo di riferimento -- contrassegna un ciclo come ⭐ nella scheda Cicli per includere questo profilo", + "advisory_phase_inconsistent": "'{name}' sembra mescolare programmi o temperature diversi - i suoi cicli riscaldano per intervalli di tempo molto diversi. Dividerlo in profili separati (ad es. per temperatura) migliorerà la corrispondenza e le stime dei tempi.", + "advisory_phase_inconsistent_title": "⚠ Possibili programmi misti" }, "pg_desc": { "abrupt_drop_watts": "Calo improvviso trattato come fine immediata", @@ -869,12 +876,19 @@ "start_duration_threshold": "Secondi sopra la soglia per confermare l'avvio", "start_threshold_w": "Watt minimi per contare come avviato", "stop_threshold_w": "Al di sotto, la macchina è considerata spenta", - "dtw_bandwidth": "Quanta deformazione temporale consente la corrispondenza della forma", - "corr_weight": "Peso della forma della curva rispetto al livello di potenza nella corrispondenza", - "duration_weight": "Quanto la concordanza della durata influisce sulla corrispondenza", - "energy_weight": "Quanto la concordanza dell'energia influisce sulla corrispondenza", - "profile_match_min_duration_ratio": "Ciclo più breve (rispetto al profilo) che può ancora corrispondere", - "profile_match_max_duration_ratio": "Ciclo più lungo (rispetto al profilo) che può ancora corrispondere" + "dtw_bandwidth": "Fase 3: banda di deformazione Sakoe-Chiba (0 = DTW disattivato; predefinito 0,2 = 20% della lunghezza del ciclo)", + "corr_weight": "Fase 2: equilibrio tra forma della curva (correlazione) e livello di potenza (MAE); predefinito 0,45", + "duration_weight": "Fase 4: quanto la concordanza di durata influisce sul punteggio finale (predefinito 0,22)", + "energy_weight": "Fase 4: quanto la concordanza di energia influisce sul punteggio finale (predefinito 0,22)", + "profile_match_min_duration_ratio": "Fase 1: durata minima del ciclo come frazione della durata del profilo; predefinito 0,1 (10%)", + "profile_match_max_duration_ratio": "Fase 1: durata massima del ciclo come frazione della durata del profilo; predefinito 1,5 (150%)", + "keep_min_score": "Fase 2: punteggio minimo per restare in gara; il predefinito 0,1 ammette candidati deboli, le fasi successive scelgono il migliore", + "dtw_blend": "Fase 3: 0 = solo punteggio centrale, 1 = solo punteggio DTW, 0,5 = mix equilibrato (predefinito)", + "dtw_ensemble_w": "Fase 3 (modalità ensemble): peso su L1 scalato rispetto a DTW derivato; il predefinito 0,7 favorisce la sensibilità al livello", + "dtw_ddtw_scale": "Fase 3: distanza di semi-saturazione DDTW; minore = maggiore sensibilità alla forma (predefinito 30)", + "dtw_refine_top_n": "Fase 3: candidati rivalutati da DTW; aumentare a 7-9 se il profilo corretto si classifica 4°-5° (predefinito 5)", + "duration_scale": "Fase 4: log-rapporto in cui la concordanza di durata si dimezza; minore = penalità più severa (predefinito 0,175)", + "energy_scale": "Fase 4: log-rapporto in cui la concordanza di energia si dimezza; minore = penalità più severa (predefinito 0,25)" }, "phase_desc": { "anti_crease": "Brevi tumble occasionali dopo il completamento per ridurre le grinze del bucato.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Segnali esterni opzionali: un trigger di fine, un sensore di porta, un interruttore di pausa e il promemoria di scarico.", "label": "Trigger e porte" + }, + "phase_eta": { + "label": "Tempo rimanente", + "intro": "Tempo rimanente in base alle fasi, per le macchine la cui durata del ciclo dipende dalla temperatura o dalla centrifuga." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Prezzo fisso per kWh utilizzato per i dati sui costi quando non è impostata alcuna entità di prezzo in tempo reale sopra.", "label": "Prezzo energia statico (per kWh)" }, + "energy_sensor": { + "doc": "Contatore di energia cumulativa opzionale (total_increasing in kWh/Wh, ad es. il contatore di consumo totale della presa stessa). Quando è impostato, l'energia riportata di ogni ciclo viene ricavata dalla differenza di questo contatore tra inizio e fine, il che evita la sottostima che si ottiene integrando un sensore di potenza che aggiorna lentamente. Ripiega sul valore integrato se la lettura manca, se la sua unità è sconosciuta o se il delta non è positivo. Lascia vuoto per continuare a integrare il sensore di potenza.", + "label": "Entità del contatore di energia" + }, "expose_debug_entities": { "doc": "Pubblica entità HA diagnostiche aggiuntive (confidenza della corrispondenza, ambiguità, stati interni). Disattivato mantiene l'elenco delle entità pulito per l'uso normale.", "label": "Esponi entità di debug" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Mostra l'attribuzione \"di \" sugli elettrodomestici della community e sui cicli di riferimento." + }, + "enable_phase_matching": { + "label": "Tempo rimanente in base alle fasi", + "doc": "Suddivide ogni ciclo in corso in fasi (riscaldamento, lavaggio, centrifuga) e distribuisce il tempo rimanente per fase, combinato con la stima classica - basandosi sulla ripartizione per fasi all'inizio del ciclo e sulla stima classica verso la fine. Questo personalizza il conto alla rovescia in base a quanto la tua macchina riscalda e funziona realmente, cosa più evidente nella prima metà di un ciclo. Disattivato = solo la stima classica. È interessata solo la visualizzazione del tempo rimanente; la corrispondenza dei programmi e il rilevamento dei cicli restano invariati." + }, + "keep_min_score": { + "label": "Score min corrispondenza", + "doc": "Punteggio di similarità minimo che un candidato deve avere per restare in gara. Il predefinito 0,1 è volutamente permissivo - ammette anche candidati deboli e si affida alle fasi successive per trovare la migliore corrispondenza. Aumentarlo per eliminare prima i profili improbabili; ridurlo per ampliare il pool iniziale di candidati." + }, + "dtw_blend": { + "label": "Fusione Warp", + "doc": "Quanto il punteggio di allineamento per deformazione temporale (DTW) sostituisce il punteggio centrale della fase 2. 0 = usa solo il punteggio della fase 2, 1 = usa solo il punteggio DTW, 0,5 (predefinito) = mix equilibrato. Aumentarlo per affidarsi maggiormente all'allineamento Warp quando i programmi hanno livelli di potenza simili ma schemi temporali diversi." + }, + "dtw_ensemble_w": { + "label": "Mix Warp ensemble", + "doc": "In modalità DTW ensemble (la predefinita), il peso su L1 scalato rispetto a DTW derivato (DDTW). 1,0 = solo L1 scalato, 0 = solo DDTW, 0,7 = predefinito. La variante L1 scalata confronta i livelli di potenza; la variante derivata reagisce a come la potenza cambia nel tempo. Ensemble li combina entrambi." + }, + "dtw_ddtw_scale": { + "label": "Scala Warp derivata", + "doc": "Distanza di semi-saturazione per la valutazione DTW derivata. Il punteggio si dimezza quando la distanza DDTW è uguale a questo valore. Minore = maggiore sensibilità alle differenze di forma. Il predefinito 30 è calibrato su tracce di potenza tipiche degli elettrodomestici. Influisce solo sulle modalità DTW ensemble e ddtw." + }, + "dtw_refine_top_n": { + "label": "Conteggio affinamento Warp", + "doc": "Quanti candidati principali della fase 2 vengono rivalutati da DTW. DTW è più costoso, quindi viene applicato solo agli N migliori candidati. Predefinito 5. Aumentarlo (a 7-9) se il profilo corretto a volte raggiunge solo il 4° o 5° posto dopo la fase 2 - DTW può recuperarlo. Ridurlo velocizza leggermente il matching." + }, + "duration_scale": { + "label": "Nitidezza durata", + "doc": "Precisione della penalità di concordanza di durata nella fase 4. È il log-rapporto in cui il punteggio di concordanza si dimezza. Minore = più severo: una discordanza di durata penalizza di più. Il predefinito 0,175 corrisponde a circa il 18% di tolleranza di durata a metà peso. Combinare con Peso durata." + }, + "energy_scale": { + "label": "Nitidezza energia", + "doc": "Precisione della penalità di concordanza di energia. Minore = più severo: una discordanza di energia penalizza di più. Il predefinito 0,25 è intenzionalmente più indulgente della Precisione di durata, perché l'energia varia con il carico. Combinare con Peso energia." } }, "setting_group": { @@ -1570,24 +1624,24 @@ "lower": "Etichetta più cicli automaticamente; alcuni possono essere mal identificati" }, "duration_tolerance": { - "higher": "Accetta un intervallo di durata più ampio — corrispondenza più flessibile", - "lower": "Richiede una durata più vicina — più rigoroso, può rifiutare cicli insoliti" + "higher": "Accetta un intervallo di durata più ampio – corrispondenza più flessibile", + "lower": "Richiede una durata più vicina – più rigoroso, può rifiutare cicli insoliti" }, "end_energy_threshold": { "higher": "Chiude solo i cicli che hanno consumato energia sostanziale", "lower": "Chiude anche cicli brevi o a basso consumo energetico" }, "end_repeat_count": { - "higher": "Richiede che il segnale di fine si ripeta più volte — più robusto, leggermente più lento", - "lower": "Termina dopo meno ripetizioni — rilevamento più rapido, maggior rischio di fine falsa" + "higher": "Richiede che il segnale di fine si ripeta più volte – più robusto, leggermente più lento", + "lower": "Termina dopo meno ripetizioni – rilevamento più rapido, maggior rischio di fine falsa" }, "learning_confidence": { - "higher": "Impara solo da corrispondenze molto affidabili — più lento ma più preciso", + "higher": "Impara solo da corrispondenze molto affidabili – più lento ma più preciso", "lower": "Impara da più cicli; il modello potrebbe essere meno preciso" }, "min_off_gap": { "higher": "Richiede più tempo di inattività prima di avviare un nuovo ciclo", - "lower": "Una breve inattività avvia un nuovo ciclo — esecuzioni consecutive possono dividersi" + "lower": "Una breve inattività avvia un nuovo ciclo – esecuzioni consecutive possono dividersi" }, "min_power": { "higher": "Meno sensibile al consumo in standby; potrebbe non rilevare programmi a bassa potenza", @@ -1598,24 +1652,24 @@ "lower": "Interrompe prima un ciclo bloccato" }, "off_delay": { - "higher": "Più tempo per le pause dell'elettrodomestico — gestisce lunghe fasi di ammollo o asciugatura", - "lower": "Rilevamento della fine più rapido — ideale per elettrodomestici con on/off netto" + "higher": "Più tempo per le pause dell'elettrodomestico – gestisce lunghe fasi di ammollo o asciugatura", + "lower": "Rilevamento della fine più rapido – ideale per elettrodomestici con on/off netto" }, "profile_match_threshold": { "higher": "Conferma solo le corrispondenze ad alta affidabilità; più cicli rimangono senza etichetta", "lower": "Corrisponde a più cicli; alcuni potrebbero essere leggermente errati" }, "running_dead_zone": { - "higher": "Ignora brevi picchi di potenza durante il riposo — meno sensibile al rumore", - "lower": "Risponde a impulsi più brevi — rileva attività transitorie rapide" + "higher": "Ignora brevi picchi di potenza durante il riposo – meno sensibile al rumore", + "lower": "Risponde a impulsi più brevi – rileva attività transitorie rapide" }, "start_threshold_w": { "higher": "Evita avvii falsi causati da brevi picchi di potenza", "lower": "Rileva programmi a bassa potenza; può attivarsi con il rumore" }, "stop_threshold_w": { - "higher": "Termina il ciclo prima — potrebbe chiudersi durante una pausa", - "lower": "Attende più a lungo per terminare — meno completamenti prematuri" + "higher": "Termina il ciclo prima – potrebbe chiudersi durante una pausa", + "lower": "Attende più a lungo per terminare – meno completamenti prematuri" }, "watchdog_interval": { "higher": "Fasi silenziose più lunghe consentite; meno false fermate forzate", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Ottimizza: {param}" + }, + "pg_detail": { + "simulate": "Simula ciclo" + }, + "split": { + "apply": "Divisione del ciclo" + }, + "trim": { + "apply": "Ritaglio del ciclo" + }, + "merge": { + "apply": "Unione dei cicli" + }, + "rebuild": { + "envelopes": "Ricostruzione degli inviluppi" + }, + "cancelling": "Annullamento..." + }, + "setup": { + "hdr": { + "card": "Configurazione dispositivo", + "healthy_chip": "Configurazione completata" + }, + "phase0": { + "washer": "WashData sta già rilevando i tuoi cicli. Registra il tuo primo ciclo per abilitare i nomi dei programmi e le stime dei tempi.", + "dishwasher": "WashData sta monitorando. Le lavastoviglie hanno cicli complessi – è vivamente consigliato registrare il primo ciclo. Se un ciclo rilevato dura troppo a lungo, usa l'editor dei cicli per ritagliarlo prima di salvarlo come profilo.", + "generic": "WashData sta monitorando. Registra o etichetta un ciclo rilevato per iniziare a creare profili." + }, + "phase1a": { + "labelled": "Buon inizio – il tuo primo programma è salvato. Per dati più precisi, considera di registrare il prossimo ciclo con il widget di registrazione." + }, + "phase1b": { + "recorded": "La tua registrazione è stata salvata come {profile_name}. Ora registra o etichetta gli altri programmi che usi di solito per ampliare la copertura." + }, + "phase1c": { + "verify": "Hai {count} programmi dalla community. Esegui un ciclo per verificare che WashData li riconosca correttamente – l'abbinamento migliorerà man mano che il dispositivo accumula il proprio storico." + }, + "phase2": { + "cluster": "WashData ha rilevato {count} cicli che non corrispondono a nessun programma salvato – si assomigliano tra loro. Vuoi creare un nuovo profilo per essi?", + "unmatched": "L'ultimo ciclo non ha corrisposto a nessun programma salvato. È un nuovo programma?" + }, + "phase3": { + "suggestions": "WashData ha raccomandazioni sulle impostazioni basate sulla cronologia dei cicli – revisionale per migliorare la precisione di rilevamento.", + "groups": "Alcuni dei tuoi profili sembrano lo stesso programma a temperature diverse. Organizzali in un gruppo per un abbinamento più affidabile.", + "phases": "Aggiungi le fasi del programma a {profile_name} per stime più accurate del tempo rimanente." + }, + "phase4": { + "healthy": "Questo dispositivo è completamente configurato ({profile_count} profili)." + }, + "cta": { + "start_recording": "Avvia registrazione", + "label_detected_cycle": "Ho già un ciclo rilevato – etichettalo invece", + "browse_cycles": "Sfoglia i cicli per etichettarne un altro", + "view_profiles": "Visualizza i tuoi profili", + "create_from_cluster": "Crea profilo da questi cicli", + "create_profile": "Crea un profilo per questo programma", + "review_suggestions": "Rivedi i suggerimenti", + "organise_profiles": "Organizza i profili", + "configure_phases": "Configura le fasi", + "skip_step": "Salta questo passaggio", + "skip_forever": "Non mostrare più", + "hide_guidance": "Nascondi guida", + "show_guidance": "Mostra guida" } } } diff --git a/custom_components/ha_washdata/translations/panel/ja.json b/custom_components/ha_washdata/translations/panel/ja.json index c0eb73f..aeb7591 100644 --- a/custom_components/ha_washdata/translations/panel/ja.json +++ b/custom_components/ha_washdata/translations/panel/ja.json @@ -1,6 +1,6 @@ { "badge": { - "artifact_tip": "{n} 個の異常が検出されました (例: サイクルの途中でドアが開いた) — 開いてグラフ上で確認します", + "artifact_tip": "{n} 個の異常が検出されました (例: サイクルの途中でドアが開いた) – 開いてグラフ上で確認します", "auto": "(自動検出)", "built_in_tag": "内蔵", "declining": "↘ 減少", @@ -87,7 +87,7 @@ "on_cycle_finished": "サイクルが終了しました", "on_cycle_started": "サイクル開始時", "pause_cycle": "一時停止", - "pause_cycle_tip": "実行中のサイクルを一時停止します — 家電は停止した場所から再開します", + "pause_cycle_tip": "実行中のサイクルを一時停止します – 家電は停止した場所から再開します", "pg_apply_device": "デバイスに適用", "pg_autofill": "自動入力", "play": "再生", @@ -97,8 +97,8 @@ "process_tip": "録音されたトレースを新しいまたは既存のプロファイルとして保存します", "rebuild": "エンベロープを再構築する", "rebuild_envelope": "エンベロープの再構築", - "rebuild_tip": "ラベル付きサイクルからすべてのプロファイルの予想電力エンベロープ(最小/最大バンド)を再計算します — 新しいサイクルにラベルを付けた後や古いラベルを修正した後に実行します", - "rec_start_tip": "家電の電力トレースの録音を開始します — サイクルを実行する直前に開始します", + "rebuild_tip": "ラベル付きサイクルからすべてのプロファイルの予想電力エンベロープ(最小/最大バンド)を再計算します – 新しいサイクルにラベルを付けた後や古いラベルを修正した後に実行します", + "rec_start_tip": "家電の電力トレースの録音を開始します – サイクルを実行する直前に開始します", "rec_stop": "停止", "rec_stop_tip": "録音を停止し、キャプチャされたトレースをレビューのために保持します", "record": "録音を開始する", @@ -231,7 +231,7 @@ "interval": "サンプリング間隔 ({si} 秒) の少なくとも 2 倍である必要があります", "sampling": "サンプリング間隔はウォッチドッグ間隔 ({wi} 秒) の半分以下である必要があります" }, - "settings_banner": "{n} 件の設定競合{s} — 強調表示されたセクションを確認し、保存前に修正してください。", + "settings_banner": "{n} 件の設定競合{s} – 強調表示されたセクションを確認し、保存前に修正してください。", "settings_banner_btn": "最初へ" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "予想される曲線オーバーレイを表示 (一致したプロファイル、オレンジ色)", "show_raw": "ライブ電力グラフに生ソケット切替を表示", "sparkline": "最近のサイクル継続時間の傾向", - "stage2": "ステージ 2 — コア類似度", - "stage3": "ステージ 3 — DTW", - "stage4": "ステージ 4 — 一致度", + "stage2": "ステージ 2 – コア類似度", + "stage3": "ステージ 3 – DTW", + "stage4": "ステージ 4 – 一致度", "status": "状態", "tail_trim": "テールトリム (複数可)", "timer_auto_pause": "自動一時停止", @@ -561,7 +561,12 @@ "flags": "フラグ", "include_phase_map": "フェーズマップを含める", "include_settings": "検出・マッチング設定を含める", - "show_contributor": "投稿者名を表示" + "show_contributor": "投稿者名を表示", + "task_pg_detail": "サイクルをシミュレート", + "task_split": "サイクルを分割中", + "task_trim": "サイクルをトリミング中", + "task_merge": "サイクルを結合中", + "task_rebuild": "エンベロープを再構築中" }, "log": { "all_levels": "すべてのレベル", @@ -654,10 +659,10 @@ "collecting_data": "データを収集中です。微調整を開始するにはあと {need} サイクル{plural}必要です({current}/{min})。", "compare_overlay_profiles": "オーバーレイプロファイル (淡い)", "compare_profiles_tip": "他のプロファイル エンベロープを上のチャートに重ねて、どれがこのサイクルに最も適合するかを確認します。", - "compare_selected_cycles": "選択されたサイクル (実線) — 表示/非表示", + "compare_selected_cycles": "選択されたサイクル (実線) – 表示/非表示", "consider_new_profile": "新しいプロファイルの作成を検討してください。", "coverage_gap": "最近のサイクルには一致するプロファイルがありません (過去 30 回の {pct}%)。", - "coverage_gap_similar_cycles": "{count} 件の類似した未ラベルサイクルが見つかりました — プロファイルを作成してマッチングを開始してください。", + "coverage_gap_similar_cycles": "{count} 件の類似した未ラベルサイクルが見つかりました – プロファイルを作成してマッチングを開始してください。", "cycles_deleted": "{count} 件のサイクルを削除しました", "enough_data": "学習に十分なデータがあります({current}/{min} サイクル)。", "export_description": "すべてのプロファイルとサイクルを JSON にエクスポートするか、以前のエクスポートから復元します。", @@ -686,7 +691,7 @@ "ml_learned_intro": "本機に合わせてチューニングされたモデル。", "ml_loading": "ML を読み込み中…", "ml_settings_intro": "2 つの独立したスイッチ: 1 つはサイクルの実行中にモデルを適用し、もう 1 つは WashData が時間の経過とともにモデルをマシンに合わせて微調整します。", - "name_first_program": "十分なサイクルがそろいました — 最初のプログラムに名前を付けて一致を開始しましょう。", + "name_first_program": "十分なサイクルがそろいました – 最初のプログラムに名前を付けて一致を開始しましょう。", "near_duplicate_cluster": "ほぼ重複したプロファイル クラスターが検出されました。グループ化により、類似したもの (例: 異なる温度/スピンの同じプログラム) の間でマッチングを確実に選択できるようになります。", "no_cycles_match": "現在のフィルターに一致するサイクルはありません。", "no_cycles_profile": "このプロファイルにはサイクルがありません。", @@ -713,7 +718,7 @@ "notify_services_hint": "{entity} サービス ID を使用してください(複数はカンマ区切り)。テンプレート変数:{vars}。", "old_actions_warning": "古いアクション エディター (現在は削除されています) で構成されています。これらは引き続きサイクル イベントで起動されますが、ここでは編集できなくなりました。それらを通常のオートメーションに変換するか、削除します。", "onboarding_progress": "{n} / 3 サイクルを観測", - "onboarding_watching": "家電をいつも通り使用してください — WashData が監視しています。3 サイクル後にプログラムの一致が始まります。", + "onboarding_watching": "家電をいつも通り使用してください – WashData が監視しています。3 サイクル後にプログラムの一致が始まります。", "pending_feedback": "保留中の検出フィードバック", "performance_trend": "パフォーマンスの傾向 ({n} サイクル)", "pg_analysis_empty": "マッチ分析を見るにはサイクルを読み込んでください。", @@ -763,7 +768,7 @@ "setting_changed": "{date} に {old} から {new} に変更", "settings_basic_note": "必須の設定のみ表示しています。すべての一覧は「高度な」に切り替えてください。", "shape_drift_advisory": "⚠ 形状がずれています", - "shape_drift_detail": "このプロファイルの電力パターンが時間とともに変化しました — 家電の劣化またはメンテナンスが必要な可能性があります(例:スケール除去、フィルター清掃)。", + "shape_drift_detail": "このプロファイルの電力パターンが時間とともに変化しました – 家電の劣化またはメンテナンスが必要な可能性があります(例:スケール除去、フィルター清掃)。", "show_all_settings": "すべての設定を表示", "showing_suggestions": "{count} 設定を候補とともに表示しています。", "split_intro": "グラフをクリックして分割ポイントを追加または削除するか、アイドル ギャップを自動検出します。結果として得られる各セグメントは、独自のプロファイルを取得できます。", @@ -857,7 +862,9 @@ "share_guidelines_title": "共有前に", "store_download_device_intro": "共有されたすべてのプログラムとそのリファレンスサイクルをデバイスに引き継ぎます。ご自身の記録済みサイクルと統計には影響しません。", "store_share_device_intro": "選択したリファレンスサイクルとともに{brand} {model}をアップロードします。同じ機器を持つ他のユーザーがあなたのプログラムを引き継ぐことができます。エントリは公開前に審査されます。", - "share_profile_no_cycles": "リファレンスサイクルなし -- サイクルタブでサイクルを⭐としてマークしてこのプロファイルを含めてください" + "share_profile_no_cycles": "リファレンスサイクルなし -- サイクルタブでサイクルを⭐としてマークしてこのプロファイルを含めてください", + "advisory_phase_inconsistent": "'{name}' は異なるプログラムや温度が混在しているようです。加熱時間がサイクルごとに大きく異なります。温度ごとなど別々のプロファイルに分割すると、マッチングと時間推定が改善されます。", + "advisory_phase_inconsistent_title": "⚠ プログラム混在の可能性" }, "pg_desc": { "abrupt_drop_watts": "急激な低下は即時終了として扱う", @@ -869,12 +876,19 @@ "start_duration_threshold": "開始を確定するしきい値超えの秒数", "start_threshold_w": "開始とみなす最小ワット数", "stop_threshold_w": "これを下回ると停止とみなす", - "dtw_bandwidth": "形状マッチングで許容する時間ワーピングの量", - "corr_weight": "マッチングにおける曲線形状と電力レベルの重み", - "duration_weight": "実行時間の一致がマッチングに与える影響の大きさ", - "energy_weight": "消費電力量の一致がマッチングに与える影響の大きさ", - "profile_match_min_duration_ratio": "一致対象として許可される最短の運転時間(プロファイル比)", - "profile_match_max_duration_ratio": "一致対象として許可される最長の運転時間(プロファイル比)" + "dtw_bandwidth": "ステージ 3: Sakoe-Chiba ワープバンド (0 = DTW 無効; デフォルト 0.2 = サイクル長の 20%)", + "corr_weight": "ステージ 2: 曲線形状(相関)と電力レベル(MAE)のバランス; デフォルト 0.45", + "duration_weight": "ステージ 4: 運転時間の一致が最終スコアにどれだけ影響するか(デフォルト 0.22)", + "energy_weight": "ステージ 4: エネルギーの一致が最終スコアにどれだけ影響するか(デフォルト 0.22)", + "profile_match_min_duration_ratio": "ステージ 1: プロファイル所要時間に対するサイクルの最短許容比率; デフォルト 0.1 (10%)", + "profile_match_max_duration_ratio": "ステージ 1: プロファイル所要時間に対するサイクルの最長許容比率; デフォルト 1.5 (150%)", + "keep_min_score": "ステージ 2: 競合に残るための最低スコア; デフォルト 0.1 は弱い候補も許可し、後のステージが最良を選ぶ", + "dtw_blend": "ステージ 3: 0 = コアスコアのみ、1 = DTW スコアのみ、0.5 = 等割合ブレンド(デフォルト)", + "dtw_ensemble_w": "ステージ 3 (アンサンブルモード): スケール付き-L1 と微分 DTW の重み; デフォルト 0.7 はレベル考慮を優先", + "dtw_ddtw_scale": "ステージ 3: DDTW 半飽和距離; 小さいほど形状に敏感(デフォルト 30)", + "dtw_refine_top_n": "ステージ 3: DTW が再スコアリングする候補数; 正しいプロファイルが 4-5 位になる場合は 7-9 に増やす(デフォルト 5)", + "duration_scale": "ステージ 4: 運転時間の一致スコアが半分になる対数比率; 小さいほど厳格なペナルティ(デフォルト 0.175)", + "energy_scale": "ステージ 4: エネルギーの一致スコアが半分になる対数比率; 小さいほど厳格なペナルティ(デフォルト 0.25)" }, "phase_desc": { "anti_crease": "メインサイクル後にドラムが断続的に回転し、衣類のシワを防止します。", @@ -957,6 +971,10 @@ "triggers": { "intro": "オプションの外部信号:終了トリガー、ドアセンサー、一時停止スイッチ、取り出しリマインダー。", "label": "トリガーとドア" + }, + "phase_eta": { + "label": "残り時間", + "intro": "サイクルの長さが温度や脱水に左右される機器向けの、フェーズを考慮した残り時間。" } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "上記のライブ価格エンティティが設定されていない場合、コスト数値に使用される kWh あたりの固定価格。", "label": "静的エネルギー価格(kWhあたり)" }, + "energy_sensor": { + "doc": "積算エネルギーカウンター(total_increasing の kWh/Wh。例: プラグ自身の積算メーター)を任意で指定します。設定すると、各サイクルの表示エネルギーはこのカウンターの開始時から終了時までの差分から算出され、更新の遅い電力センサーを積分する際に生じる過少計上を回避できます。読み取り値が欠落している場合、単位が不明な場合、または差分が正でない場合は、積分値にフォールバックします。空欄のままにすると、電力センサーの積分を継続します。", + "label": "エネルギーメーターエンティティ" + }, "expose_debug_entities": { "doc": "追加の診断 HA エンティティ (一致信頼性、曖昧性、状態内部) を公開します。オフにすると、通常の使用のためにエンティティ リストがクリーンな状態に保たれます。", "label": "デバッグエンティティを公開" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "コミュニティの家電および基準サイクルに「by 」のクレジット表示をします。" + }, + "enable_phase_matching": { + "label": "フェーズを考慮した残り時間", + "doc": "実行中の各サイクルをフェーズ(加熱、洗い、脱水)に分割し、フェーズごとに残り時間を見積もって従来の推定値とブレンドします。サイクルの序盤ではフェーズごとの見積もりを重視し、終盤に近づくにつれて従来の推定値を重視します。これにより、お使いの機器が実際に加熱・運転する時間に合わせてカウントダウンが調整され、サイクルの前半で最も効果が表れます。オフ = 従来の推定値のみ。影響を受けるのは残り時間の表示だけで、プログラムのマッチングとサイクル検出は変わりません。" + }, + "keep_min_score": { + "label": "最低マッチスコア", + "doc": "候補がマッチング競合に残るために必要な最低類似度スコア。デフォルトの 0.1 は意図的に緩やかで、弱い候補も許可し、最良のマッチングを後のステージに委ねます。可能性の低いプロファイルを早期に除外するには値を上げ、候補プールを広げるには値を下げてください。" + }, + "dtw_blend": { + "label": "ワープブレンド", + "doc": "時間ワーピング (DTW) アライメントスコアがステージ 2 コアスコアをどれほど置き換えるか。0 = ステージ 2 スコアのみ使用、1 = DTW スコアのみ使用、0.5 (デフォルト) = 等割合ブレンド。プログラムの電力レベルが似ていても時間パターンが異なる場合、ワープアライメントへの依存度を高めるために値を上げてください。" + }, + "dtw_ensemble_w": { + "label": "ワープアンサンブル混合", + "doc": "アンサンブル DTW モード(デフォルト)において、スケール付き L1 と微分 DTW (DDTW) の重み配分。1.0 = スケール付き-L1 のみ、0 = DDTW のみ、0.7 = デフォルト。スケール付き-L1 変種は電力レベルを比較し、微分変種は電力がどう変化するかに反応します。アンサンブルは両者を組み合わせます。" + }, + "dtw_ddtw_scale": { + "label": "微分ワープスケール", + "doc": "微分 DTW スコアリングの半飽和距離。DDTW 距離がこの値と等しいとき、スコアが半分になります。小さいほど形状の差異に敏感になります。デフォルトの 30 は一般的な家電製品の電力トレースに合わせて調整されています。アンサンブルおよび ddtw DTW モードのみに影響します。" + }, + "dtw_refine_top_n": { + "label": "ワープ精製候補数", + "doc": "DTW によって再スコアリングされるステージ 2 上位候補の数。DTW は計算コストが高いため、上位 N 候補にのみ適用されます。デフォルト 5。ステージ 2 後に正しいプロファイルが 4 位または 5 位にしかならない場合は値を上げてください (7-9 程度) - DTW がそれを救えます。値を下げるとマッチングが若干速くなります。" + }, + "duration_scale": { + "label": "継続時間シャープネス", + "doc": "ステージ 4 の運転時間一致ペナルティの厳格さ。一致スコアが半分になる対数比率を指します。小さいほど厳格で、時間のズレはより大きな影響を与えます。デフォルト 0.175 は半ウェイト時に約 18% の許容誤差に相当します。運転時間の重みと組み合わせて使用してください。" + }, + "energy_scale": { + "label": "エネルギーシャープネス", + "doc": "ステージ 4 のエネルギー一致ペナルティの厳格さ。小さいほど厳格で、エネルギーのズレはより大きな影響を与えます。デフォルト 0.25 はエネルギーが負荷によって変動するため、時間の厳格さよりも意図的に緩やかに設定されています。エネルギーの重みと組み合わせて使用してください。" } }, "setting_group": { @@ -1766,6 +1820,68 @@ }, "pg_sweep": { "optimize": "最適化: {param}" + }, + "pg_detail": { + "simulate": "サイクルをシミュレート" + }, + "split": { + "apply": "サイクルを分割中" + }, + "trim": { + "apply": "サイクルをトリミング中" + }, + "merge": { + "apply": "サイクルを結合中" + }, + "rebuild": { + "envelopes": "エンベロープを再構築中" + } + }, + "setup": { + "hdr": { + "card": "デバイスのセットアップ", + "healthy_chip": "セットアップ完了" + }, + "phase0": { + "washer": "WashData はすでにサイクルを検出しています。プログラム名と残り時間の推定を有効にするには、最初のサイクルを記録してください。", + "dishwasher": "WashData は監視しています。食器洗い機のサイクルは複雑です – 最初のサイクルの記録を強くお勧めします。検出されたサイクルが長すぎる場合は、プロファイルとして保存する前にサイクル編集ツールで切り詰めてください。", + "generic": "WashData は監視しています。プロファイルの構築を始めるには、検出されたサイクルを記録またはラベル付けしてください。" + }, + "phase1a": { + "labelled": "良いスタートです – 最初のプログラムが保存されました。より正確なデータを得るには、レコーダーウィジェットで次のサイクルを記録することをご検討ください。" + }, + "phase1b": { + "recorded": "記録が {profile_name} として保存されました。カバレッジを広げるために、他のよく使うプログラムも記録またはラベル付けしてください。" + }, + "phase1c": { + "verify": "コミュニティから {count} 個のプログラムがあります。WashData が正しく認識するか確認するためにサイクルを実行してください – デバイスが独自の履歴を積み重ねるにつれ、マッチングが向上します。" + }, + "phase2": { + "cluster": "WashData は、保存されているプログラムに一致しない {count} 件のサイクルを確認しました – それらは互いに似ています。これらのための新しいプロファイルを作成しますか?", + "unmatched": "直前のサイクルはどの保存済みプログラムにも一致しませんでした。新しいプログラムでしょうか?" + }, + "phase3": { + "suggestions": "WashData はサイクル履歴に基づいた設定の推奨事項があります – 検出精度を向上させるために確認してください。", + "groups": "いくつかのプロファイルが異なる温度の同じプログラムのように見えます。より良いマッチングのためにグループに整理してください。", + "phases": "より正確な残り時間の推定のために {profile_name} にプログラムフェーズを追加してください。" + }, + "phase4": { + "healthy": "このデバイスは完全にセットアップされています({profile_count} 個のプロファイル)。" + }, + "cta": { + "start_recording": "記録を開始", + "label_detected_cycle": "すでに検出済みのサイクルがあります – それをラベル付けする", + "browse_cycles": "別のサイクルをラベル付けするために閲覧する", + "view_profiles": "プロファイルを表示", + "create_from_cluster": "これらのサイクルからプロファイルを作成", + "create_profile": "これのプロファイルを作成", + "review_suggestions": "提案を確認", + "organise_profiles": "プロファイルを整理", + "configure_phases": "フェーズを設定", + "skip_step": "このステップをスキップ", + "skip_forever": "今後表示しない", + "hide_guidance": "ガイダンスを非表示", + "show_guidance": "ガイダンスを表示" } } } diff --git a/custom_components/ha_washdata/translations/panel/ko.json b/custom_components/ha_washdata/translations/panel/ko.json index efe7dc3..f5661cf 100644 --- a/custom_components/ha_washdata/translations/panel/ko.json +++ b/custom_components/ha_washdata/translations/panel/ko.json @@ -15,7 +15,7 @@ "needs_review": "검토 필요", "overrun": "평소보다 더 오래 달렸어요", "poor_fit": "적합하지 않음", - "poor_fit_tip": "일치하지 않는 일치 기록 — 이 프로필을 다시 작성하는 것이 좋습니다.", + "poor_fit_tip": "일치하지 않는 일치 기록 – 이 프로필을 다시 작성하는 것이 좋습니다.", "restart_gap_tip": "{n} HA 재시작 공백: 전력 그래프에 빈 구간이 있습니다", "reviewed": "검토됨", "steady": "→ 꾸준한", @@ -87,7 +87,7 @@ "on_cycle_finished": "사이클 완료됨", "on_cycle_started": "주기 시작됨", "pause_cycle": "정지시키다", - "pause_cycle_tip": "실행 중인 사이클을 일시 중지합니다 — 기기가 중단된 지점부터 재개됩니다", + "pause_cycle_tip": "실행 중인 사이클을 일시 중지합니다 – 기기가 중단된 지점부터 재개됩니다", "pg_apply_device": "장치에 적용", "pg_autofill": "자동 채우기", "play": "재생", @@ -97,8 +97,8 @@ "process_tip": "기록된 트레이스를 새 프로필 또는 기존 프로필로 저장합니다", "rebuild": "봉투 다시 작성", "rebuild_envelope": "봉투 재구축", - "rebuild_tip": "라벨이 지정된 주기에서 모든 프로필의 예상 전력 포락선(최소/최대 범위)을 다시 계산합니다 — 새 주기에 라벨을 지정하거나 기존 주기를 수정한 후 실행합니다", - "rec_start_tip": "기기의 전력 트레이스 기록을 시작합니다 — 주기를 실행하기 직전에 시작합니다", + "rebuild_tip": "라벨이 지정된 주기에서 모든 프로필의 예상 전력 포락선(최소/최대 범위)을 다시 계산합니다 – 새 주기에 라벨을 지정하거나 기존 주기를 수정한 후 실행합니다", + "rec_start_tip": "기기의 전력 트레이스 기록을 시작합니다 – 주기를 실행하기 직전에 시작합니다", "rec_stop": "멈추다", "rec_stop_tip": "녹음을 중지하고 캡처된 트레이스를 검토를 위해 보관합니다", "record": "녹음 시작", @@ -231,7 +231,7 @@ "interval": "샘플링 간격 ({si} 초)의 최소 2배이어야 합니다", "sampling": "샘플링 간격은 워치독 간격 ({wi} 초)의 절반 이하이어야 합니다" }, - "settings_banner": "{n}건의 설정 충돌{s} — 강조 표시된 섹션을 확인하고 저장 전에 수정하세요.", + "settings_banner": "{n}건의 설정 충돌{s} – 강조 표시된 섹션을 확인하고 저장 전에 수정하세요.", "settings_banner_btn": "첫 번째로" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "예상 곡선 오버레이 표시(일치하는 프로필, 주황색)", "show_raw": "라이브 전력 그래프에 원시 소켓 토글 표시", "sparkline": "최근 사이클 지속 시간 추세", - "stage2": "2단계 — 핵심 유사도", - "stage3": "3단계 — DTW", - "stage4": "4단계 — 일치도", + "stage2": "2단계 – 핵심 유사도", + "stage3": "3단계 – DTW", + "stage4": "4단계 – 일치도", "status": "상태", "tail_trim": "테일 트림(들)", "timer_auto_pause": "자동 일시중지", @@ -561,7 +561,12 @@ "flags": "플래그", "include_phase_map": "페이즈 맵 포함", "include_settings": "감지 및 매칭 설정 포함", - "show_contributor": "기여자 이름 표시" + "show_contributor": "기여자 이름 표시", + "task_pg_detail": "사이클 시뮬레이션", + "task_split": "사이클 분할 중", + "task_trim": "사이클 트리밍 중", + "task_merge": "사이클 병합 중", + "task_rebuild": "엔벨로프 재구성 중" }, "log": { "all_levels": "모든 레벨", @@ -654,10 +659,10 @@ "collecting_data": "데이터 수집 중입니다. 미세 조정을 시작하려면 {need}번의 사이클{plural}이 더 필요합니다({current}/{min})。", "compare_overlay_profiles": "오버레이 프로필(희미함)", "compare_profiles_tip": "위 차트에 다른 프로필 봉투를 겹쳐서 어느 것이 이 주기에 가장 적합한지 확인하세요.", - "compare_selected_cycles": "선택한 주기(단색) — 표시/숨기기", + "compare_selected_cycles": "선택한 주기(단색) – 표시/숨기기", "consider_new_profile": "새 프로필을 만드는 것을 고려해 보세요.", "coverage_gap": "최근 주기에 일치하는 프로필이 없습니다(지난 30개 중 {pct}%).", - "coverage_gap_similar_cycles": "유사한 미라벨 사이클 {count}개를 발견했습니다 — 프로필을 만들어 매칭을 시작하세요.", + "coverage_gap_similar_cycles": "유사한 미라벨 사이클 {count}개를 발견했습니다 – 프로필을 만들어 매칭을 시작하세요.", "cycles_deleted": "{count}개 사이클이 삭제되었습니다", "enough_data": "학습할 충분한 데이터가 있습니다({current}/{min}사이클)。", "export_description": "모든 프로필과 사이클을 JSON으로 내보내거나 이전 내보내기에서 복원합니다.", @@ -686,7 +691,7 @@ "ml_learned_intro": "이 기계에 맞게 미세 조정된 모델입니다.", "ml_loading": "ML 로드 중…", "ml_settings_intro": "두 개의 독립적인 스위치: 하나는 사이클이 실행되는 동안 모델을 적용하고 다른 하나는 WashData가 시간이 지남에 따라 기계에 맞게 미세 조정할 수 있도록 합니다.", - "name_first_program": "충분한 사이클이 모였습니다 — 첫 프로그램에 이름을 지정하여 일치를 시작하세요.", + "name_first_program": "충분한 사이클이 모였습니다 – 첫 프로그램에 이름을 지정하여 일치를 시작하세요.", "near_duplicate_cluster": "거의 중복된 프로필 클러스터가 감지되었습니다. 그룹화를 통해 일치하는 항목을 유사 항목(예: 서로 다른 온도/회전의 동일한 프로그램) 중에서 안정적으로 선택할 수 있습니다.", "no_cycles_match": "현재 필터와 일치하는 사이클이 없습니다.", "no_cycles_profile": "이 프로필에는 사이클이 없습니다.", @@ -713,7 +718,7 @@ "notify_services_hint": "{entity} 서비스 ID를 사용하세요(여러 개는 쉼표로 구분)。템플릿 변수:{vars}。", "old_actions_warning": "이전 작업 편집기로 구성되었습니다(현재 제거됨). 사이클 이벤트에서는 여전히 실행되지만 여기에서는 더 이상 편집할 수 없습니다. 일반 자동화로 변환하거나 제거하십시오.", "onboarding_progress": "{n} / 3개 사이클 관찰됨", - "onboarding_watching": "가전을 평소처럼 사용하세요 — WashData가 관찰하고 있습니다. 3개 사이클 후 프로그램 일치가 시작됩니다.", + "onboarding_watching": "가전을 평소처럼 사용하세요 – WashData가 관찰하고 있습니다. 3개 사이클 후 프로그램 일치가 시작됩니다.", "pending_feedback": "보류 중인 감지 피드백", "performance_trend": "실적 추세({n}주기)", "pg_analysis_empty": "매칭 분석을 보려면 사이클을 불러오세요.", @@ -763,7 +768,7 @@ "setting_changed": "{date}에 {old}에서 {new}(으)로 변경됨", "settings_basic_note": "필수 설정만 표시하고 있습니다. 전체 목록을 보려면 고급으로 전환하세요.", "shape_drift_advisory": "⚠ 형태 변화 감지됨", - "shape_drift_detail": "이 프로필의 전력 패턴이 시간이 지남에 따라 변화했습니다 — 가전제품 마모 또는 유지보수가 필요할 수 있습니다 (예: 스케일 제거, 필터 청소).", + "shape_drift_detail": "이 프로필의 전력 패턴이 시간이 지남에 따라 변화했습니다 – 가전제품 마모 또는 유지보수가 필요할 수 있습니다 (예: 스케일 제거, 필터 청소).", "show_all_settings": "모든 설정 표시", "showing_suggestions": "제안사항과 함께 {count} 설정을 표시합니다.", "split_intro": "그래프를 클릭하여 분할 지점을 추가 또는 제거하거나 유휴 간격으로 자동 감지합니다. 각 결과 세그먼트는 자체 프로필을 얻을 수 있습니다.", @@ -789,10 +794,10 @@ "toast_revert_failed": "되돌리기 실패: {error}", "toast_reverted": "{key}을(를) 되돌렸습니다", "toast_save_failed": "저장 실패: {error}", - "trend_duration_longer": "기간이 길어지는 추세({pct}%/주기) — 최근 평균 {avg}", - "trend_duration_shorter": "기간이 짧아지는 추세({pct}%/주기) — 최근 평균 {avg}", + "trend_duration_longer": "기간이 길어지는 추세({pct}%/주기) – 최근 평균 {avg}", + "trend_duration_shorter": "기간이 짧아지는 추세({pct}%/주기) – 최근 평균 {avg}", "trend_energy_down": "에너지 감소 추세({pct}%/주기)", - "trend_energy_up": "에너지 상승 추세({pct}%/주기) — 최근 평균 {avg}", + "trend_energy_up": "에너지 상승 추세({pct}%/주기) – 최근 평균 {avg}", "trim_intro": "빨간색 핸들을 드래그하거나 값을 입력합니다. 창 밖의 모든 것이 제거됩니다.", "tuning_suggestions_available": "관찰된 주기에서 사용할 수 있는 {count} 조정 제안입니다. 관련 필드 옆에 표시됩니다.", "unsure_detected_prefix": "WashData가 감지되었는지 확실하지 않습니다.", @@ -857,7 +862,9 @@ "share_guidelines_title": "공유하기 전에", "store_download_device_intro": "공유된 모든 프로그램과 해당 참조 사이클을 기기에 채택합니다. 직접 기록한 사이클과 통계는 영향을 받지 않습니다.", "store_share_device_intro": "선택한 참조 사이클과 함께 {brand} {model}을 업로드합니다. 같은 가전제품을 가진 다른 사용자가 프로그램을 채택할 수 있습니다. 항목은 공개 전에 검토됩니다.", - "share_profile_no_cycles": "참조 사이클 없음 -- 이 프로파일을 포함하려면 사이클 탭에서 사이클을 ⭐로 표시하세요" + "share_profile_no_cycles": "참조 사이클 없음 -- 이 프로파일을 포함하려면 사이클 탭에서 사이클을 ⭐로 표시하세요", + "advisory_phase_inconsistent": "'{name}'은(는) 서로 다른 프로그램이나 온도가 섞여 있는 것 같습니다. 사이클마다 가열 시간이 크게 다릅니다. 온도별 등으로 별도의 프로필로 나누면 매칭과 시간 추정이 개선됩니다.", + "advisory_phase_inconsistent_title": "⚠ 프로그램이 섞였을 수 있음" }, "pg_desc": { "abrupt_drop_watts": "급격한 강하는 즉시 종료로 처리", @@ -869,12 +876,19 @@ "start_duration_threshold": "시작 확정을 위한 임계값 초과 초", "start_threshold_w": "시작으로 간주할 최소 와트", "stop_threshold_w": "이 값 미만이면 기기가 꺼진 것으로 간주", - "dtw_bandwidth": "형상 매칭에서 허용하는 시간 왜곡의 정도", - "corr_weight": "매칭에서 곡선 형상과 전력 레벨의 가중치", - "duration_weight": "실행 시간 일치가 매칭에 미치는 영향의 정도", - "energy_weight": "에너지 일치가 매칭에 미치는 영향의 정도", - "profile_match_min_duration_ratio": "일치 대상으로 허용되는 최단 실행 시간(프로파일 대비)", - "profile_match_max_duration_ratio": "일치 대상으로 허용되는 최장 실행 시간(프로파일 대비)" + "dtw_bandwidth": "Stage 3: Sakoe-Chiba 워프 대역폭 (0 = DTW 비활성; 기본값 0.2 = 사이클 길이의 20%)", + "corr_weight": "Stage 2: 곡선 형태(상관관계)와 전력 수준(MAE) 사이의 균형; 기본값 0.45", + "duration_weight": "Stage 4: 실행 시간 일치가 최종 점수에 미치는 영향(기본값 0.22)", + "energy_weight": "Stage 4: 에너지 일치가 최종 점수에 미치는 영향(기본값 0.22)", + "profile_match_min_duration_ratio": "Stage 1: 프로파일 지속 시간의 분수로서 최소 사이클 길이; 기본값 0.1 (10%)", + "profile_match_max_duration_ratio": "Stage 1: 프로파일 지속 시간의 분수로서 최대 사이클 길이; 기본값 1.5 (150%)", + "keep_min_score": "Stage 2: 경쟁에 남기 위한 최소 점수; 기본값 0.1은 약한 후보도 허용하며, 이후 단계에서 최선을 선택", + "dtw_blend": "Stage 3: 0 = 핵심 점수만, 1 = DTW 점수만, 0.5 = 동등 혼합(기본값)", + "dtw_ensemble_w": "Stage 3 (앙상블 모드): 스케일 L1 대 도함수 DTW 가중치; 기본값 0.7은 수준 인식 방식을 선호", + "dtw_ddtw_scale": "Stage 3: DDTW 반포화 거리; 작을수록 형상에 민감(기본값 30)", + "dtw_refine_top_n": "Stage 3: DTW가 재점수화하는 후보 수; 올바른 프로파일이 4-5위면 7-9로 높임(기본값 5)", + "duration_scale": "Stage 4: 지속 시간 일치 점수가 반으로 줄어드는 로그 비율; 작을수록 엄격한 패널티(기본값 0.175)", + "energy_scale": "Stage 4: 에너지 일치 점수가 반으로 줄어드는 로그 비율; 작을수록 엄격한 패널티(기본값 0.25)" }, "phase_desc": { "anti_crease": "메인 사이클 후 드럼이 간헐적으로 회전하여 의류 구김을 방지합니다.", @@ -957,6 +971,10 @@ "triggers": { "intro": "선택적 외부 신호: 종료 트리거, 문 센서, 일시 정지 스위치, 꺼내기 알림.", "label": "트리거와 문" + }, + "phase_eta": { + "label": "남은 시간", + "intro": "사이클 길이가 온도나 탈수에 따라 달라지는 기기를 위한, 단계를 고려한 남은 시간." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "위에 실시간 가격 엔터티가 설정되지 않은 경우 비용 수치에 사용되는 kWh당 고정 가격입니다.", "label": "고정 에너지 가격 (kWh당)" }, + "energy_sensor": { + "doc": "선택적 누적 에너지 카운터(total_increasing kWh/Wh, 예: 플러그 자체의 누적 계량기)입니다. 설정하면 각 사이클의 표시 에너지가 이 카운터의 시작부터 종료까지의 차이(delta)에서 계산되므로, 보고 주기가 느린 전력 센서를 적분할 때 발생하는 과소 집계를 방지합니다. 판독값이 없거나 단위를 알 수 없거나 차이가 양수가 아닌 경우 적분된 값으로 대체됩니다. 비워 두면 전력 센서를 계속 적분합니다.", + "label": "에너지 계량기 엔티티" + }, "expose_debug_entities": { "doc": "추가 진단 HA 엔터티를 게시합니다(신뢰도, 모호성, 상태 내부 일치). 끄면 일반적인 사용을 위해 엔터티 목록을 깨끗하게 유지합니다.", "label": "디버그 엔티티 노출" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "커뮤니티 가전제품 및 참조 사이클에 \"by \" 기여자 표시를 보여줍니다." + }, + "enable_phase_matching": { + "label": "단계 기반 남은 시간", + "doc": "실행 중인 각 사이클을 단계(가열, 세탁, 탈수)로 나누고 단계별로 남은 시간을 배분하여 기존 추정값과 혼합합니다. 사이클 초반에는 단계별 배분을, 종료에 가까워질수록 기존 추정값을 더 중시합니다. 이렇게 하면 기기가 실제로 가열하고 작동하는 시간에 맞춰 카운트다운이 개인화되며, 사이클 전반부에서 가장 뚜렷하게 나타납니다. 꺼짐 = 기존 추정값만 사용. 남은 시간 표시에만 영향을 미치며, 프로그램 매칭과 사이클 감지는 변경되지 않습니다." + }, + "keep_min_score": { + "label": "최소 매칭 점수", + "doc": "후보가 매칭 경쟁에 남기 위해 필요한 최소 유사도 점수입니다. 기본값 0.1은 의도적으로 허용적입니다. 약한 후보도 허용하고 이후 단계에서 최선을 찾도록 합니다. 가능성이 낮은 프로파일을 더 일찍 제거하려면 높이고, 초기 후보 풀을 넓히려면 낮추세요." + }, + "dtw_blend": { + "label": "워프 블렌드", + "doc": "시간 워프(DTW) 정렬 점수가 Stage 2 핵심 점수를 얼마나 대체하는지 나타냅니다. 0 = Stage 2 점수만 사용, 1 = DTW 점수만 사용, 0.5 (기본값) = 동등 혼합. 프로그램들이 유사한 전력 수준이지만 다른 타이밍 패턴을 가질 때 워프 정렬에 더 의존하려면 높이세요." + }, + "dtw_ensemble_w": { + "label": "워프 앙상블 혼합", + "doc": "앙상블 DTW 모드(기본값)에서 스케일 L1과 도함수 DTW(DDTW)의 가중치 배분입니다. 1.0 = 모두 스케일-L1, 0 = 모두 DDTW, 0.7 = 기본값. 스케일-L1 방식은 전력 수준을 비교하고, 도함수 방식은 전력이 시간에 따라 변하는 방식에 반응합니다. 앙상블은 두 가지를 결합합니다." + }, + "dtw_ddtw_scale": { + "label": "미분 워프 스케일", + "doc": "도함수 DTW 점수화를 위한 반포화 거리입니다. DDTW 거리가 이 값과 같을 때 점수가 반으로 줄어듭니다. 작을수록 형상 차이에 민감합니다. 기본값 30은 일반적인 가전제품 전력 추적에 맞게 보정되어 있습니다. 앙상블 및 ddtw DTW 모드에만 영향을 미칩니다." + }, + "dtw_refine_top_n": { + "label": "워프 정제 개수", + "doc": "DTW가 재점수화하는 Stage 2 상위 후보 수입니다. DTW는 계산 비용이 높아 상위 N개 후보에만 적용됩니다. 기본값 5. 올바른 프로파일이 Stage 2 후 4위나 5위에 그치는 경우 값을 높이세요(7-9) - DTW가 이를 구할 수 있습니다. 낮추면 매칭 속도가 약간 빨라집니다." + }, + "duration_scale": { + "label": "지속 시간 선명도", + "doc": "Stage 4 지속 시간 일치 패널티의 날카로움입니다. 이것은 일치 점수가 반으로 줄어드는 로그 비율입니다. 작을수록 엄격합니다. 지속 시간 불일치가 더 큰 영향을 미칩니다. 기본값 0.175는 반가중치에서 약 18%의 지속 시간 허용 오차에 해당합니다. 지속 시간 가중치와 함께 사용하세요." + }, + "energy_scale": { + "label": "에너지 선명도", + "doc": "Stage 4 에너지 일치 패널티의 날카로움입니다. 작을수록 엄격합니다. 에너지 불일치가 더 큰 영향을 미칩니다. 기본값 0.25는 에너지가 부하에 따라 변하기 때문에 지속 시간 날카로움보다 의도적으로 더 허용적입니다. 에너지 가중치와 함께 사용하세요." } }, "setting_group": { @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "최적화: {param}" + }, + "pg_detail": { + "simulate": "사이클 시뮬레이션" + }, + "split": { + "apply": "사이클 분할 중" + }, + "trim": { + "apply": "사이클 트리밍 중" + }, + "merge": { + "apply": "사이클 병합 중" + }, + "rebuild": { + "envelopes": "엔벨로프 재구성 중" + }, + "cancelling": "취소 중..." + }, + "setup": { + "hdr": { + "card": "기기 설정", + "healthy_chip": "설정 완료" + }, + "phase0": { + "washer": "WashData가 이미 사이클을 감지하고 있습니다. 프로그램 이름과 시간 추정을 활성화하려면 첫 번째 사이클을 기록하세요.", + "dishwasher": "WashData가 관찰 중입니다. 식기세척기 사이클은 복잡합니다 – 첫 번째 사이클을 기록하는 것이 강력히 권장됩니다. 감지된 사이클이 너무 길게 실행되면 프로필로 저장하기 전에 사이클 편집기를 사용하여 다듬으세요.", + "generic": "WashData가 관찰 중입니다. 프로필 구축을 시작하려면 감지된 사이클을 기록하거나 라벨을 지정하세요." + }, + "phase1a": { + "labelled": "좋은 시작입니다 – 첫 번째 프로그램이 저장되었습니다. 가장 정확한 데이터를 위해 녹음기 위젯으로 다음 사이클을 기록하는 것을 고려해 보세요." + }, + "phase1b": { + "recorded": "녹음이 {profile_name}으로 저장되었습니다. 이제 다른 자주 사용하는 프로그램을 기록하거나 라벨을 지정하여 커버리지를 구축하세요." + }, + "phase1c": { + "verify": "커뮤니티에서 {count}개의 프로그램이 있습니다. WashData가 올바르게 인식하는지 확인하기 위해 사이클을 실행하세요 – 기기가 자체 기록을 쌓으면서 매칭이 개선됩니다." + }, + "phase2": { + "cluster": "WashData가 저장된 프로그램과 일치하지 않는 {count}개의 사이클을 발견했습니다 – 서로 비슷해 보입니다. 이를 위한 새 프로필을 만들고 싶으신가요?", + "unmatched": "마지막 사이클이 저장된 프로그램과 일치하지 않았습니다. 새 프로그램인가요?" + }, + "phase3": { + "suggestions": "WashData가 사이클 기록을 기반으로 설정 권장 사항을 제안합니다 – 감지 정확도를 높이기 위해 검토하세요.", + "groups": "일부 프로필이 다른 온도의 동일한 프로그램처럼 보입니다. 더 나은 매칭을 위해 그룹으로 구성하세요.", + "phases": "더 정확한 잔여 시간 추정을 위해 {profile_name}에 프로그램 단계를 추가하세요." + }, + "phase4": { + "healthy": "이 기기는 완전히 설정되었습니다 ({profile_count}개 프로필)." + }, + "cta": { + "start_recording": "녹음 시작", + "label_detected_cycle": "이미 감지된 사이클이 있습니다 – 대신 라벨 지정", + "browse_cycles": "다른 사이클에 라벨을 지정하려면 찾아보기", + "view_profiles": "프로필 보기", + "create_from_cluster": "이 사이클들로 프로필 만들기", + "create_profile": "이를 위한 프로필 만들기", + "review_suggestions": "제안 검토", + "organise_profiles": "프로필 구성", + "configure_phases": "단계 구성", + "skip_step": "이 단계 건너뛰기", + "skip_forever": "다시 표시 안 함", + "hide_guidance": "안내 숨기기", + "show_guidance": "안내 표시" } } } diff --git a/custom_components/ha_washdata/translations/panel/lt.json b/custom_components/ha_washdata/translations/panel/lt.json index 27e50b9..29a2243 100644 --- a/custom_components/ha_washdata/translations/panel/lt.json +++ b/custom_components/ha_washdata/translations/panel/lt.json @@ -87,7 +87,7 @@ "on_cycle_finished": "Ciklas baigtas", "on_cycle_started": "Prasidėjus ciklui", "pause_cycle": "Pauzė", - "pause_cycle_tip": "Pristabdyti veikiantį ciklą — prietaisas tęsis nuo tos vietos, kur sustojo", + "pause_cycle_tip": "Pristabdyti veikiantį ciklą – prietaisas tęsis nuo tos vietos, kur sustojo", "pg_apply_device": "Taikyti įrenginiui", "pg_autofill": "Automatinis užpildymas", "play": "Leisti", @@ -97,8 +97,8 @@ "process_tip": "Išsaugoti įrašytą seką kaip naują arba esamą profilį", "rebuild": "Atstatyti vokus", "rebuild_envelope": "Atstatyti voką", - "rebuild_tip": "Perskaičiuoti laukiamą galios voką (min/maks juosta) visiems profiliams iš jų pažymėtų ciklų — paleiskite pažymėję naujus ciklus arba pataisę senus", - "rec_start_tip": "Pradėti įrašyti prietaiso galios seką — pradėkite prieš pat paleidžiant ciklą", + "rebuild_tip": "Perskaičiuoti laukiamą galios voką (min/maks juosta) visiems profiliams iš jų pažymėtų ciklų – paleiskite pažymėję naujus ciklus arba pataisę senus", + "rec_start_tip": "Pradėti įrašyti prietaiso galios seką – pradėkite prieš pat paleidžiant ciklą", "rec_stop": "Stabdyti", "rec_stop_tip": "Sustabdyti įrašymą ir laikyti užfiksuotą seką peržiūrai", "record": "Pradėti įrašymą", @@ -182,7 +182,7 @@ }, "attn_sub": "Prieš išsaugant ištaisykite konfliktus", "attn_title": "{n} nustatymų konfliktas{s}", - "settings_banner": "{n} nustatymų konfliktas{s} — patikrinkite pažymėtas sekcijas ir ištaisykite prieš išsaugant.", + "settings_banner": "{n} nustatymų konfliktas{s} – patikrinkite pažymėtas sekcijas ir ištaisykite prieš išsaugant.", "settings_banner_btn": "Eiti į pirmą", "confidence": { "auto": "Turi būti lygus arba aukščiau atitikimo slenksčio ({match})", @@ -451,9 +451,9 @@ "show_expected": "Rodyti numatomą kreivės perdangą (atitinkantį profilį, oranžinė)", "show_raw": "Rodyti neapdoroto lizdo perjungiklį tiesioginiame galios grafike", "sparkline": "Naujausia ciklų trukmės tendencija", - "stage2": "2 etapas — pagrindinis panašumas", - "stage3": "3 etapas — DTW", - "stage4": "4 etapas — atitikimas", + "stage2": "2 etapas – pagrindinis panašumas", + "stage3": "3 etapas – DTW", + "stage4": "4 etapas – atitikimas", "status": "Būsena", "tail_trim": "Uodegos apdaila (-ai)", "timer_auto_pause": "Automatinis pristabdymas", @@ -561,7 +561,12 @@ "flags": "Žymės", "include_phase_map": "Įtraukti fazių žemėlapį", "include_settings": "Įtraukti aptikimo ir atitikties nustatymus", - "show_contributor": "Rodyti dalyvių vardus" + "show_contributor": "Rodyti dalyvių vardus", + "task_pg_detail": "Simuliuojamas ciklas", + "task_split": "Padalijamas ciklas", + "task_trim": "Apkarpomas ciklas", + "task_merge": "Sujungiami ciklai", + "task_rebuild": "Perkuriamos gaubtinės" }, "log": { "all_levels": "Visi lygiai", @@ -657,7 +662,7 @@ "compare_selected_cycles": "Pasirinkti ciklai (vientisas) – rodyti / slėpti", "consider_new_profile": "Apsvarstykite galimybę sukurti naują profilį.", "coverage_gap": "paskutiniai ciklai neturi atitinkančio profilio ({pct} % iš paskutinių 30).", - "coverage_gap_similar_cycles": "Rasti {count} panašūs nepažymėti ciklai — sukurkite profilį, kad pradėtumėte juos atpažinti.", + "coverage_gap_similar_cycles": "Rasti {count} panašūs nepažymėti ciklai – sukurkite profilį, kad pradėtumėte juos atpažinti.", "cycles_deleted": "Ištrinta ciklų: {count}", "enough_data": "Pakankamai duomenų mokymui ({current}/{min} ciklų).", "export_description": "Eksportuokite visus profilius ir ciklus į JSON arba atkurkite iš ankstesnio eksportavimo.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modeliai, pritaikyti šiai mašinai.", "ml_loading": "įkeliamas ML…", "ml_settings_intro": "Du nepriklausomi jungikliai: vienas pritaiko modelius, kol veikia ciklas, kitas leidžia WashData laikui bėgant tiksliai suderinti juos su jūsų aparatu.", - "name_first_program": "Turite pakankamai ciklų — pavadinkite pirmąją programą, kad prasidėtų suderinimas.", + "name_first_program": "Turite pakankamai ciklų – pavadinkite pirmąją programą, kad prasidėtų suderinimas.", "near_duplicate_cluster": "Aptikta beveik pasikartojanti profilių grupė. Grupavimas leidžia suderinti patikimai pasirinkti panašius dalykus (pvz., ta pati programa esant skirtingai temperatūrai / sukimuisi).", "no_cycles_match": "Nė vienas ciklas neatitinka esamo filtro.", "no_cycles_profile": "Šiam profiliui nėra ciklų.", @@ -710,10 +715,10 @@ "no_settings_match": "Jokie nustatymai neatitinka „{q}“", "no_split_points": "Dar nėra padalintų taškų.", "no_suggestions": "Nėra aktyvių pasiūlymų.", - "notify_services_hint": "Naudokite {entity} paslaugų ID (keliems — atskirkite kableliais). Šablono kintamieji: {vars}.", + "notify_services_hint": "Naudokite {entity} paslaugų ID (keliems – atskirkite kableliais). Šablono kintamieji: {vars}.", "old_actions_warning": "Sukonfigūruota naudojant seną veiksmų rengyklę (dabar pašalinta). Jie vis dar suaktyvinami ciklo įvykiuose, bet čia jų redaguoti nebegalima. Konvertuokite juos į įprastą automatiką arba pašalinkite.", "onboarding_progress": "Stebėta ciklų: {n} / 3", - "onboarding_watching": "Naudokite prietaisą įprastai — „WashData“ stebi. Po 3 ciklų prasidės programų suderinimas.", + "onboarding_watching": "Naudokite prietaisą įprastai – „WashData“ stebi. Po 3 ciklų prasidės programų suderinimas.", "pending_feedback": "Laukiama aptikimo atsiliepimo", "performance_trend": "Našumo tendencija ({n} ciklai)", "pg_analysis_empty": "Įkelkite ciklą, kad pamatytumėte atitikties analizę.", @@ -763,7 +768,7 @@ "setting_changed": "Pakeista iš {old} į {new} {date}", "settings_basic_note": "Rodomi esminiai nustatymai. Norėdami pamatyti visą sąrašą, perjunkite į „Išplėstinė“.", "shape_drift_advisory": "⚠ Formos dreifas", - "shape_drift_detail": "Šio profilio galios šablonas laikui bėgant pasikeitė — galimas prietaiso susidėvėjimas arba reikalinga priežiūra (pvz., nukalkinimas, filtro valymas).", + "shape_drift_detail": "Šio profilio galios šablonas laikui bėgant pasikeitė – galimas prietaiso susidėvėjimas arba reikalinga priežiūra (pvz., nukalkinimas, filtro valymas).", "show_all_settings": "Rodyti visus nustatymus", "showing_suggestions": "Rodomas {count} nustatymas su pasiūlymais.", "split_intro": "Spustelėkite diagramą, kad pridėtumėte arba pašalintumėte padalijimo tašką, arba automatiškai aptiktumėte pagal tuščiosios eigos tarpus. Kiekvienas gautas segmentas gali gauti savo profilį.", @@ -857,7 +862,9 @@ "share_guidelines_title": "Prieš dalijantis", "store_download_device_intro": "Perimkite kiekvieną bendrą programą ir jos etalonius ciklus į savo įrenginį. Jūsų pačių įrašyti ciklai ir statistika neįtakojami.", "store_share_device_intro": "Įkelkite {brand} {model} su pasirinktais etaloniniais ciklais. Kiti, turintys tą patį prietaisą, gali perimti jūsų programas. Įrašai peržiūrimi prieš viešinant.", - "share_profile_no_cycles": "Nėra etaloninių ciklų -- pažymėkite ciklą kaip ⭐ Ciklų skirtuke, kad įtrauktumėte šį profilį" + "share_profile_no_cycles": "Nėra etaloninių ciklų -- pažymėkite ciklą kaip ⭐ Ciklų skirtuke, kad įtrauktumėte šį profilį", + "advisory_phase_inconsistent": "Atrodo, kad '{name}' maišo skirtingas programas ar temperatūras - jo ciklai kaista labai skirtingą laiką. Padalijus jį į atskirus profilius (pvz., pagal temperatūrą), pagerės atitikimas ir laiko įverčiai.", + "advisory_phase_inconsistent_title": "⚠ Galimai sumaišytos programos" }, "phase_desc": { "anti_crease": "Po pagrindinio ciklo būgnas intermitiškai sukasi, kad išvengtų audinių raukšlių.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Pasirenkami išoriniai signalai: pabaigos trigeris, durų jutiklis, pauzės jungiklis ir iškrovimo priminimas.", "label": "Trigeriai ir durys" + }, + "phase_eta": { + "label": "Likęs laikas", + "intro": "Į fazes atsižvelgiantis likęs laikas mašinoms, kurių ciklo trukmė priklauso nuo temperatūros ar gręžimo." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Fiksuota kaina už kWh, naudojama sąnaudų skaičiams, kai aukščiau nenustatytas joks tiesioginės kainos subjektas.", "label": "Statinė energijos kaina (už kWh)" }, + "energy_sensor": { + "doc": "Neprivalomas kaupiamasis energijos skaitiklis (total_increasing kWh/Wh, pvz., paties kištuko viso laikotarpio skaitiklis). Kai nustatytas, kiekvieno ciklo pateikiama energija imama iš šio skaitiklio rodmens pokyčio nuo pradžios iki pabaigos, taip išvengiama per mažo skaičiavimo, kurį gaunate integruodami lėtai duomenis pateikiantį galios jutiklį. Grįžta prie integruotos reikšmės, jei rodmens nėra, jo vienetas nežinomas arba skirtumas nėra teigiamas. Palikite tuščią, kad būtų ir toliau integruojamas galios jutiklis.", + "label": "Energijos skaitiklio objektas" + }, "expose_debug_entities": { "doc": "Paskelbkite papildomus diagnostinius HA objektus (atitikties patikimumą, dviprasmiškumą, būsenos vidines savybes). Išjungta objektų sąrašas išlieka švarus normaliam naudojimui.", "label": "Rodyti derinimo objektus" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Rodyti \"autorius: \" priskyrimą bendruomenės prietaisuose ir etaloniniuose cikluose." + }, + "enable_phase_matching": { + "label": "Į fazes atsižvelgiantis likęs laikas", + "doc": "Padalykite kiekvieną vykstantį ciklą į fazes (šildymas, plovimas, gręžimas) ir paskirstykite likusį laiką kiekvienai fazei, sumaišydami su klasikiniu įverčiu - ciklo pradžioje remdamiesi fazių paskirstymu, o pabaigoje - klasikiniu įverčiu. Tai pritaiko laikmatį prie to, kiek laiko jūsų mašina iš tikrųjų kaista ir veikia, o tai labiausiai pastebima pirmoje ciklo pusėje. Išjungta = tik klasikinis įvertis. Paveikiamas tik likusio laiko rodinys; programų atitikimas ir ciklų aptikimas nekeičiami." + }, + "keep_min_score": { + "label": "Min. atitikties balas", + "doc": "Žemiausias panašumo balas, kurio kandidatui reikia likti derinimo procese. Numatytoji 0,1 yra sąmoningai leistina – ji leidžia net silpnus kandidatus ir pasitiki vėlesniais etapais rasti geriausią atitiktį. Padidinkite, kad anksčiau filtruotumėte mažai tikėtinus profilius; sumažinkite pradiniam kandidatų telkiniui išplėsti." + }, + "dtw_blend": { + "label": "DTW mišinys", + "doc": "Kiek DTW lygiavimo balas pakeičia 2 etapo pagrindinį balą. 0 = naudokite tik 2 etapo balą, 1 = naudokite tik DTW balą, 0,5 (numatytoji) = lygus mišinys. Padidinkite, kad labiau pasikliauti laiko lygiavimo, kai programos turi panašius galios lygius, bet skirtingus laiko modelius." + }, + "dtw_ensemble_w": { + "label": "DTW ansamblio mišinys", + "doc": "Ansamblio DTW režime (numatytoji) tai yra skalieroto L1 svoris lyginant su išvestiniu DTW (DDTW). 1,0 = tik skalierota L1, 0 = tik DDTW, 0,7 = numatytoji. Skalierota variacija lygina galios lygius; išvestinė variacija reaguoja į tai, kaip galia keičiasi bėgant laikui. Ansamblis derina abi." + }, + "dtw_ddtw_scale": { + "label": "Išvest. DTW skalė", + "doc": "Pusiau prisotinimo atstumas išvestiniam DTW vertinimui. Balas perpus sumažėja, kai DDTW atstumas lygus šiai reikšmei. Mažesnis = jautresnis formos skirtumams. Numatytoji 30 sukalibruota tipiškai buitinių prietaisų galios kreivėms. Veikia tik ansamblio ir ddtw DTW režimus." + }, + "dtw_refine_top_n": { + "label": "DTW tikslinimo skaičius", + "doc": "Kiek geriausių 2 etapo kandidatų DTW perskaičiuoja balus. DTW yra brangesnė operacija, todėl taikoma tik N geriausiems. Numatytoji 5. Padidinkite (iki 7-9), jei teisingas profilis kartais po 2 etapo patenka tik į 4 arba 5 vietą – DTW gali jį išgelbėti. Mažesnė reikšmė šiek tiek pagreitina derinimą." + }, + "duration_scale": { + "label": "Trukmės ryškumas", + "doc": "4 etapo trukmės atitikimo bausmės ryškumas. Tai yra logaritmas, ties kuriuo atitikimo balas perpus sumažėja. Mažesnis = griežtesnis: trukmės neatitikimas kenkia labiau. Numatytoji 0,175 atitinka maždaug 18% trukmės toleranciją ties pusiau svoriu. Naudokite su Trukmės svoriu." + }, + "energy_scale": { + "label": "Energijos ryškumas", + "doc": "4 etapo energijos atitikimo bausmės ryškumas. Mažesnis = griežtesnis: energijos neatitikimas kenkia labiau. Numatytoji 0,25 sąmoningai atlaidesnė nei trukmės ryškumas, nes energija kinta priklausomai nuo apkrovos. Naudokite su Energijos svoriu." } }, "setting_group": { @@ -1553,24 +1600,24 @@ "lower": "Automatiškai pažymi daugiau ciklų; kai kurie gali būti neteisingai identifikuoti" }, "duration_tolerance": { - "higher": "Priima platesnį trukmės diapazoną — lankstesnis atitikimas", - "lower": "Reikalauja tikslesnio trukmės atitikimo — griežtesnė, bet gali atmesti neįprastus ciklus" + "higher": "Priima platesnį trukmės diapazoną – lankstesnis atitikimas", + "lower": "Reikalauja tikslesnio trukmės atitikimo – griežtesnė, bet gali atmesti neįprastus ciklus" }, "end_energy_threshold": { "higher": "Uždaro tik ciklus, kurie sunaudojo daug energijos", "lower": "Uždaro ir trumpesnius arba mažiau energijos naudojančius ciklus" }, "end_repeat_count": { - "higher": "Reikalauja, kad pabaigos signalas kartotųsi daugiau kartų — patikimesnis, šiek tiek lėtesnis", - "lower": "Baigia po mažiau pasikartojimų — greitesnis aptikimas, didesnis klaidingo pabaigos pavojus" + "higher": "Reikalauja, kad pabaigos signalas kartotųsi daugiau kartų – patikimesnis, šiek tiek lėtesnis", + "lower": "Baigia po mažiau pasikartojimų – greitesnis aptikimas, didesnis klaidingo pabaigos pavojus" }, "learning_confidence": { - "higher": "Mokosi tik iš labai tikrų atitikimų — lėčiau, bet patikimiau", + "higher": "Mokosi tik iš labai tikrų atitikimų – lėčiau, bet patikimiau", "lower": "Mokosi iš daugiau ciklų; modelis gali būti triukšmingesnis" }, "min_off_gap": { "higher": "Prieš naują ciklą reikia ilgesnio neveiklumo laikotarpio", - "lower": "Trumpas neveiklumas paleidžia naują ciklą — vienas po kito einantys paleidimai gali išsiskirti" + "lower": "Trumpas neveiklumas paleidžia naują ciklą – vienas po kito einantys paleidimai gali išsiskirti" }, "min_power": { "higher": "Mažiau jautrus budėjimo galiai; gali praleisti labai tylias programas", @@ -1581,24 +1628,24 @@ "lower": "Greičiau priverstinai sustabdo įstrigusį ciklą" }, "off_delay": { - "higher": "Daugiau laiko prietaiso pauzėms — valdo ilgas mirkymo arba džiovinimo fazes", - "lower": "Greitesnis pabaigos aptikimas — tinka aiškiems įjungimo/išjungimo prietaisams" + "higher": "Daugiau laiko prietaiso pauzėms – valdo ilgas mirkymo arba džiovinimo fazes", + "lower": "Greitesnis pabaigos aptikimas – tinka aiškiems įjungimo/išjungimo prietaisams" }, "profile_match_threshold": { "higher": "Patvirtina tik aukšto pasitikėjimo atitikimus; daugiau ciklų lieka nepažymėtų", "lower": "Atitinka daugiau ciklų; kai kurie gali būti šiek tiek neteisingi" }, "running_dead_zone": { - "higher": "Ignoruoja trumpus galios šuolius neveiklumo metu — mažiau jautrus triukšmui", - "lower": "Reaguoja į trumpesnius galios pliūpsnius — aptinka greitą trumpalaikę veiklą" + "higher": "Ignoruoja trumpus galios šuolius neveiklumo metu – mažiau jautrus triukšmui", + "lower": "Reaguoja į trumpesnius galios pliūpsnius – aptinka greitą trumpalaikę veiklą" }, "start_threshold_w": { "higher": "Išvengia netikrų paleidimų dėl trumpų galios šuolių", "lower": "Aptinka mažos galios programas; gali suaktyvėti nuo triukšmo" }, "stop_threshold_w": { - "higher": "Greičiau baigia ciklą — gali uždaryti pauzės metu", - "lower": "Laukia ilgiau prieš baigiant — mažiau ankstyvų pabaigimų" + "higher": "Greičiau baigia ciklą – gali uždaryti pauzės metu", + "lower": "Laukia ilgiau prieš baigiant – mažiau ankstyvų pabaigimų" }, "watchdog_interval": { "higher": "Leidžiamos ilgesnės tylios fazės; mažiau klaidingų priverstinių sustabdymų", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Sekundės virš slenksčio startui patvirtinti", "start_threshold_w": "Mažiausia galia (W), kad būtų skaičiuojama kaip prasidėjęs", "stop_threshold_w": "Žemiau šios ribos aparatas laikomas išjungtu", - "dtw_bandwidth": "Kiek laiko iškraipymo leidžia formos atitiktis", - "corr_weight": "Kreivės formos ir galios lygio svoris atitikime", - "duration_weight": "Kiek trukmės sutapimas veikia atitiktį", - "energy_weight": "Kiek energijos sutapimas veikia atitiktį", - "profile_match_min_duration_ratio": "Trumpiausias ciklas (palyginti su profiliu), kuriam dar leidžiama atitikti", - "profile_match_max_duration_ratio": "Ilgiausias ciklas (palyginti su profiliu), kuriam dar leidžiama atitikti" + "dtw_bandwidth": "3 etapas: Sakoe-Chiba laiko lankstymo juosta (0 = DTW išjungtas; numatytoji 0,2 = 20% ciklo ilgio)", + "corr_weight": "2 etapas: balansas tarp kreivės formos (koreliacija) ir galios lygio (MAE); numatytoji 0,45", + "duration_weight": "4 etapas: kaip stipriai trukmės atitikimas veikia galutinį balą (numatytoji 0,22)", + "energy_weight": "4 etapas: kaip stipriai energijos atitikimas veikia galutinį balą (numatytoji 0,22)", + "profile_match_min_duration_ratio": "1 etapas: trumpiausias ciklas kaip profilio trukmės dalis; numatytoji 0,1 (10%)", + "profile_match_max_duration_ratio": "1 etapas: ilgiausias ciklas kaip profilio trukmės dalis; numatytoji 1,5 (150%)", + "keep_min_score": "2 etapas: žemiausias balas likti lenktynėse; numatytoji 0,1 leidžia silpnus kandidatus, vėlesni etapai parenka geriausią", + "dtw_blend": "3 etapas: 0 = tik pagrindinis balas, 1 = tik DTW balas, 0,5 = lygus mišinys (numatytoji)", + "dtw_ensemble_w": "3 etapas (ansamblio režimas): skalieroto L1 svoris prieš išvestinės DTW; numatytoji 0,7 teikia pirmenybę lygio sąmoningumui", + "dtw_ddtw_scale": "3 etapas: DDTW pusiau prisotinimo atstumas; mažesnis = jautresnis formos skirtumams (numatytoji 30)", + "dtw_refine_top_n": "3 etapas: kandidatai, kuriems DTW perskaičiuoja balus; padidinkite iki 7-9 jei teisingas profilis patenka į 4-5 vietą (numatytoji 5)", + "duration_scale": "4 etapas: logaritmas, ties kuriuo trukmės atitikimas perpus sumažėja; mažesnis = griežtesnė bausmė (numatytoji 0,175)", + "energy_scale": "4 etapas: logaritmas, ties kuriuo energijos atitikimas perpus sumažėja; mažesnis = griežtesnė bausmė (numatytoji 0,25)" }, "col": { "profile_tip": "Atitikusios programos pavadinimas. Nepažymėta reiškia, kad ciklo pabaigoje neatitiko joks profilis.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimizavimas: {param}" + }, + "pg_detail": { + "simulate": "Simuliuojamas ciklas" + }, + "split": { + "apply": "Padalijamas ciklas" + }, + "trim": { + "apply": "Apkarpomas ciklas" + }, + "merge": { + "apply": "Sujungiami ciklai" + }, + "rebuild": { + "envelopes": "Perkuriamos gaubtinės" + }, + "cancelling": "Atšaukiama..." + }, + "setup": { + "hdr": { + "card": "Įrenginio sąranka", + "healthy_chip": "Sąranka baigta" + }, + "phase0": { + "washer": "WashData jau aptinka jūsų ciklus. Įrašykite pirmąjį ciklą, kad galėtumėte naudoti programų pavadinimus ir laiko įverčius.", + "dishwasher": "WashData stebi. Indaplovių ciklai yra sudėtingi – labai rekomenduojame įrašyti pirmąjį ciklą. Jei aptiktas ciklas trunka per ilgai, prieš išsaugant kaip profilį, apkarpykite jį ciklo redaktoriuje.", + "generic": "WashData stebi. Įrašykite arba pažymėkite aptiktą ciklą, kad pradėtumėte kurti profilius." + }, + "phase1a": { + "labelled": "Gera pradžia – jūsų pirmoji programa išsaugota. Kad duomenys būtų kuo švaresni, apsvarstykite galimybę įrašyti kitą ciklą naudodami įrašymo valdiklį." + }, + "phase1b": { + "recorded": "Jūsų įrašas išsaugotas kaip {profile_name}. Dabar įrašykite arba pažymėkite kitas dažniausiai naudojamas programas, kad padidintumėte aprėptį." + }, + "phase1c": { + "verify": "Turite {count} bendruomenės programas. Paleiskite ciklą, kad patikrintumėte, ar WashData jas teisingai atpažįsta – atitikimas gerės, kai jūsų įrenginys sukaupia savo istoriją." + }, + "phase2": { + "cluster": "WashData aptiko {count} ciklus, kurie neatitinka jokios išsaugotos programos – jie yra panašūs vienas į kitą. Ar norite sukurti jiems naują profilį?", + "unmatched": "Paskutinis ciklas neatitiko jokios išsaugotos programos. Ar tai nauja programa?" + }, + "phase3": { + "suggestions": "WashData pateikia nustatymų rekomendacijas pagal jūsų ciklų istoriją – peržiūrėkite jas, kad pagerintumėte aptikimo tikslumą.", + "groups": "Kai kurie jūsų profiliai atrodo kaip ta pati programa skirtingose temperatūrose. Sugrupuokite juos, kad atitikimas būtų tikslesnis.", + "phases": "Pridėkite programų fazes prie {profile_name}, kad likusio laiko įverčiai būtų tikslesni." + }, + "phase4": { + "healthy": "Šis įrenginys yra visiškai sukonfigūruotas ({profile_count} profiliai)." + }, + "cta": { + "start_recording": "Pradėti įrašymą", + "label_detected_cycle": "Jau turiu aptiktą ciklą – vietoj to pažymėti jį", + "browse_cycles": "Naršyti ciklus, kad pažymėtumėte kitą", + "view_profiles": "Peržiūrėti profilius", + "create_from_cluster": "Sukurti profilį iš šių ciklų", + "create_profile": "Sukurti jam profilį", + "review_suggestions": "Peržiūrėti pasiūlymus", + "organise_profiles": "Tvarkyti profilius", + "configure_phases": "Konfigūruoti fazes", + "skip_step": "Praleisti šį žingsnį", + "skip_forever": "Daugiau nerodyti", + "hide_guidance": "Slėpti nurodymus", + "show_guidance": "Rodyti nurodymus" } } } diff --git a/custom_components/ha_washdata/translations/panel/lv.json b/custom_components/ha_washdata/translations/panel/lv.json index 10abbf8..fe2496d 100644 --- a/custom_components/ha_washdata/translations/panel/lv.json +++ b/custom_components/ha_washdata/translations/panel/lv.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "Konstatētas {n} anomālijas (piemēram, durvis atvērtas cikla vidū) — atveriet, lai tās skatītu diagrammā", + "artifact_tip": "Konstatētas {n} anomālijas (piemēram, durvis atvērtas cikla vidū) – atveriet, lai tās skatītu diagrammā", "auto": "(noteikts automātiski)", "built_in_tag": "iebūvēts", "declining": "↘ samazinās", "energy_low": "Zemāka enerģija nekā parasti", "energy_spike": "Augstāka enerģija nekā parasti", "fair_fit": "godīga atbilstība", - "fair_fit_tip": "Mērena atbilstības konsekvence — dažiem šim profilam piešķirtajiem cikliem ir zemāki ticamības rādītāji. Iezīmējiet vairāk ciklu vai atkārtoti ierakstiet profilu, lai uzlabotu precizitāti.", + "fair_fit_tip": "Mērena atbilstības konsekvence – dažiem šim profilam piešķirtajiem cikliem ir zemāki ticamības rādītāji. Iezīmējiet vairāk ciklu vai atkārtoti ierakstiet profilu, lai uzlabotu precizitāti.", "feedback_requested": "Pieprasītas atsauksmes", "golden_cycle": "Reģistrēts atskaites cikls", "improving": "↗ uzlabošanās", @@ -15,7 +15,7 @@ "needs_review": "Nepieciešama pārskatīšana", "overrun": "Skrēja ilgāk nekā parasti", "poor_fit": "slikta atbilstība", - "poor_fit_tip": "Nekonsekventa spēļu vēsture — apsveriet iespēju atjaunot šo profilu", + "poor_fit_tip": "Nekonsekventa spēļu vēsture – apsveriet iespēju atjaunot šo profilu", "restart_gap_tip": "{n} HA restartēšanas atstarpe: jaudas trasē ir caurums", "reviewed": "Pārskatīts", "steady": "→ stabils", @@ -57,7 +57,7 @@ "create_profile": "+ Izveidojiet profilu", "delete": "Dzēst", "delete_group": "Dzēst grupu", - "delete_group_tip": "Dzēst tikai šo grupu — dalībnieku profili tiek saglabāti", + "delete_group_tip": "Dzēst tikai šo grupu – dalībnieku profili tiek saglabāti", "delete_profile": "Dzēst profilu", "discard": "Izmest", "discard_tip": "Izmest ierakstīto trasē bez saglabāšanas", @@ -87,7 +87,7 @@ "on_cycle_finished": "Cikls ir pabeigts", "on_cycle_started": "Cikls sākās", "pause_cycle": "Pauze", - "pause_cycle_tip": "Pauzēt pašreizējo ciklu — ierīce turpinās no pārtraukuma vietas", + "pause_cycle_tip": "Pauzēt pašreizējo ciklu – ierīce turpinās no pārtraukuma vietas", "pg_apply_device": "Lietot ierīcei", "pg_autofill": "Automātiskā aizpilde", "play": "Atskaņot", @@ -97,8 +97,8 @@ "process_tip": "Saglabāt ierakstīto trasē kā jaunu vai esošu profilu", "rebuild": "Pārbūvēt aploksnes", "rebuild_envelope": "Pārbūvēt aploksni", - "rebuild_tip": "Pārrēķināt paredzamo jaudas aploksni (min/maks josla) visiem profiliem no to iezīmētajiem cikliem — palaidiet pēc jaunu ciklu iezīmēšanas vai veco labošanas", - "rec_start_tip": "Sākt ierīces jaudas trasē ierakstīšanu — sāciet tieši pirms cikla palaišanas", + "rebuild_tip": "Pārrēķināt paredzamo jaudas aploksni (min/maks josla) visiem profiliem no to iezīmētajiem cikliem – palaidiet pēc jaunu ciklu iezīmēšanas vai veco labošanas", + "rec_start_tip": "Sākt ierīces jaudas trasē ierakstīšanu – sāciet tieši pirms cikla palaišanas", "rec_stop": "Apturēt", "rec_stop_tip": "Pārtraukt ierakstīšanu un saglabāt uzņemto trasē pārskatīšanai", "record": "Sāciet ierakstīšanu", @@ -182,7 +182,7 @@ }, "attn_sub": "Pirms saglabāšanas novērsiet konfliktus", "attn_title": "{n} iestatījumu konflikts{s}", - "settings_banner": "{n} iestatījumu konflikts{s} — pārbaudiet iezīmētās sadaļas un labojiet pirms saglabāšanas.", + "settings_banner": "{n} iestatījumu konflikts{s} – pārbaudiet iezīmētās sadaļas un labojiet pirms saglabāšanas.", "settings_banner_btn": "Doties uz pirmo", "confidence": { "auto": "Jābūt vienādam vai virs atbilstības sliekšņa ({match})", @@ -451,9 +451,9 @@ "show_expected": "Rādīt paredzamo līknes pārklājumu (atbilstošs profils, oranžs)", "show_raw": "Rādīt neapstrādātas kontaktligzdas pārslēgu tiešraides jaudas grafikā", "sparkline": "Neseno ciklu ilguma tendence", - "stage2": "2. posms — galvenā līdzība", - "stage3": "3. posms — DTW", - "stage4": "4. posms — atbilstība", + "stage2": "2. posms – galvenā līdzība", + "stage3": "3. posms – DTW", + "stage4": "4. posms – atbilstība", "status": "Statuss", "tail_trim": "Astes apdare (-es)", "timer_auto_pause": "Automātiska pauze", @@ -561,7 +561,12 @@ "flags": "Karodziņi", "include_phase_map": "Iekļaut fāžu karti", "include_settings": "Iekļaut noteikšanas un saskaņošanas iestatījumus", - "show_contributor": "Rādīt līdzautoru vārdus" + "show_contributor": "Rādīt līdzautoru vārdus", + "task_pg_detail": "Cikla simulēšana", + "task_split": "Cikla sadalīšana", + "task_trim": "Cikla apgriešana", + "task_merge": "Ciklu apvienošana", + "task_rebuild": "Apvalklīkņu pārbūve" }, "log": { "all_levels": "Visi līmeņi", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Krita zem parastā jaudas joslas ~{n}s.", "artifact_footer": "Izcelts augstāk esošajā grafikā. Tie ir pārejoši artefakti (piemēram, durvis atvērtas cikla vidū), ne vienmēr problēmas.", "artifact_header": "Šajā ciklā konstatētas {n} anomālijas", - "artifact_pause_detail": "Jauda krita gandrīz līdz nullei ~{n}s, pēc tam atjaunojās — visticamāk, durvis tika atvērtas cikla vidū vai cikls tika pauzēts.", + "artifact_pause_detail": "Jauda krita gandrīz līdz nullei ~{n}s, pēc tam atjaunojās – visticamāk, durvis tika atvērtas cikla vidū vai cikls tika pauzēts.", "artifact_spike_detail": "Pārsniedza parasto jaudas joslu ~{n}s.", "auto_label_intro": "Piešķiriet profilus nemarķētiem cikliem, kuru atbilstības pārliecība notīra slieksni.", "automations_intro": "WashData aktivizē {start} / {end} notikumus un atklāj entītijas, tāpēc paziņojumus un darbības vislabāk veidot kā parastas Home Assistant automatizācijas. Zemāk redzamas automatizācijas, kas izmanto šo ierīci.", @@ -654,10 +659,10 @@ "collecting_data": "Datu vākšana - nepieciešami vēl {need} cikli, lai varētu sākt precizēšanu ({current}/{min}).", "compare_overlay_profiles": "Pārklājuma profili (blāvi)", "compare_profiles_tip": "Iepriekš redzamajā diagrammā pārklājiet citas profila aploksnes, lai redzētu, kura no tām vislabāk atbilst šim ciklam.", - "compare_selected_cycles": "Atlasītie cikli (pastāvīgi) — parādīt/slēpt", + "compare_selected_cycles": "Atlasītie cikli (pastāvīgi) – parādīt/slēpt", "consider_new_profile": "Apsveriet iespēju izveidot jaunu profilu.", "coverage_gap": "pēdējiem cikliem nav atbilstoša profila ({pct}% no pēdējiem 30).", - "coverage_gap_similar_cycles": "Atrasti {count} līdzīgi nemarķēti cikli — izveidojiet profilu, lai sāktu tos saskaņot.", + "coverage_gap_similar_cycles": "Atrasti {count} līdzīgi nemarķēti cikli – izveidojiet profilu, lai sāktu tos saskaņot.", "cycles_deleted": "Dzēsti cikli: {count}", "enough_data": "Pietiek datu mācīšanai ({current}/{min} cikli).", "export_description": "Eksportējiet visus profilus un ciklus uz JSON vai atjaunojiet no iepriekšējās eksportēšanas.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modeļi, kas precīzi pielāgoti šai iekārtai.", "ml_loading": "tiek ielādēts ML…", "ml_settings_intro": "Divi neatkarīgi slēdži: viens piemēro modeļus, kamēr darbojas cikls, otrs ļauj WashData laika gaitā precīzi noregulēt tos jūsu mašīnai.", - "name_first_program": "Jums ir pietiekami daudz ciklu — nosauciet savu pirmo programmu, lai sāktu saskaņošanu.", + "name_first_program": "Jums ir pietiekami daudz ciklu – nosauciet savu pirmo programmu, lai sāktu saskaņošanu.", "near_duplicate_cluster": "Konstatēts gandrīz dublikāts profilu kopa. Grupēšana ļauj saskaņošanai droši izvēlēties starp līdzīgiem (piemēram, viena un tā pati programma atšķirīgā temperatūrā/griešanā).", "no_cycles_match": "Neviens cikls neatbilst pašreizējam filtram.", "no_cycles_profile": "Šim profilam nav ciklu.", @@ -694,12 +699,12 @@ "no_cycles_yet": "Vēl nav reģistrēts neviens cikls.", "no_device_selected": "Nav atlasīta neviena ierīce.", "no_devices": "Vēl nav konfigurēta neviena WashData ierīce.", - "no_envelope": "Vēl nav aploksnes — pēc marķēšanas cikliem pārbūvēt.", + "no_envelope": "Vēl nav aploksnes – pēc marķēšanas cikliem pārbūvēt.", "no_envelope_overlay": "Pārklāšanai nav pieejama neviena aploksne.", - "no_fine_tuned": "Vēl nekas nav precīzi noregulēts — WashData izmanto savus iebūvētos modeļus.", + "no_fine_tuned": "Vēl nekas nav precīzi noregulēts – WashData izmanto savus iebūvētos modeļus.", "no_logs": "Vēl nav buferizēts neviens žurnāla ieraksts.", "no_maintenance": "Vēl nav reģistrēta neviena apkope.", - "no_match_yet": "Vēl nav saskaņošanas mēģinājuma — tas tiek aizpildīts darbības cikla laikā.", + "no_match_yet": "Vēl nav saskaņošanas mēģinājuma – tas tiek aizpildīts darbības cikla laikā.", "no_other_users": "Nav atrasts neviens cits mājas palīga lietotājs.", "no_phases": "Nav noteiktas fāzes.", "no_phases_assigned": "Nav piešķirtas fāzes.", @@ -710,10 +715,10 @@ "no_settings_match": "Neviens iestatījums neatbilst \"{q}\"", "no_split_points": "Vēl nav sadalīti punkti.", "no_suggestions": "Nav aktīvu ieteikumu.", - "notify_services_hint": "Izmantojiet {entity} pakalpojumu ID (vairākiem — atdaliet ar komatiem). Veidnes mainīgie: {vars}.", + "notify_services_hint": "Izmantojiet {entity} pakalpojumu ID (vairākiem – atdaliet ar komatiem). Veidnes mainīgie: {vars}.", "old_actions_warning": "Konfigurēts ar veco darbību redaktoru (tagad noņemts). Tie joprojām aktivizē cikla notikumus, taču tos vairs nevar rediģēt šeit. Pārvērtiet tos par parastu automatizāciju vai noņemiet tos.", "onboarding_progress": "Novēroti cikli: {n} / 3", - "onboarding_watching": "Lietojiet ierīci kā parasti — WashData vēro. Pēc 3 cikliem sāksies programmu saskaņošana.", + "onboarding_watching": "Lietojiet ierīci kā parasti – WashData vēro. Pēc 3 cikliem sāksies programmu saskaņošana.", "pending_feedback": "Gaida atsauksmes par noteikšanu", "performance_trend": "Veiktspējas tendence ({n} cikli)", "pg_analysis_empty": "Ielādējiet ciklu, lai redzētu atbilstības analīzi.", @@ -748,13 +753,13 @@ "restart_gap_footer": "Izcelts diagrammā. Jaudas dati trūkst šiem intervāliem; atbilstības noteikšana izmantoja tikai faktiskos rādījumus.", "restart_gap_header": "{n} HA restartēšanas atstarpe šī cikla laikā", "restart_gap_item": "{dur} atstarpe", - "review_confirm_help": "Apstipriniet, vai šis cikls tika noteikts pareizi. Jūsu atsauksmes apmāca modeli jūsu mašīnā — jo vairāk ciklu apstiprināsit, jo labāks atbilstības un veselības novērtējums. Pietiek ar ātru Labu/Sliktu.", + "review_confirm_help": "Apstipriniet, vai šis cikls tika noteikts pareizi. Jūsu atsauksmes apmāca modeli jūsu mašīnā – jo vairāk ciklu apstiprināsit, jo labāks atbilstības un veselības novērtējums. Pietiek ar ātru Labu/Sliktu.", "review_in_settings": "Pārskatiet sadaļā Iestatījumi", "review_notes_placeholder": "Piezīmes (pēc izvēles)", "review_notes_tip": "Brīvā teksta piezīmes jūsu uzziņai. Netiek izmantots saskaņošanā vai apmācībā.", - "review_profile_tip": "Šī cikla programma ir apzīmēta kā. Ja automātiski noteikta programma bija nepareiza, izlabojiet to šeit — marķējums māca noteikt atbilstību nākamajiem cikliem.", + "review_profile_tip": "Šī cikla programma ir apzīmēta kā. Ja automātiski noteikta programma bija nepareiza, izlabojiet to šeit – marķējums māca noteikt atbilstību nākamajiem cikliem.", "review_quality_tip": "Cik tīrs ir šis cikls. Labi = šīs programmas mācību grāmatas piemērs; Slikti = atklāts, bet trokšņains vai netipisks; Nelietojams = nepareizi noteikts (sapludināts, saīsināts vai viltots). Vada veselības rādītāju un to, kuri cikli ir atļauti, lai apmācītu modeli.", - "review_recorded_tip": "Atzīmējiet to kā manuāli izvēlētu atsauces ciklu savai programmai — tāda pati loma kā manuāli ierakstītam ciklam. Atsauces cikli vienmēr tiek saglabāti, ievietojiet atbilstošo veidni, un tīrīšanas laikā tie nekad netiek atmesti. (Šis ir \"zelta\"/ierakstītais karogs; abi ir viens un tas pats.)", + "review_recorded_tip": "Atzīmējiet to kā manuāli izvēlētu atsauces ciklu savai programmai – tāda pati loma kā manuāli ierakstītam ciklam. Atsauces cikli vienmēr tiek saglabāti, ievietojiet atbilstošo veidni, un tīrīšanas laikā tie nekad netiek atmesti. (Šis ir \"zelta\"/ierakstītais karogs; abi ir viens un tas pats.)", "review_tags_tip": "Izvēles karodziņi, kas apraksta, kas šajā ciklā nogāja greizi, tāpēc apmācība un tīrīšana var to izskaidrot.", "review_to_cycles": "Atveriet ciklu pārskatīšanas rindu", "saving_triggers_reload": "Saglabāšana aktivizē integrācijas atkārtotu ielādi. HA entītijas var īslaicīgi parādīties kā nepieejamas.", @@ -763,7 +768,7 @@ "setting_changed": "Mainīts no {old} uz {new} {date}", "settings_basic_note": "Tiek rādīti būtiskākie iestatījumi. Pārslēdzieties uz Papildu, lai skatītu pilnu sarakstu.", "shape_drift_advisory": "⚠ Formas novirze", - "shape_drift_detail": "Šī profila jaudas raksts laika gaitā ir mainījies — iespējama ierīces nodilšana vai nepieciešama apkope (piemēram, atkaļķošana, filtru tīrīšana).", + "shape_drift_detail": "Šī profila jaudas raksts laika gaitā ir mainījies – iespējama ierīces nodilšana vai nepieciešama apkope (piemēram, atkaļķošana, filtru tīrīšana).", "show_all_settings": "Rādīt visus iestatījumus", "showing_suggestions": "Tiek rādīts {count} iestatījums ar ieteikumiem.", "split_intro": "Noklikšķiniet uz diagrammas, lai pievienotu vai noņemtu šķelšanās punktu, vai automātiski noteiktu dīkstāves spraugas. Katrs iegūtais segments var iegūt savu profilu.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Atgriešana neizdevās: {error}", "toast_reverted": "{key} atgriezts", "toast_save_failed": "Saglabāšana neizdevās: {error}", - "trend_duration_longer": "Ilgums kļūst arvien garāks ({pct}%/ciklā) — nesenā vidējā {avg}", - "trend_duration_shorter": "Ilguma tendence ir īsāka ({pct}%/cikls) — nesenā vidējā {avg}", + "trend_duration_longer": "Ilgums kļūst arvien garāks ({pct}%/ciklā) – nesenā vidējā {avg}", + "trend_duration_shorter": "Ilguma tendence ir īsāka ({pct}%/cikls) – nesenā vidējā {avg}", "trend_energy_down": "Enerģijas tendence samazinās ({pct}%/cikls)", - "trend_energy_up": "Enerģijas tendence pieaug ({pct}%/ciklā) — nesenā vidējā {avg}", + "trend_energy_up": "Enerģijas tendence pieaug ({pct}%/ciklā) – nesenā vidējā {avg}", "trim_intro": "Velciet sarkanos rokturus vai ievadiet vērtības. Viss, kas atrodas ārpus loga, tiek noņemts.", "tuning_suggestions_available": "No novērotajiem cikliem pieejams {count} regulēšanas ieteikums. Tie parādās blakus attiecīgajiem laukiem.", "unsure_detected_prefix": "WashData nav pārliecināts, ka tas ir atklāts", @@ -857,7 +862,9 @@ "share_guidelines_title": "Pirms kopīgošanas", "store_download_device_intro": "Pieņemiet katru kopīgoto programmu un tās atskaites ciklus savā ierīcē. Jūsu pašu ierakstītie cikli un statistika netiek ietekmēti.", "store_share_device_intro": "Augšupielādējiet {brand} {model} ar atlasītajiem atskaites cikliem. Citi ar to pašu ierīci var pieņemt jūsu programmas. Ieraksti tiek pārskatīti pirms publiskošanas.", - "share_profile_no_cycles": "Nav atskaites ciklu -- atzīmējiet ciklu kā ⭐ cilnē Cikli, lai iekļautu šo profilu" + "share_profile_no_cycles": "Nav atskaites ciklu -- atzīmējiet ciklu kā ⭐ cilnē Cikli, lai iekļautu šo profilu", + "advisory_phase_inconsistent": "Šķiet, ka '{name}' sajauc dažādas programmas vai temperatūras - tā cikli sildās ļoti atšķirīgu laiku. Sadalot to atsevišķos profilos (piemēram, pēc temperatūras), tiks uzlabota saskaņošana un laika aprēķini.", + "advisory_phase_inconsistent_title": "⚠ Iespējams, jauktas programmas" }, "phase_desc": { "anti_crease": "Laiku pa laikam pēc pabeigšanas veiciet īsus gājienus, lai samazinātu grumbiņas.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Neobligātie ārējie signāli: beigu aktivators, durvju sensors, pauzes slēdzis un izkraušanas atgādinājums.", "label": "Aktivatori un durvis" + }, + "phase_eta": { + "label": "Atlikušais laiks", + "intro": "Uz fāzēm balstīts atlikušais laiks mašīnām, kuru cikla ilgums ir atkarīgs no temperatūras vai izgriešanas." } }, "setting": { @@ -1008,7 +1019,7 @@ "label": "Lietot viedos modeļus cikla laikā" }, "end_energy_threshold": { - "doc": "Izslēgšanas aizkaves atpakaļskaitīšanas laikā uzkrātā enerģija (vati x laiks) tiek salīdzināta ar šo slieksni. Ja tas tiek pārsniegts, atpakaļskaitīšanas laiks tiek atiestatīts — ciklam tiek pievienotas pretburzīšanās un trauku mazgājamās mašīnas žāvēšanas astes, nevis saīsinātas. Paceliet to, ja atdzišanas laikā cikli beidzas pārāk agri; samaziniet to, ja noteikšana ir gausa.", + "doc": "Izslēgšanas aizkaves atpakaļskaitīšanas laikā uzkrātā enerģija (vati x laiks) tiek salīdzināta ar šo slieksni. Ja tas tiek pārsniegts, atpakaļskaitīšanas laiks tiek atiestatīts – ciklam tiek pievienotas pretburzīšanās un trauku mazgājamās mašīnas žāvēšanas astes, nevis saīsinātas. Paceliet to, ja atdzišanas laikā cikli beidzas pārāk agri; samaziniet to, ja noteikšana ir gausa.", "label": "Beigu enerģija" }, "end_repeat_count": { @@ -1023,6 +1034,10 @@ "doc": "Fiksētā cena par kWh, ko izmanto izmaksu skaitļiem, ja iepriekš nav iestatīta reāllaika cenas vienība.", "label": "Fiksētā enerģijas cena (par kWh)" }, + "energy_sensor": { + "doc": "Neobligāts kumulatīvs enerģijas skaitītājs (total_increasing kWh/Wh, piemēram, paša kontaktdakšas kopējais skaitītājs). Ja iestatīts, katra cikla uzrādītā enerģija tiek ņemta no šī skaitītāja rādījuma starpības no sākuma līdz beigām, kas novērš nepietiekamu uzskaiti, ko rada lēni ziņojoša jaudas sensora integrēšana. Atgriežas pie integrētās vērtības, ja rādījuma nav, tā mērvienība nav zināma vai starpība nav pozitīva. Atstājiet tukšu, lai turpinātu integrēt jaudas sensoru.", + "label": "Enerģijas skaitītāja entītija" + }, "expose_debug_entities": { "doc": "Publicējiet papildu diagnostikas HA entītijas (atbilstības ticamība, neskaidrība, stāvokļa iekšējās vērtības). Izslēgts saglabā entītiju sarakstu tīru normālai lietošanai.", "label": "Atklāt atkļūdošanas entītijas" @@ -1056,7 +1071,7 @@ "label": "Minimālā jauda" }, "ml_training_enabled": { - "doc": "Periodiski izpētiet savus pārskatītos ciklus nakti un precizējiet modeļus šai konkrētajai iekārtai. Izmaiņas tiek saglabātas tikai tad, ja tās patiešām iegūst labākus rezultātus ilgstošajos ciklos, tāpēc tas var tikai palīdzēt vai palikt nemainīgs — nekad neatkāpties.", + "doc": "Periodiski izpētiet savus pārskatītos ciklus nakti un precizējiet modeļus šai konkrētajai iekārtai. Izmaiņas tiek saglabātas tikai tad, ja tās patiešām iegūst labākus rezultātus ilgstošajos ciklos, tāpēc tas var tikai palīdzēt vai palikt nemainīgs – nekad neatkāpties.", "label": "Mācīties no šīs mašīnas" }, "ml_training_hour": { @@ -1220,7 +1235,7 @@ "label": "Darbības mirušā zona" }, "sampling_interval": { - "doc": "Paredzamais laiks starp sensora rādījumiem — tiek izmantots, lai izlīdzinātu logu un sāktu pareizu atlēcienu. Katrs sensora atjauninājums tiek fiksēts neatkarīgi no šīs vērtības; tas tikai kalibrē pakārtotos aprēķinus. Ieteikumu programma mēra jūsu sensora faktisko ritmu no iepriekšējiem cikliem un iestata to automātiski.", + "doc": "Paredzamais laiks starp sensora rādījumiem – tiek izmantots, lai izlīdzinātu logu un sāktu pareizu atlēcienu. Katrs sensora atjauninājums tiek fiksēts neatkarīgi no šīs vērtības; tas tikai kalibrē pakārtotos aprēķinus. Ieteikumu programma mēra jūsu sensora faktisko ritmu no iepriekšējiem cikliem un iestata to automātiski.", "label": "Paraugu ņemšanas intervāls" }, "save_debug_traces": { @@ -1244,7 +1259,7 @@ "label": "Starta slieksnis" }, "stop_threshold_w": { - "doc": "Pirms izslēgšanas aizkaves atpakaļskaitīšanas sākuma jaudai ir jāsamazinās zem šī līmeņa. Iestatiet to zem sākuma sliekšņa — atstarpe starp tām ir histerēzes josla, kas novērš mirgošanu. Ja iestatīts pārāk augsts, mazjaudas fāzes (skalošanas aizturēšana, pretburzīšanās) kļūdaini aktivizē beigu secību.", + "doc": "Pirms izslēgšanas aizkaves atpakaļskaitīšanas sākuma jaudai ir jāsamazinās zem šī līmeņa. Iestatiet to zem sākuma sliekšņa – atstarpe starp tām ir histerēzes josla, kas novērš mirgošanu. Ja iestatīts pārāk augsts, mazjaudas fāzes (skalošanas aizturēšana, pretburzīšanās) kļūdaini aktivizē beigu secību.", "label": "Apstāšanās slieksnis" }, "switch_entity": { @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Rādīt \"autors: \" attiecinājumu kopienas ierīcēs un atskaites ciklos." + }, + "enable_phase_matching": { + "label": "Uz fāzēm balstīts atlikušais laiks", + "doc": "Sadaliet katru notiekošo ciklu fāzēs (sildīšana, mazgāšana, izgriešana) un plānojiet atlikušo laiku katrai fāzei, sajaucot ar klasisko aprēķinu - cikla sākumā paļaujoties uz fāžu plānojumu, bet beigās uz klasisko aprēķinu. Tas pielāgo atpakaļskaitīšanu tam, cik ilgi jūsu mašīna patiešām sildās un darbojas, kas visvairāk pamanāms cikla pirmajā pusē. Izslēgts = tikai klasiskais aprēķins. Tiek ietekmēts tikai atlikušā laika attēlojums; programmu saskaņošana un ciklu noteikšana paliek nemainīga." + }, + "keep_min_score": { + "label": "Min. atbilstības rezultāts", + "doc": "Zemākais līdzības rezultāts, kas kandidātam vajadzīgs, lai paliktu saskaņošanas procesā. Noklusējums 0,1 ir apzināti ļaujošs – tas pieļauj pat vājus kandidātus un paļaujas uz vēlākajiem posmiem labākā atbilstības atrašanai. Palieliniet, lai agrāk filtrētu maz ticamos profilus; samaziniet, lai paplašinātu sākotnējo kandidātu kopumu." + }, + "dtw_blend": { + "label": "DTW sajaukums", + "doc": "Cik daudz DTW izlīdzināšanas rezultāts aizstāj 2. posma pamatrezultātu. 0 = izmantojiet tikai 2. posma rezultātu, 1 = izmantojiet tikai DTW rezultātu, 0,5 (noklusējums) = vienāds sajaukums. Palieliniet, lai vairāk paļautos uz laika izlīdzināšanu, ja programmām ir līdzīgi jaudas līmeņi, bet atšķirīgas laika shēmas." + }, + "dtw_ensemble_w": { + "label": "DTW ensemble miks", + "doc": "Ensemble-DTW režīmā (noklusējums) tas ir svars skalētajam L1 salīdzinājumā ar atvasināto DTW (DDTW). 1,0 = tikai skalētais L1, 0 = tikai DDTW, 0,7 = noklusējums. Skalētā varianta salīdzina jaudas līmeņus; atvasinātā varianta reaģē uz to, kā jauda mainās laika gaitā. Ensemble apvieno abus." + }, + "dtw_ddtw_scale": { + "label": "Atv. DTW skāle", + "doc": "Atvasinātās DTW vērtēšanas pusiesātinājuma attālums. Rezultāts uz pusēm samazinās, kad DDTW attālums ir vienāds ar šo vērtību. Mazāks = jutīgāks pret formas atšķirībām. Noklusējums 30 ir kalibrēts tipiskajiem sadzīves tehnikas jaudas profiliem. Ietekmē tikai ensemble un ddtw DTW režīmus." + }, + "dtw_refine_top_n": { + "label": "DTW precizēšanas skaits", + "doc": "Cik daudz 2. posma labāko kandidātu DTW pārvērtē. DTW ir dārgāks, tāpēc tas tiek piemērots tikai N labākajiem. Noklusējums 5. Palieliniet (līdz 7-9), ja pareizais profils dažreiz pēc 2. posma sasniedz tikai 4. vai 5. vietu – DTW var to glābt. Zemāka vērtība nedaudz paātrina saskaņošanu." + }, + "duration_scale": { + "label": "Ilguma asums", + "doc": "4. posma ilguma atbilstības soda asums. Tas ir logaritmiskais koeficients, pie kura atbilstības rezultāts uz pusēm samazinās. Mazāks = bargāks: ilguma neatbilstība kaitē vairāk. Noklusējums 0,175 atbilst aptuveni 18% ilguma tolerancei pie pus-svara. Izmantojiet kopā ar Ilguma svaru." + }, + "energy_scale": { + "label": "Enerģijas asums", + "doc": "4. posma enerģijas atbilstības soda asums. Mazāks = bargāks: enerģijas neatbilstība kaitē vairāk. Noklusējums 0,25 ir apzināti piedodošāks nekā ilguma asums, jo enerģija mainās atkarībā no slodzes. Izmantojiet kopā ar Enerģijas svaru." } }, "setting_group": { @@ -1553,24 +1600,24 @@ "lower": "Automātiski iezīmē vairāk ciklu; daži var tikt nepareizi identificēti" }, "duration_tolerance": { - "higher": "Pieņem plašāku ilguma diapazonu — elastīgāka atbilstības noteikšana", - "lower": "Prasa precīzāku ilguma atbilstību — stingrāks, bet var noraidīt neparastus ciklus" + "higher": "Pieņem plašāku ilguma diapazonu – elastīgāka atbilstības noteikšana", + "lower": "Prasa precīzāku ilguma atbilstību – stingrāks, bet var noraidīt neparastus ciklus" }, "end_energy_threshold": { "higher": "Aizver tikai ciklus, kas patērēja ievērojamu enerģiju", "lower": "Aizver arī īsākus vai zemas enerģijas ciklus" }, "end_repeat_count": { - "higher": "Prasa beigu signālu atkārtot vairāk reižu — uzticamāks, nedaudz lēnāks", - "lower": "Beidz pēc mazākiem atkārtojumiem — ātrāka atklāšana, lielāks viltus beigu risks" + "higher": "Prasa beigu signālu atkārtot vairāk reižu – uzticamāks, nedaudz lēnāks", + "lower": "Beidz pēc mazākiem atkārtojumiem – ātrāka atklāšana, lielāks viltus beigu risks" }, "learning_confidence": { - "higher": "Mācās tikai no ļoti pārliecinošām atbilstībām — lēnāks, bet uzticamāks", + "higher": "Mācās tikai no ļoti pārliecinošām atbilstībām – lēnāks, bet uzticamāks", "lower": "Mācās no vairākiem cikliem; modelis var būt trokšņaināks" }, "min_off_gap": { "higher": "Pirms jauna cikla nepieciešams ilgāks dīkstāves periods", - "lower": "Īsa dīkstāve aktivizē jaunu ciklu — secīgas palaišanas var sadalīties" + "lower": "Īsa dīkstāve aktivizē jaunu ciklu – secīgas palaišanas var sadalīties" }, "min_power": { "higher": "Mazāk jutīgs pret dīkstāves jaudas patēriņu; var palaist garām klusus režīmus", @@ -1581,24 +1628,24 @@ "lower": "Piespiedu kārtā aptur iestrēgušu ciklu ātrāk" }, "off_delay": { - "higher": "Vairāk laika ierīces pauzēm — apstrādā ilgu mērcēšanu vai žāvēšanu", - "lower": "Ātrāka beigu atklāšana — labi darbojas skaidrām ieslēgt/izslēgt ierīcēm" + "higher": "Vairāk laika ierīces pauzēm – apstrādā ilgu mērcēšanu vai žāvēšanu", + "lower": "Ātrāka beigu atklāšana – labi darbojas skaidrām ieslēgt/izslēgt ierīcēm" }, "profile_match_threshold": { "higher": "Apstiprina tikai augstas pārliecības atbilstības; vairāk ciklu paliek bez iezīmes", "lower": "Atbilst vairākiem cikliem; daži var būt nedaudz nepareizi" }, "running_dead_zone": { - "higher": "Ignorē īsus jaudas pieauguma momentus dīkstāvē — mazāk jutīgs pret troksni", - "lower": "Reaģē uz īsākiem jaudas uzliesmojumiem — uztver ātru pārejošu aktivitāti" + "higher": "Ignorē īsus jaudas pieauguma momentus dīkstāvē – mazāk jutīgs pret troksni", + "lower": "Reaģē uz īsākiem jaudas uzliesmojumiem – uztver ātru pārejošu aktivitāti" }, "start_threshold_w": { "higher": "Novērš viltus palaišanas no īslaicīgiem jaudas pieaugumiem", "lower": "Uztver mazjaudas programmas; var reaģēt uz troksni" }, "stop_threshold_w": { - "higher": "Beidz ciklu ātrāk — var aizvērt pauzes laikā", - "lower": "Gaida ilgāk līdz beigām — mazāk priekšlaicīgu pabeigšanu" + "higher": "Beidz ciklu ātrāk – var aizvērt pauzes laikā", + "lower": "Gaida ilgāk līdz beigām – mazāk priekšlaicīgu pabeigšanu" }, "watchdog_interval": { "higher": "Atļautas garākas klusas fāzes; mazāk viltus piespiedu apstāšanās", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Sekundes virs sliekšņa, lai apstiprinātu sākumu", "start_threshold_w": "Minimālais vatu skaits, lai skaitītos kā sācies", "stop_threshold_w": "Zem šīs vērtības iekārta skaitās izslēgta", - "dtw_bandwidth": "Cik lielu laika deformāciju pieļauj formas saskaņošana", - "corr_weight": "Līknes formas un jaudas līmeņa svars saskaņošanā", - "duration_weight": "Cik lielā mērā ilguma atbilstība ietekmē saskaņošanu", - "energy_weight": "Cik lielā mērā enerģijas atbilstība ietekmē saskaņošanu", - "profile_match_min_duration_ratio": "Īsākais cikls (salīdzinot ar profilu), kuram vēl atļauts atbilst", - "profile_match_max_duration_ratio": "Garākais cikls (salīdzinot ar profilu), kuram vēl atļauts atbilst" + "dtw_bandwidth": "3. posms: Sakoe-Chiba laika locīšanas josla (0 = DTW izslēgts; noklusējums 0,2 = 20% no cikla garuma)", + "corr_weight": "2. posms: līdzsvars starp līknes formu (korelācija) un jaudas līmeni (MAE); noklusējums 0,45", + "duration_weight": "4. posms: cik stipri ilguma atbilstība ietekmē galīgo rezultātu (noklusējums 0,22)", + "energy_weight": "4. posms: cik stipri enerģijas atbilstība ietekmē galīgo rezultātu (noklusējums 0,22)", + "profile_match_min_duration_ratio": "1. posms: īsākais cikls kā profila ilguma daļa; noklusējums 0,1 (10%)", + "profile_match_max_duration_ratio": "1. posms: garākais cikls kā profila ilguma daļa; noklusējums 1,5 (150%)", + "keep_min_score": "2. posms: zemākais rezultāts, lai paliktu sacensībā; noklusējums 0,1 pieļauj vājus kandidātus, vēlākie posmi izvēlas labāko", + "dtw_blend": "3. posms: 0 = tikai pamatrezultāts, 1 = tikai DTW rezultāts, 0,5 = vienāds sajaukums (noklusējums)", + "dtw_ensemble_w": "3. posms (ensemble režīms): skalētā L1 svars pret atvasinātās DTW; noklusējums 0,7 dod priekšroku līmeņapziņai", + "dtw_ddtw_scale": "3. posms: DDTW pusiesātinājuma attālums; mazāks = jutīgāks pret formas atšķirībām (noklusējums 30)", + "dtw_refine_top_n": "3. posms: kandidāti, kurus DTW pārvērtē; palieliniet līdz 7-9, ja pareizais profils ierindojas 4.-5. vietā (noklusējums 5)", + "duration_scale": "4. posms: logaritmiskais koeficients, kur ilguma atbilstība uz pusēm; mazāks = bargāks sods (noklusējums 0,175)", + "energy_scale": "4. posms: logaritmiskais koeficients, kur enerģijas atbilstība uz pusēm; mazāks = bargāks sods (noklusējums 0,25)" }, "col": { "profile_tip": "Atbilstošās programmas nosaukums. Bez birkas nozīmē, ka cikla beigās neatbilda neviens profils.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimizēšana: {param}" + }, + "pg_detail": { + "simulate": "Cikla simulēšana" + }, + "split": { + "apply": "Cikla sadalīšana" + }, + "trim": { + "apply": "Cikla apgriešana" + }, + "merge": { + "apply": "Ciklu apvienošana" + }, + "rebuild": { + "envelopes": "Apvalklīkņu pārbūve" + }, + "cancelling": "Atceļ..." + }, + "setup": { + "hdr": { + "card": "Ierīces iestatīšana", + "healthy_chip": "Iestatīšana pabeigta" + }, + "phase0": { + "washer": "WashData jau konstatē jūsu ciklus. Ierakstiet pirmo ciklu, lai aktivizētu programmu nosaukumus un laika aprēķinus.", + "dishwasher": "WashData vēro. Trauku mazgājamām mašīnām ir sarežģīti cikli – ir ļoti ieteicams ierakstīt pirmo ciklu. Ja konstatētais cikls darbojas pārāk ilgi, pirms saglabāšanas kā profilu izmantojiet cikla redaktoru, lai to apgrieztu.", + "generic": "WashData vēro. Ierakstiet vai iezīmējiet konstatētu ciklu, lai sāktu veidot profilus." + }, + "phase1a": { + "labelled": "Labs sākums – jūsu pirmā programma ir saglabāta. Lai iegūtu tīrākos datus, apsveriet iespēju ierakstīt nākamo ciklu ar ierakstīšanas logrīku." + }, + "phase1b": { + "recorded": "Jūsu ieraksts ir saglabāts kā {profile_name}. Tagad ierakstiet vai iezīmējiet citas bieži izmantotās programmas, lai paplašinātu pārklājumu." + }, + "phase1c": { + "verify": "Jums ir {count} programmas no kopienas. Palaidiet ciklu, lai pārbaudītu, vai WashData to pareizi atpazīst – atbilstība uzlabosies, kad jūsu ierīce veidos savu vēsturi." + }, + "phase2": { + "cluster": "WashData ir konstatējis {count} ciklus, kas neatbilst nevienai saglabātai programmai – tie izskatās līdzīgi viens otram. Vai vēlaties tiem izveidot jaunu profilu?", + "unmatched": "Pēdējais cikls neatbilda nevienai saglabātai programmai. Vai tā ir jauna programma?" + }, + "phase3": { + "suggestions": "WashData ir iestatījumu ieteikumi, pamatojoties uz jūsu ciklu vēsturi – pārskatiet tos, lai uzlabotu noteikšanas precizitāti.", + "groups": "Daži jūsu profili izskatās kā viena un tā pati programma dažādās temperatūrās. Sakārtojiet tos grupā labākai atbilstībai.", + "phases": "Pievienojiet programmas fāzes profilam {profile_name}, lai iegūtu precīzākus laika atlikuma aprēķinus." + }, + "phase4": { + "healthy": "Šī ierīce ir pilnīgi iestatīta ({profile_count} profili)." + }, + "cta": { + "start_recording": "Sākt ierakstīšanu", + "label_detected_cycle": "Man jau ir konstatēts cikls – iezīmēt to", + "browse_cycles": "Pārlūkot ciklus, lai iezīmētu citu", + "view_profiles": "Skatīt savus profilus", + "create_from_cluster": "Izveidot profilu no šiem cikliem", + "create_profile": "Izveidot tam profilu", + "review_suggestions": "Pārskatīt ieteikumus", + "organise_profiles": "Kārtot profilus", + "configure_phases": "Konfigurēt fāzes", + "skip_step": "Izlaist šo soli", + "skip_forever": "Vairs nerādīt", + "hide_guidance": "Slēpt norādījumus", + "show_guidance": "Rādīt norādījumus" } } } diff --git a/custom_components/ha_washdata/translations/panel/mk.json b/custom_components/ha_washdata/translations/panel/mk.json index 3eb06b2..3bb74c1 100644 --- a/custom_components/ha_washdata/translations/panel/mk.json +++ b/custom_components/ha_washdata/translations/panel/mk.json @@ -87,7 +87,7 @@ "on_cycle_finished": "На циклус заврши", "on_cycle_started": "На циклус започна", "pause_cycle": "Пауза", - "pause_cycle_tip": "Паузирај го тековниот циклус — апаратот ќе продолжи од местото каде застанал", + "pause_cycle_tip": "Паузирај го тековниот циклус – апаратот ќе продолжи од местото каде застанал", "pg_apply_device": "Примени на уредот", "pg_autofill": "Автоматско пополнување", "play": "Пушти", @@ -97,8 +97,8 @@ "process_tip": "Зачувај го снимениот траг како нов или постоечки профил", "rebuild": "Обновете ги пликовите", "rebuild_envelope": "Обновете го пликот", - "rebuild_tip": "Препресметај го очекуваниот плик на моќноста (мин./макс. лента) за сите профили од нивните означени циклуси — извршете после означување на нови циклуси или поправање на стари", - "rec_start_tip": "Почнете снимање на трагот на моќноста на апаратот — стартувајте непосредно пред почеток на циклусот", + "rebuild_tip": "Препресметај го очекуваниот плик на моќноста (мин./макс. лента) за сите профили од нивните означени циклуси – извршете после означување на нови циклуси или поправање на стари", + "rec_start_tip": "Почнете снимање на трагот на моќноста на апаратот – стартувајте непосредно пред почеток на циклусот", "rec_stop": "Стоп", "rec_stop_tip": "Запрете го снимањето и задржете го снимениот траг за преглед", "record": "Започнете со снимање", @@ -182,7 +182,7 @@ }, "attn_sub": "Поправете ги конфликтите пред зачувување", "attn_title": "{n} конфликт{s} во поставките", - "settings_banner": "{n} конфликт{s} во поставките — проверете ги означените раздели и поправете пред зачувување.", + "settings_banner": "{n} конфликт{s} во поставките – проверете ги означените раздели и поправете пред зачувување.", "settings_banner_btn": "Оди на прв", "confidence": { "auto": "Мора да е на или над Прагот за Совпаѓање ({match})", @@ -451,9 +451,9 @@ "show_expected": "Прикажи очекувано преклопување на кривата (совпаднат профил, портокалова)", "show_raw": "Прикажи прекинувач за необработени отчитувања на приклучокот во живиот графикон на моќноста", "sparkline": "Неодамнешен тренд на времетраење на циклусите", - "stage2": "Етапа 2 — основна сличност", - "stage3": "Етапа 3 — DTW", - "stage4": "Етапа 4 — согласување", + "stage2": "Етапа 2 – основна сличност", + "stage3": "Етапа 3 – DTW", + "stage4": "Етапа 4 – согласување", "status": "Статус", "tail_trim": "Намалување на опашката (и)", "timer_auto_pause": "Автоматска пауза", @@ -561,7 +561,12 @@ "flags": "Ознаки", "include_phase_map": "Вклучи карта на фазите", "include_settings": "Вклучи поставки", - "show_contributor": "Прикажи соработник" + "show_contributor": "Прикажи соработник", + "task_pg_detail": "Симулирај циклус", + "task_split": "Разделување на циклус", + "task_trim": "Отсекување на циклус", + "task_merge": "Спојување на циклуси", + "task_rebuild": "Повторно градење на обвивки" }, "log": { "all_levels": "Сите нивоа", @@ -645,19 +650,19 @@ "artifact_dip_detail": "Падна под вообичаениот опсег на моќност за ~{n}с.", "artifact_footer": "Нагласено на графиконот погоре. Ова се минливи артефакти (на пр. вратата отворена во средината на циклусот), а не нужно проблеми.", "artifact_header": "{n} откриени аномалии за време на овој циклус", - "artifact_pause_detail": "Моќноста падна до скоро нула за ~{n}с потоа продолжи — веројатно вратата беше отворена во средината на циклусот или циклусот беше паузиран.", + "artifact_pause_detail": "Моќноста падна до скоро нула за ~{n}с потоа продолжи – веројатно вратата беше отворена во средината на циклусот или циклусот беше паузиран.", "artifact_spike_detail": "Надмина го вообичаениот опсег на моќност за ~{n}с.", "auto_label_intro": "Доделете профили на неозначени циклуси чијашто доверба за совпаѓање го брише прагот.", "automations_intro": "WashData активира настани {start} / {end} и изложува ентитети, па известувањата и акциите е најдобро да се градат како нормални автоматизации на Home Assistant. Автоматизациите кои го користат овој уред се прикажани подолу.", "cleanup_intro": "Секој означен циклус е преклопен. Штиклирајте ги оддалечените и избришете за да го исчистите профилот.", "clear_debug_hint": "Отстранете ги зачуваните податоци за отстранување грешки за да ослободите простор.", - "collecting_data": "Собирање на податоци — уште {need} циклус{plural} пред почетокот на финото подесување ({current}/{min}).", + "collecting_data": "Собирање на податоци – уште {need} циклус{plural} пред почетокот на финото подесување ({current}/{min}).", "compare_overlay_profiles": "Преклопени профили (бледо)", "compare_profiles_tip": "Преклопете ги другите пликови на профили на табелата погоре за да видите кој најдобро одговара на овој циклус.", "compare_selected_cycles": "Избрани циклуси (цврсти) - прикажи / скриј", "consider_new_profile": "Размислете за создавање нов профил.", "coverage_gap": "последните циклуси немаат соодветни профили ({pct}% од последните 30).", - "coverage_gap_similar_cycles": "{count} слични необележани циклуси пронајдени — направете профил за да започнете да ги совпаѓате.", + "coverage_gap_similar_cycles": "{count} слични необележани циклуси пронајдени – направете профил за да започнете да ги совпаѓате.", "cycles_deleted": "Избришани се {count} циклуси", "enough_data": "Доволно податоци за учење ({current}/{min} циклуси).", "export_description": "Извезете ги сите профили и циклуси во JSON или вратете ги од претходен извоз.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Модели фино подесени на оваа машина.", "ml_loading": "се вчитува ML…", "ml_settings_intro": "Два независни прекинувачи: едниот ги применува моделите додека работи циклусот, другиот дозволува WashData фино да ги прилагоди на вашата машина со текот на времето.", - "name_first_program": "Имате доволно циклуси — именувајте ја вашата прва програма за да започне совпаѓањето.", + "name_first_program": "Имате доволно циклуси – именувајте ја вашата прва програма за да започне совпаѓањето.", "near_duplicate_cluster": "Откриена е скоро дупликат профилна група. Групирањето овозможува совпаѓањето со сигурност да се избере помеѓу сличните (на пр. иста програма на различна температура/вртење).", "no_cycles_match": "Ниту еден циклус не одговара на тековниот филтер.", "no_cycles_profile": "Нема циклуси за овој профил.", @@ -713,7 +718,7 @@ "notify_services_hint": "Користете ID-ови на сервисите {entity} (одвоени со запирки за повеќе). Варијабли на шаблонот: {vars}.", "old_actions_warning": "Конфигуриран со стариот уредувач на дејства (сега отстранет). Сè уште пукаат на настани од циклусот, но веќе не може да се уредуваат овде. Претворете ги во нормална автоматизација или отстранете ги.", "onboarding_progress": "{n} / 3 набљудувани циклуси", - "onboarding_watching": "Користете го апаратот како вообичаено — WashData набљудува. По 3 циклуси, ќе започне совпаѓањето на програми.", + "onboarding_watching": "Користете го апаратот како вообичаено – WashData набљудува. По 3 циклуси, ќе започне совпаѓањето на програми.", "pending_feedback": "Во очекување на повратни информации за откривање", "performance_trend": "Тренд на изведба ({n} циклуси)", "pg_analysis_empty": "Вчитајте циклус за да видите анализа на совпаѓање.", @@ -763,7 +768,7 @@ "setting_changed": "Променето од {old} на {new} на {date}", "settings_basic_note": "Прикажани се основните поставки. Префрлете се на Напредно за целосната листа.", "shape_drift_advisory": "⚠ Форма дрифт", - "shape_drift_detail": "Обрасецот на моќноста за овој профил се промени со текот на времето — можно е трошење на апаратот или потребно е одржување (на пр. одкаменување, чистење на филтерот).", + "shape_drift_detail": "Обрасецот на моќноста за овој профил се промени со текот на времето – можно е трошење на апаратот или потребно е одржување (на пр. одкаменување, чистење на филтерот).", "show_all_settings": "Прикажи ги сите поставки", "showing_suggestions": "Се прикажува поставката {count} со предлози.", "split_intro": "Кликнете на графиконот за да додадете или отстраните точка на разделување или автоматско откривање со празнини во мирување. Секој добиен сегмент може да добие свој профил.", @@ -857,7 +862,9 @@ "share_guidelines_title": "Пред споделување", "store_download_device_intro": "Преземете поставки на уредот од заедницата и применете ги на нов или постоечки уред", "store_share_device_intro": "Споделете ги програмите на вашиот уред (профили + референтни циклуси) со заедницата. Поставките се опционални.", - "share_profile_no_cycles": "Профилот '{p}' нема ⭐ референтни циклуси -- ќе биде прескокнат освен ако немате {n}+ потврдени стартувања" + "share_profile_no_cycles": "Профилот '{p}' нема ⭐ референтни циклуси -- ќе биде прескокнат освен ако немате {n}+ потврдени стартувања", + "advisory_phase_inconsistent": "Изгледа дека '{name}' меша различни програми или температури - неговите циклуси се загреваат многу различно долго. Поделбата на посебни профили (на пр. по температура) ќе ги подобри совпаѓањето и проценките на времето.", + "advisory_phase_inconsistent_title": "⚠ Можеби измешани програми" }, "phase_desc": { "anti_crease": "Повремени кратки превртувања по завршувањето за да се намалат брчките.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Опционални надворешни сигнали: тригер за крај, сензор за врата, прекинувач за пауза и потсетник за истовар.", "label": "Тригери и врати" + }, + "phase_eta": { + "label": "Преостанато време", + "intro": "Проценка на преостанатото време, свесна за фазите, за машини чија должина на циклусот зависи од температурата или центрифугирањето." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Фиксна цена по kWh што се користи за бројките на трошоците кога не е поставен ентитет на цена во живо погоре.", "label": "Статична цена на енергија (по kWh)" }, + "energy_sensor": { + "doc": "Изборен кумулативен бројач на енергија (total_increasing kWh/Wh, на пр. сопствениот бројач за вкупната потрошувачка на приклучокот). Кога е поставен, енергијата пријавена за секој циклус се зема од разликата во отчитувањата на овој бројач меѓу почетокот и крајот, што го избегнува потценувањето што се добива при интегрирање на бавно известувачки сензор за моќност. Се враќа на интегрираната вредност ако отчитувањето недостасува, неговата единица е непозната или разликата не е позитивна. Оставете празно за да продолжите да го интегрирате сензорот за моќност.", + "label": "Ентитет на мерач на енергија" + }, "expose_debug_entities": { "doc": "Објавувајте дополнителни дијагностички HA ентитети (доверба на натпреварот, двосмисленост, внатрешни работи). Исклучено го одржува списокот со ентитети чист за нормална употреба.", "label": "Изложи ентитети за дебагирање" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Прикажи го името на соработникот на профилите преземени од продавницата на заедницата" + }, + "enable_phase_matching": { + "label": "Преостанато време според фазите", + "doc": "Го дели секој активен циклус на фази (загревање, перење, центрифугирање) и го распределува преостанатото време по фази, во комбинација со класичната проценка - потпирајќи се на распределбата по фази на почетокот на циклусот, а на класичната проценка кон крајот. Ова го прилагодува одбројувањето на тоа колку навистина се загрева и работи вашата машина, што е најзабележливо во првата половина од циклусот. Исклучено = само класичната проценка. Влијае само на приказот на преостанатото време; совпаѓањето на програми и откривањето на циклуси остануваат непроменети." + }, + "keep_min_score": { + "label": "Мин. резултат на совпаѓање", + "doc": "Минимален резултат на сличност потребен на кандидатот за да остане во трката на совпаѓање. Стандардната вредност 0,1 е намерно попустлива - прифаќа дури и слаби кандидати и се потпира на подоцнежните фази за наоѓање на најдоброто совпаѓање. Зголемете за порано отфрлање на малку веројатни профили; намалете за проширување на почетниот сет на кандидати." + }, + "dtw_blend": { + "label": "Мешање на деформација", + "doc": "Колку DTW резултатот на порамнување го заменува основниот резултат на Фаза 2. 0 = користи само резултат на Фаза 2, 1 = користи само DTW резултат, 0,5 (стандардно) = еднакво мешање. Зголемете за поголемо потпирање на warp порамнување кога програмите имаат слични нивоа на моќност но различни временски обрасци." + }, + "dtw_ensemble_w": { + "label": "Микс на комбинација DTW", + "doc": "Во комбинираниот DTW режим (стандардниот), тежина на скалиран L1 наспроти изводен DTW (DDTW). 1,0 = само скалиран L1, 0 = само DDTW, 0,7 = стандардно. Скалираната варијанта L1 ги споредува нивоата на моќност; изводната варијанта реагира на промените на моќноста низ времето. Комбинираниот режим ги спојува двете." + }, + "dtw_ddtw_scale": { + "label": "Скала на изводна деформација", + "doc": "Растојание на пол-заситување за оценување со изводен DTW. Резултатот се преполовува кога растојанието DDTW е еднакво на оваа вредност. Помало = поосетливо на разлики во форма. Стандардната вредност 30 е калибрирана за типични траги на моќност на домашни апарати. Влијае само на комбинираниот и ddtw режим на DTW." + }, + "dtw_refine_top_n": { + "label": "Број на прецизирани кандидати", + "doc": "Колку врвни кандидати на Фаза 2 DTW повторно ги оценува. DTW е поскап па се применува само за N најдобри кандидати. Стандардно 5. Зголемете (на 7-9) ако правилниот профил понекогаш достигнува само 4. или 5. место по Фаза 2 - DTW може да го спаси. Намалувањето малку го забрзува совпаѓањето." + }, + "duration_scale": { + "label": "Острина на траење", + "doc": "Острина на казната за несовпаѓање на траење во Фаза 4. Ова е лог-однос при кој резултатот на согласност се преполовува. Помал = построг: несовпаѓањето на траење повеќе го намалува резултатот. Стандардната вредност 0,175 одговара на толеранција на траење од околу 18% при пол-тежина. Употребувајте заедно со Тежината на траење." + }, + "energy_scale": { + "label": "Острина на енергија", + "doc": "Острина на казната за несовпаѓање на енергија во Фаза 4. Помал = построг: несовпаѓањето на енергија повеќе го намалува резултатот. Стандардната вредност 0,25 е намерно попустлива отколку Острината на траење бидејќи енергијата варира со оптоварувањето. Употребувајте заедно со Тежината на енергија." } }, "setting_group": { @@ -1553,24 +1600,24 @@ "lower": "Автоматски означува повеќе циклуси; некои може да бидат погрешно идентификувани" }, "duration_tolerance": { - "higher": "Прифаќа поширок опсег на траење — пофлексибилно поврзување", - "lower": "Бара поточно совпаѓање на траење — построго, но може да ги отфрли невообичаените циклуси" + "higher": "Прифаќа поширок опсег на траење – пофлексибилно поврзување", + "lower": "Бара поточно совпаѓање на траење – построго, но може да ги отфрли невообичаените циклуси" }, "end_energy_threshold": { "higher": "Затвора само циклуси со значителна потрошувачка на енергија", "lower": "Затвора и пократки или понискоенергетски циклуси" }, "end_repeat_count": { - "higher": "Бара сигналот за крај да се повтори повеќе пати — посигурно, малку побавно", - "lower": "Завршува по помалку повторувања — побрзо откривање, поголем ризик од лажен крај" + "higher": "Бара сигналот за крај да се повтори повеќе пати – посигурно, малку побавно", + "lower": "Завршува по помалку повторувања – побрзо откривање, поголем ризик од лажен крај" }, "learning_confidence": { - "higher": "Учи само од многу сигурни совпаѓања — побавно, но посигурно", + "higher": "Учи само од многу сигурни совпаѓања – побавно, но посигурно", "lower": "Учи од повеќе циклуси; моделот може да биде позашумен" }, "min_off_gap": { "higher": "Потребен е подолг период на неактивност пред нов циклус", - "lower": "Краток период на неактивност стартува нов циклус — последователните работи може да се поделат" + "lower": "Краток период на неактивност стартува нов циклус – последователните работи може да се поделат" }, "min_power": { "higher": "Помалку чувствителен на фонска потрошувачка; може да ги пропушти тивките програми", @@ -1581,24 +1628,24 @@ "lower": "Принудно запира заглавен циклус побрзо" }, "off_delay": { - "higher": "Повеќе време за паузи на уредот — управува со долго натопување или сушење", - "lower": "Побрзо откривање на крај — добро за уреди со јасно вкл./искл." + "higher": "Повеќе време за паузи на уредот – управува со долго натопување или сушење", + "lower": "Побрзо откривање на крај – добро за уреди со јасно вкл./искл." }, "profile_match_threshold": { "higher": "Потврдува само совпаѓања со висока сигурност; повеќе циклуси остануваат без ознака", "lower": "Поврзува повеќе циклуси; некои може да бидат малку погрешни" }, "running_dead_zone": { - "higher": "Ги игнорира кратките скокови на моќност во неактивност — помалку чувствителен на шум", - "lower": "Реагира на пократки наплив на моќност — ги фаќа брзите преодни активности" + "higher": "Ги игнорира кратките скокови на моќност во неактивност – помалку чувствителен на шум", + "lower": "Реагира на пократки наплив на моќност – ги фаќа брзите преодни активности" }, "start_threshold_w": { "higher": "Избегнува лажни стартови од кратковремени скокови на моќност", "lower": "Ги фаќа програмите со мала моќност; може да се активира на шум" }, "stop_threshold_w": { - "higher": "Го завршува циклусот побрзо — може да затвори за време на пауза", - "lower": "Чека подолго за завршување — помалку предвремени завршувања" + "higher": "Го завршува циклусот побрзо – може да затвори за време на пауза", + "lower": "Чека подолго за завршување – помалку предвремени завршувања" }, "watchdog_interval": { "higher": "Дозволени подолги тивки фази; помалку лажни принудни запирања", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Секунди над прагот за потврда на стартот", "start_threshold_w": "Минимум вати за да се смета за започнато", "stop_threshold_w": "Под ова, машината се смета за исклучена", - "dtw_bandwidth": "Колку временско растегнување дозволува совпаѓањето по форма", - "corr_weight": "Тежина на формата на кривата наспроти нивото на моќност при совпаѓањето", - "duration_weight": "Колку согласувањето на времетраењето влијае на совпаѓањето", - "energy_weight": "Колку согласувањето на потрошувачката на енергија влијае на совпаѓањето", - "profile_match_min_duration_ratio": "Најкратко извршување (во однос на профилот) што сè уште може да се совпадне", - "profile_match_max_duration_ratio": "Најдолго извршување (во однос на профилот) што сè уште може да се совпадне" + "dtw_bandwidth": "Фаза 3: warp лента на Sakoe-Chiba (0 = DTW исклучен; стандардно 0,2 = 20% од должината на циклусот)", + "corr_weight": "Фаза 2: рамнотежа помеѓу обликот на кривата (корелација) и нивото на моќност (MAE); стандардно 0,45", + "duration_weight": "Фаза 4: колку силно согласноста на траење влијае на конечниот резултат (стандардно 0,22)", + "energy_weight": "Фаза 4: колку силно согласноста на енергија влијае на конечниот резултат (стандардно 0,22)", + "profile_match_min_duration_ratio": "Фаза 1: минимална должина на циклус како дел од траењето на профилот; стандардно 0,1 (10%)", + "profile_match_max_duration_ratio": "Фаза 1: максимална должина на циклус како дел од траењето на профилот; стандардно 1,5 (150%)", + "keep_min_score": "Фаза 2: минимален резултат за останување во трката; стандардно 0,1 прифаќа слаби совпаѓања, подоцнежните фази го избираат најдоброто", + "dtw_blend": "Фаза 3: 0 = само основен резултат, 1 = само DTW резултат, 0,5 = еднакво мешање (стандардно)", + "dtw_ensemble_w": "Фаза 3 (комбиниран режим): тежина на скалиран L1 наспроти изводен DTW; стандардно 0,7 ги фаворизира нивоата", + "dtw_ddtw_scale": "Фаза 3: растојание на пол-заситување на DDTW; помало = поосетливо на форма (стандардно 30)", + "dtw_refine_top_n": "Фаза 3: кандидати кои DTW повторно ги оценува; зголемете на 7-9 ако правилниот профил заземе 4.-5. место (стандардно 5)", + "duration_scale": "Фаза 4: лог-однос каде согласноста на траење се преполовува; помал = построга казна (стандардно 0,175)", + "energy_scale": "Фаза 4: лог-однос каде согласноста на енергија се преполовува; помал = построга казна (стандардно 0,25)" }, "col": { "profile_tip": "Име на совпаднатата програма. „Неозначено“ значи дека на крајот на циклусот не се совпадна ниту еден профил.", @@ -1766,6 +1820,68 @@ }, "pg_sweep": { "optimize": "Оптимизирање: {param}" + }, + "pg_detail": { + "simulate": "Симулирај циклус" + }, + "split": { + "apply": "Разделување на циклус" + }, + "trim": { + "apply": "Отсекување на циклус" + }, + "merge": { + "apply": "Спојување на циклуси" + }, + "rebuild": { + "envelopes": "Повторно градење на обвивки" + } + }, + "setup": { + "hdr": { + "card": "Поставување на уред", + "healthy_chip": "Поставувањето е завршено" + }, + "phase0": { + "washer": "WashData веќе ги открива вашите циклуси. Снимете го првиот циклус за да ги овозможите имињата на програмите и проценките на времето.", + "dishwasher": "WashData набљудува. Машините за миење садови имаат сложени циклуси – снимањето на првиот циклус е изразито препорачано. Ако открит циклус трае предолго, користете го уредувачот на циклуси за скратување пред зачувување како профил.", + "generic": "WashData набљудува. Снимете или означете откриен циклус за да започнете со градење профили." + }, + "phase1a": { + "labelled": "Добар почеток – вашата прва програма е зачувана. За најчисти податоци, размислете за снимање на следниот циклус со додатокот за снимање." + }, + "phase1b": { + "recorded": "Вашата снимка е зачувана како {profile_name}. Сега снимете или означете ги другите чести програми за да ја проширите покриеноста." + }, + "phase1c": { + "verify": "Имате {count} програми од заедницата. Стартувајте циклус за да проверите дали WashData ги препознава правилно – совпаѓањето ќе се подобрува додека вашиот уред ја гради сопствената историја." + }, + "phase2": { + "cluster": "WashData видел {count} циклуси кои не одговараат на ниедна зачувана програма – меѓусебно си наликуваат. Сакате ли за нив да создадете нов профил?", + "unmatched": "Вашиот последен циклус не одговараше на ниедна зачувана програма. Дали е тоа нова програма?" + }, + "phase3": { + "suggestions": "WashData има препораки за поставки засновани на историјата на вашите циклуси – прегледајте ги за подобрување на точноста на откривање.", + "groups": "Некои од вашите профили изгледаат како иста програма на различни температури. Организирајте ги во група за подобро совпаѓање.", + "phases": "Додадете фази на програмата кон {profile_name} за попрецизни проценки на преостанатото време." + }, + "phase4": { + "healthy": "Овој уред е целосно поставен ({profile_count} профили)." + }, + "cta": { + "start_recording": "Започни снимање", + "label_detected_cycle": "Веќе имам откриен циклус – означи го наместо тоа", + "browse_cycles": "Прелистај циклуси за означување на следниот", + "view_profiles": "Погледни ги вашите профили", + "create_from_cluster": "Создај профил од овие циклуси", + "create_profile": "Создај за него профил", + "review_suggestions": "Прегледај препораки", + "organise_profiles": "Организирај профили", + "configure_phases": "Конфигурирај фази", + "skip_step": "Прескокни го овој чекор", + "skip_forever": "Не прикажувај повеќе", + "hide_guidance": "Скриј насоки", + "show_guidance": "Прикажи насоки" } } } diff --git a/custom_components/ha_washdata/translations/panel/nb.json b/custom_components/ha_washdata/translations/panel/nb.json index a277daf..6e50a8a 100644 --- a/custom_components/ha_washdata/translations/panel/nb.json +++ b/custom_components/ha_washdata/translations/panel/nb.json @@ -87,7 +87,7 @@ "on_cycle_finished": "På syklus ferdig", "on_cycle_started": "På syklus startet", "pause_cycle": "Pause", - "pause_cycle_tip": "Sett den kjørende syklusen på pause — apparatet fortsetter der det slapp", + "pause_cycle_tip": "Sett den kjørende syklusen på pause – apparatet fortsetter der det slapp", "pg_apply_device": "Bruk på enhet", "pg_autofill": "Autofyll", "play": "Spill av", @@ -97,8 +97,8 @@ "process_tip": "Lagre det innspilte sporet som en ny eller eksisterende profil", "rebuild": "Gjenoppbygg konvolutter", "rebuild_envelope": "Gjenoppbygg konvolutt", - "rebuild_tip": "Beregn den forventede effektkonvolutten (min/maks-bånd) for alle profiler fra deres merkede sykluser på nytt — kjør etter merking av nye sykluser eller korrigering av gamle", - "rec_start_tip": "Begynn å spille inn apparatets effektspor — start rett før du kjører en syklus", + "rebuild_tip": "Beregn den forventede effektkonvolutten (min/maks-bånd) for alle profiler fra deres merkede sykluser på nytt – kjør etter merking av nye sykluser eller korrigering av gamle", + "rec_start_tip": "Begynn å spille inn apparatets effektspor – start rett før du kjører en syklus", "rec_stop": "Stopp", "rec_stop_tip": "Stopp opptaket og behold det fangede sporet for gjennomgang", "record": "Start opptak", @@ -231,7 +231,7 @@ "interval": "Bør være minst 2x samplingsintervallet ({si} s)", "sampling": "Samplingsintervall bør være høyst halvparten av vaktbikkjeintervallet ({wi} s)" }, - "settings_banner": "{n} innstillingskonflikt{s} — sjekk de fremhevede seksjonene og rett dem opp før du lagrer.", + "settings_banner": "{n} innstillingskonflikt{s} – sjekk de fremhevede seksjonene og rett dem opp før du lagrer.", "settings_banner_btn": "Gå til første" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Vis forventet kurveoverlegg (matchet profil, oransje)", "show_raw": "Vis veksler for råe stikkontaktverdier i sanntids effektgraf", "sparkline": "Nylig trend for syklusvarighet", - "stage2": "Trinn 2 — kjernelikhet", - "stage3": "Trinn 3 — DTW", - "stage4": "Trinn 4 — samsvar", + "stage2": "Trinn 2 – kjernelikhet", + "stage3": "Trinn 3 – DTW", + "stage4": "Trinn 4 – samsvar", "status": "Status", "tail_trim": "Beskjær slutt (s)", "timer_auto_pause": "Auto-pause", @@ -561,7 +561,12 @@ "flags": "Flagg", "include_phase_map": "Inkluder fasekart", "include_settings": "Inkluder deteksjons- og matchinnstillinger", - "show_contributor": "Vis bidragsyternavn" + "show_contributor": "Vis bidragsyternavn", + "task_pg_detail": "Simuler syklus", + "task_split": "Deler opp syklus", + "task_trim": "Beskjærer syklus", + "task_merge": "Slår sammen sykluser", + "task_rebuild": "Bygger opp envelopes på nytt" }, "log": { "all_levels": "Alle nivåer", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Falt under det vanlige effektbåndet i ~{n}s.", "artifact_footer": "Fremhevet på grafen over. Dette er forbigående artefakter (f.eks. døren åpnet midt i syklusen), ikke nødvendigvis problemer.", "artifact_header": "{n} uregelmessigheter oppdaget i løpet av denne syklusen", - "artifact_pause_detail": "Effekten falt til nær null i ~{n}s og gjenopptok deretter — sannsynligvis ble døren åpnet midt i syklusen eller syklusen ble satt på pause.", + "artifact_pause_detail": "Effekten falt til nær null i ~{n}s og gjenopptok deretter – sannsynligvis ble døren åpnet midt i syklusen eller syklusen ble satt på pause.", "artifact_spike_detail": "Gikk over det vanlige effektbåndet i ~{n}s.", "auto_label_intro": "Tilordne profiler til umerkede sykluser hvis matchkonfidens klarerer terskelen.", "automations_intro": "WashData utløser {start} / {end}-hendelser og gjør entiteter tilgjengelige, slik at varsler og handlinger best bygges som vanlige Home Assistant-automatiseringer. Automatiseringer som bruker denne enheten, vises nedenfor.", @@ -654,10 +659,10 @@ "collecting_data": "Samler inn data: {need} syklus{plural} til før finjustering kan starte ({current}/{min}).", "compare_overlay_profiles": "Overleggsprofiler (svak)", "compare_profiles_tip": "Legg over andre profilkonvolutter på diagrammet ovenfor for å se hvilken som passer best til denne syklusen.", - "compare_selected_cycles": "Valgte sykluser (fast) — vis / skjul", + "compare_selected_cycles": "Valgte sykluser (fast) – vis / skjul", "consider_new_profile": "Vurder å opprette en ny profil.", "coverage_gap": "nylige sykluser har ingen samsvarende profil ({pct}% av siste 30).", - "coverage_gap_similar_cycles": "{count} like umerkede sykluser funnet — opprett en profil for å begynne å matche dem.", + "coverage_gap_similar_cycles": "{count} like umerkede sykluser funnet – opprett en profil for å begynne å matche dem.", "cycles_deleted": "{count} syklus(er) slettet", "enough_data": "Nok data til å lære av ({current}/{min} sykluser).", "export_description": "Eksporter alle profiler og sykluser til JSON, eller gjenopprett fra en tidligere eksport.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modeller finjustert til denne maskinen.", "ml_loading": "laster inn ML...", "ml_settings_intro": "To uavhengige brytere: den ene bruker modellene mens en syklus kjører, den andre lar WashData finjustere dem til maskinen din over tid.", - "name_first_program": "Du har nok sykluser — gi det første programmet et navn for å starte samsvarssøket.", + "name_first_program": "Du har nok sykluser – gi det første programmet et navn for å starte samsvarssøket.", "near_duplicate_cluster": "nesten duplikatprofilklynge oppdaget. Gruppering lar matching pålitelig velge mellom like-like (f.eks. samme program ved forskjellig temperatur/spinn).", "no_cycles_match": "Ingen sykluser samsvarer med gjeldende filter.", "no_cycles_profile": "Ingen sykluser for denne profilen.", @@ -713,7 +718,7 @@ "notify_services_hint": "Bruk {entity}-tjeneste-ID-er (kommaseparert for flere). Malvariabler: {vars}.", "old_actions_warning": "Konfigurert med den gamle handlingsredigereren (nå fjernet). De utløses fortsatt ved syklushendelser, men kan ikke lenger redigeres her. Konverter dem til en vanlig automatisering, eller fjern dem.", "onboarding_progress": "{n} / 3 sykluser observert", - "onboarding_watching": "Bruk apparatet som normalt — WashData følger med. Etter 3 sykluser starter programsamsvaret.", + "onboarding_watching": "Bruk apparatet som normalt – WashData følger med. Etter 3 sykluser starter programsamsvaret.", "pending_feedback": "Venter på tilbakemelding på gjenkjenning", "performance_trend": "Ytelsestrend ({n} sykluser)", "pg_analysis_empty": "Last inn en syklus for å se treffanalyse.", @@ -763,7 +768,7 @@ "setting_changed": "Endret fra {old} til {new} den {date}", "settings_basic_note": "Viser de viktigste innstillingene. Bytt til Avansert for hele listen.", "shape_drift_advisory": "⚠ Formavvik", - "shape_drift_detail": "Effektmønsteret for denne profilen har endret seg over tid — mulig slitasje på apparatet eller vedlikehold nødvendig (f.eks. avkalking, filterrengjøring).", + "shape_drift_detail": "Effektmønsteret for denne profilen har endret seg over tid – mulig slitasje på apparatet eller vedlikehold nødvendig (f.eks. avkalking, filterrengjøring).", "show_all_settings": "Vis alle innstillinger", "showing_suggestions": "Viser {count}-innstilling med forslag.", "split_intro": "Klikk på grafen for å legge til eller fjerne et delt punkt, eller automatisk oppdage ved tomgangshull. Hvert resulterende segment kan få sin egen profil.", @@ -857,7 +862,9 @@ "share_guidelines_title": "Før du deler", "store_download_device_intro": "Ta over alle delte programmer og deres referansesykluser til apparatet ditt. Dine egne innspilte sykluser og statistikk påvirkes ikke.", "store_share_device_intro": "Last opp {brand} {model} med referansesyklusene du velger. Andre med samme apparat kan ta i bruk programmene dine. Bidrag gjennomgås før de vises offentlig.", - "share_profile_no_cycles": "Ingen referansesykluser -- marker en syklus som ⭐ under fanen Sykluser for å inkludere denne profilen" + "share_profile_no_cycles": "Ingen referansesykluser -- marker en syklus som ⭐ under fanen Sykluser for å inkludere denne profilen", + "advisory_phase_inconsistent": "'{name}' ser ut til å blande ulike programmer eller temperaturer - syklusene varmer opp i svært ulike tidsrom. Hvis du deler den opp i separate profiler (f.eks. per temperatur), blir samsvar og tidsestimater bedre.", + "advisory_phase_inconsistent_title": "⚠ Muligens blandede programmer" }, "pg_desc": { "abrupt_drop_watts": "Plutselig fall behandlet som umiddelbar slutt", @@ -869,12 +876,19 @@ "start_duration_threshold": "Sekunder over terskelen for å bekrefte start", "start_threshold_w": "Minimum watt for å telle som startet", "stop_threshold_w": "Under dette telles maskinen som av", - "dtw_bandwidth": "Hvor mye tidsforvrengning formgjenkjenningen tillater", - "corr_weight": "Vekt på kurveform kontra effektnivå i gjenkjenningen", - "duration_weight": "Hvor mye samsvar i kjøretid påvirker gjenkjenningen", - "energy_weight": "Hvor mye samsvar i energiforbruk påvirker gjenkjenningen", - "profile_match_min_duration_ratio": "Korteste kjøring (i forhold til profilen) som fortsatt kan matche", - "profile_match_max_duration_ratio": "Lengste kjøring (i forhold til profilen) som fortsatt kan matche" + "dtw_bandwidth": "Trinn 3: Sakoe-Chiba-tidsfordreiningsband (0 = DTW av; standard 0,2 = 20% av sykellengde)", + "corr_weight": "Trinn 2: balanse mellom kurvform (korrelasjon) og effektnivå (MAE); standard 0,45", + "duration_weight": "Trinn 4: hvor sterkt varighetsoverensstemmelse påvirker sluttscoren (standard 0,22)", + "energy_weight": "Trinn 4: hvor sterkt energioverensstemmelse påvirker sluttscoren (standard 0,22)", + "profile_match_min_duration_ratio": "Trinn 1: korteste sykel som andel av profilvarighet; standard 0,1 (10%)", + "profile_match_max_duration_ratio": "Trinn 1: lengste sykel som andel av profilvarighet; standard 1,5 (150%)", + "keep_min_score": "Trinn 2: laveste poeng for å bli i matchen; standard 0,1 tillater svake kandidater, senere trinn velger best", + "dtw_blend": "Trinn 3: 0 = bare kjernepoengscore, 1 = bare DTW-score, 0,5 = lik blanding (standard)", + "dtw_ensemble_w": "Trinn 3 (ensemble-modus): vekt på skalert L1 kontra derivativ DTW; standard 0,7 foretrekker nivåbevisst", + "dtw_ddtw_scale": "Trinn 3: DDTW-halvmetningsavstand; mindre = mer formfølsom (standard 30)", + "dtw_refine_top_n": "Trinn 3: kandidater DTW beregner på nytt; øk til 7-9 hvis riktig profil rangerer 4.-5. (standard 5)", + "duration_scale": "Trinn 4: log-forhold der varighetsoverensstemmelse halveres; lavere = strengere straff (standard 0,175)", + "energy_scale": "Trinn 4: log-forhold der energioverensstemmelse halveres; lavere = strengere straff (standard 0,25)" }, "phase_desc": { "anti_crease": "Enkelte korte fall etter ferdigstillelse for å redusere rynker.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Valgfrie eksterne signaler: en slutt-utløser, en dørensor, en pausebryter og utlastingspåminnelsen.", "label": "Utløsere og dører" + }, + "phase_eta": { + "label": "Tid som gjenstår", + "intro": "Fasebevisst tid som gjenstår, for maskiner der syklusens lengde avhenger av temperatur eller sentrifugering." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Fast pris per kWh brukt for kostnadstall når ingen levende prisenhet er satt ovenfor.", "label": "Statisk energipris (per kWh)" }, + "energy_sensor": { + "doc": "Valgfri kumulativ energiteller (total_increasing i kWh/Wh, f.eks. støpselets egen levetidsmåler). Når den er angitt, hentes hver syklus' rapporterte energi fra forskjellen på denne telleren mellom start og slutt, noe som unngår undertellingen du får ved å integrere en tregt rapporterende strømsensor. Faller tilbake til den integrerte verdien hvis avlesningen mangler, enheten er ukjent, eller forskjellen ikke er positiv. La feltet stå tomt for å fortsette å integrere strømsensoren.", + "label": "Energimålerenhet" + }, "expose_debug_entities": { "doc": "Publiser ekstra diagnostiske HA-enheter (match konfidens, tvetydighet, tilstandsinternal). Av holder enhetslisten ren for normal bruk.", "label": "Vis feilsøkingsenheter" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Viser tilskrivningen 'av ' på fellesskapsapparater og referansesykluser." + }, + "enable_phase_matching": { + "label": "Fasebevisst tid som gjenstår", + "doc": "Del hver pågående syklus inn i faser (oppvarming, vask, sentrifugering) og budsjetter tiden som gjenstår per fase, blandet med det klassiske estimatet - tidlig i syklusen støtter den seg på fasebudsjettet og mot slutten på det klassiske estimatet. Dette tilpasser nedtellingen til hvor lenge maskinen din faktisk varmer opp og kjører, noe som er mest merkbart i første halvdel av en syklus. Av = bare det klassiske estimatet. Bare visningen av tid som gjenstår påvirkes; programsamsvar og syklusdeteksjon er uendret." + }, + "keep_min_score": { + "label": "Min. matchingspoeng", + "doc": "Laveste likhetspoeng en kandidat trenger for å bli i matcheprosessen. Standard 0,1 er bevisst tillåtende – det tillater selv svake kandidater og stoler på at senere trinn finner best samsvar. Øk for å luke ut usannsynlige profiler tidligere; senk for å utvide den innledende kandidatpoolen." + }, + "dtw_blend": { + "label": "DTW-blanding", + "doc": "Hvor mye DTW-justeringsscoren erstatter kjerneresultatet fra Trinn 2. 0 = bruk bare Trinn 2-scoren, 1 = bruk bare DTW-scoren, 0,5 (standard) = lik blanding. Øk for å stole mer på tidsjustering når programmer har like effektnivåer men ulike timingmønstre." + }, + "dtw_ensemble_w": { + "label": "DTW-ensemblemiks", + "doc": "I ensemble-DTW-modus (standard) er dette vekten på skalert L1 kontra derivativ DTW (DDTW). 1,0 = bare skalert L1, 0 = bare DDTW, 0,7 = standard. Den skalerte varianten sammenligner effektnivåer; den deriverte reagerer på hvordan effekten endres over tid. Ensemble blander begge." + }, + "dtw_ddtw_scale": { + "label": "Derivativ DTW-skala", + "doc": "Halvmetningsavstand for derivativ DTW-scoring. Scoren halveres når DDTW-avstanden er lik denne verdien. Lavere = mer følsom for formforskjeller. Standard 30 er kalibrert for typiske husholdningsapparaters effektprofiler. Påvirker bare ensemble- og ddtw-DTW-modi." + }, + "dtw_refine_top_n": { + "label": "DTW-forfiningsantall", + "doc": "Hvor mange toppkandidater fra Trinn 2 som beregnes på nytt av DTW. DTW er mer kostbart, så det brukes bare på de N beste. Standard 5. Øk (til 7-9) hvis riktig profil noen ganger bare når 4. eller 5. plass etter Trinn 2 – DTW kan redde den. Lavere verdi gir litt raskere matching." + }, + "duration_scale": { + "label": "Varighetsskarphet", + "doc": "Skarphet i Trinn 4 sitt straff for varighetsoverensstemmelse. Det er log-forholdet der overensstemmelsesscoren halveres. Lavere = strengere: en varighetsavvik straffes mer. Standard 0,175 tilsvarer omtrent 18% varighetstolerans ved halvvekt. Par med Varighetsvekt." + }, + "energy_scale": { + "label": "Energiskarphet", + "doc": "Skarphet i Trinn 4 sitt straff for energioverensstemmelse. Lavere = strengere: et energiavvik straffes mer. Standard 0,25 er bevisst mer tillåtende enn varighetsskarphet fordi energi varierer med last. Par med Energivekt." } }, "setting_group": { @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimaliser: {param}" + }, + "pg_detail": { + "simulate": "Simuler syklus" + }, + "split": { + "apply": "Deler opp syklus" + }, + "trim": { + "apply": "Beskjærer syklus" + }, + "merge": { + "apply": "Slår sammen sykluser" + }, + "rebuild": { + "envelopes": "Bygger opp envelopes på nytt" + }, + "cancelling": "Avbryter..." + }, + "setup": { + "hdr": { + "card": "Enhetsoppsett", + "healthy_chip": "Oppsett fullført" + }, + "phase0": { + "washer": "WashData oppdager allerede syklusene dine. Spill inn den første syklusen for å aktivere programnavn og tidsanslag.", + "dishwasher": "WashData følger med. Oppvaskmaskiner har komplekse sykluser – det anbefales på det sterkeste å spille inn den første syklusen. Hvis en oppdaget syklus kjører for lenge, bruk syklusredigereren til å trimme den før du lagrer den som en profil.", + "generic": "WashData følger med. Spill inn eller merk en oppdaget syklus for å begynne å bygge opp profiler." + }, + "phase1a": { + "labelled": "Bra start – det første programmet ditt er lagret. For de reneste dataene kan du vurdere å spille inn neste syklus med opptakswidgeten." + }, + "phase1b": { + "recorded": "Opptaket ditt ble lagret som {profile_name}. Spill nå inn eller merk de andre vanlige programmene dine for å bygge opp dekning." + }, + "phase1c": { + "verify": "Du har {count} programmer fra fellesskapet. Kjør en syklus for å bekrefte at WashData gjenkjenner den riktig – gjenkjenningen forbedres etter hvert som enheten bygger opp sin egen historikk." + }, + "phase2": { + "cluster": "WashData har sett {count} sykluser som ikke samsvarer med noe lagret program – de ligner på hverandre. Vil du opprette en ny profil for dem?", + "unmatched": "Den siste syklusen din samsvarte ikke med noe lagret program. Er det et nytt program?" + }, + "phase3": { + "suggestions": "WashData har innstillingsanbefalinger basert på syklushistorikken din – gå gjennom dem for å forbedre deteksjonsnøyaktigheten.", + "groups": "Noen av profilene dine ser ut som det samme programmet ved forskjellige temperaturer. Organiser dem i en gruppe for bedre gjenkjenning.", + "phases": "Legg til programfaser i {profile_name} for mer nøyaktige anslag på gjenværende tid." + }, + "phase4": { + "healthy": "Denne enheten er fullt konfigurert ({profile_count} profiler)." + }, + "cta": { + "start_recording": "Start opptak", + "label_detected_cycle": "Jeg har allerede en oppdaget syklus – merk den i stedet", + "browse_cycles": "Bla gjennom sykluser for å merke en til", + "view_profiles": "Vis profilene dine", + "create_from_cluster": "Opprett profil fra disse syklusene", + "create_profile": "Opprett en profil for den", + "review_suggestions": "Se gjennom anbefalinger", + "organise_profiles": "Organiser profiler", + "configure_phases": "Konfigurer faser", + "skip_step": "Hopp over dette trinnet", + "skip_forever": "Ikke vis igjen", + "hide_guidance": "Skjul veiledning", + "show_guidance": "Vis veiledning" } } } diff --git a/custom_components/ha_washdata/translations/panel/nl.json b/custom_components/ha_washdata/translations/panel/nl.json index 1ea1e6c..c83fb47 100644 --- a/custom_components/ha_washdata/translations/panel/nl.json +++ b/custom_components/ha_washdata/translations/panel/nl.json @@ -1,6 +1,6 @@ { "badge": { - "artifact_tip": "{n} afwijkingen gedetecteerd (bijvoorbeeld de deur werd halverwege de cyclus geopend) — open om ze in de grafiek te zien", + "artifact_tip": "{n} afwijkingen gedetecteerd (bijvoorbeeld de deur werd halverwege de cyclus geopend) – open om ze in de grafiek te zien", "auto": "(automatisch gedetecteerd)", "built_in_tag": "ingebouwd", "declining": "↘ afnemend", @@ -87,7 +87,7 @@ "on_cycle_finished": "Op cyclus klaar", "on_cycle_started": "Op cyclus gestart", "pause_cycle": "Pauze", - "pause_cycle_tip": "De actieve cyclus pauzeren — het apparaat hervat waar het was gestopt", + "pause_cycle_tip": "De actieve cyclus pauzeren – het apparaat hervat waar het was gestopt", "pg_apply_device": "Toepassen op apparaat", "pg_autofill": "Automatisch invullen", "play": "Afspelen", @@ -97,8 +97,8 @@ "process_tip": "Sla de geregistreerde trace op als een nieuw of bestaand profiel", "rebuild": "Enveloppen opnieuw opbouwen", "rebuild_envelope": "Envelop opnieuw opbouwen", - "rebuild_tip": "Herbereken de verwachte vermogensenvelop (min/max-band) voor alle profielen op basis van hun gelabelde cycli — voer uit na het labelen van nieuwe cycli of het corrigeren van bestaande", - "rec_start_tip": "Begin met het opnemen van het vermogensspoor van het apparaat — start vlak voor het uitvoeren van een cyclus", + "rebuild_tip": "Herbereken de verwachte vermogensenvelop (min/max-band) voor alle profielen op basis van hun gelabelde cycli – voer uit na het labelen van nieuwe cycli of het corrigeren van bestaande", + "rec_start_tip": "Begin met het opnemen van het vermogensspoor van het apparaat – start vlak voor het uitvoeren van een cyclus", "rec_stop": "Stop", "rec_stop_tip": "Stop de opname en bewaar de vastgelegde trace voor beoordeling", "record": "Begin met opnemen", @@ -231,7 +231,7 @@ "interval": "Moet minimaal 2x het bemonsteringsinterval ({si} s) zijn", "sampling": "Bemonsteringsinterval moet maximaal de helft van het watchdog-interval ({wi} s) zijn" }, - "settings_banner": "{n} instellingsconflict{s} — controleer de gemarkeerde secties en los ze op voor het opslaan.", + "settings_banner": "{n} instellingsconflict{s} – controleer de gemarkeerde secties en los ze op voor het opslaan.", "settings_banner_btn": "Ga naar eerste" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Verwachte curve-overlay weergeven (overeenkomend profiel, oranje)", "show_raw": "Toon schakelaar voor onbewerkte stopcontactwaarden in live vermogensgrafiek", "sparkline": "Recente trend van cyclusduur", - "stage2": "Stap 2 — kerngelijkenis", - "stage3": "Stap 3 — DTW", - "stage4": "Stap 4 — overeenkomst", + "stage2": "Stap 2 – kerngelijkenis", + "stage3": "Stap 3 – DTW", + "stage4": "Stap 4 – overeenkomst", "status": "Status", "tail_trim": "Einde bijsnijden (s)", "timer_auto_pause": "Automatische pauze", @@ -561,7 +561,12 @@ "flags": "Vlaggen", "include_phase_map": "Fasekaart opnemen", "include_settings": "Detectie- en matchinstellingen opnemen", - "show_contributor": "Bijdragersnamen weergeven" + "show_contributor": "Bijdragersnamen weergeven", + "task_pg_detail": "Cyclus simuleren", + "task_split": "Cyclus splitsen", + "task_trim": "Cyclus bijsnijden", + "task_merge": "Cycli samenvoegen", + "task_rebuild": "Enveloppen opnieuw opbouwen" }, "log": { "all_levels": "Alle niveaus", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Daalde onder de gebruikelijke vermogensband voor ~{n}s.", "artifact_footer": "Gemarkeerd in de bovenstaande grafiek. Dit zijn tijdelijke artefacten (bijvoorbeeld de deur die halverwege de cyclus wordt geopend), en niet noodzakelijkerwijs problemen.", "artifact_header": "{n} afwijkingen gedetecteerd tijdens deze cyclus", - "artifact_pause_detail": "Vermogen daalde tot bijna nul voor ~{n}s en hervatte vervolgens — waarschijnlijk werd de deur halverwege de cyclus geopend of de cyclus gepauzeerd.", + "artifact_pause_detail": "Vermogen daalde tot bijna nul voor ~{n}s en hervatte vervolgens – waarschijnlijk werd de deur halverwege de cyclus geopend of de cyclus gepauzeerd.", "artifact_spike_detail": "Trad boven de gebruikelijke vermogensband voor ~{n}s.", "auto_label_intro": "Wijs profielen toe aan niet-gelabelde cycli waarvan het matchvertrouwen de drempel overschrijdt.", "automations_intro": "WashData activeert {start} / {end}-gebeurtenissen en stelt entiteiten beschikbaar, waardoor meldingen en acties het best als gewone Home Assistant-automatiseringen worden gebouwd. Automatiseringen die dit apparaat gebruiken, worden hieronder weergegeven.", @@ -654,10 +659,10 @@ "collecting_data": "Gegevens worden verzameld: nog {need} cyclus{plural} voordat verfijning kan beginnen ({current}/{min}).", "compare_overlay_profiles": "Overlay-profielen (vaag)", "compare_profiles_tip": "Leg andere profielenveloppen op het schema hierboven om te zien welke het beste bij deze cyclus past.", - "compare_selected_cycles": "Geselecteerde cycli (vast) — tonen/verbergen", + "compare_selected_cycles": "Geselecteerde cycli (vast) – tonen/verbergen", "consider_new_profile": "Overweeg een nieuw profiel aan te maken.", "coverage_gap": "recente cycli hebben geen overeenkomend profiel ({pct}% van de laatste 30).", - "coverage_gap_similar_cycles": "{count} vergelijkbare niet-gelabelde cycli gevonden — maak een profiel aan om ze te beginnen matchen.", + "coverage_gap_similar_cycles": "{count} vergelijkbare niet-gelabelde cycli gevonden – maak een profiel aan om ze te beginnen matchen.", "cycles_deleted": "{count} cyclus(sen) verwijderd", "enough_data": "Voldoende gegevens om van te leren ({current}/{min} cycli).", "export_description": "Exporteer alle profielen en cycli naar JSON, of herstel vanuit een eerdere export.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modellen die op deze machine zijn afgestemd.", "ml_loading": "ML laden...", "ml_settings_intro": "Twee onafhankelijke schakelaars: de ene past de modellen toe terwijl een cyclus loopt, de andere laat WashData ze in de loop van de tijd nauwkeurig afstemmen op uw machine.", - "name_first_program": "Je hebt genoeg cycli — geef je eerste programma een naam om te beginnen met matchen.", + "name_first_program": "Je hebt genoeg cycli – geef je eerste programma een naam om te beginnen met matchen.", "near_duplicate_cluster": "bijna dubbel profielcluster gedetecteerd. Door te groeperen kan er op betrouwbare wijze worden gekozen tussen look-alikes (bijvoorbeeld hetzelfde programma op verschillende temperatuur/centrifugeren).", "no_cycles_match": "Er zijn geen cycli die overeenkomen met het huidige filter.", "no_cycles_profile": "Geen cycli voor dit profiel.", @@ -713,7 +718,7 @@ "notify_services_hint": "Gebruik {entity}-service-ID's (kommagescheiden voor meerdere). Sjabloonvariabelen: {vars}.", "old_actions_warning": "Geconfigureerd met de oude actie-editor (nu verwijderd). Ze vuren nog steeds op cyclusevenementen, maar kunnen hier niet langer worden bewerkt. Converteer ze naar een normale automatisering of verwijder ze.", "onboarding_progress": "{n} / 3 cycli waargenomen", - "onboarding_watching": "Gebruik je apparaat normaal — WashData kijkt mee. Na 3 cycli begint het matchen van programma's.", + "onboarding_watching": "Gebruik je apparaat normaal – WashData kijkt mee. Na 3 cycli begint het matchen van programma's.", "pending_feedback": "In afwachting van detectiefeedback", "performance_trend": "Prestatietrend ({n} cycli)", "pg_analysis_empty": "Laad een cyclus om de matchanalyse te bekijken.", @@ -763,7 +768,7 @@ "setting_changed": "Gewijzigd van {old} naar {new} op {date}", "settings_basic_note": "Essentiële instellingen worden getoond. Schakel over naar Geavanceerd voor de volledige lijst.", "shape_drift_advisory": "⚠ Vormafwijking", - "shape_drift_detail": "Het vermogenspatroon voor dit profiel is in de loop van de tijd verschoven — mogelijk slijtage van het apparaat of onderhoud nodig (bijv. ontkalken, filterreiniging).", + "shape_drift_detail": "Het vermogenspatroon voor dit profiel is in de loop van de tijd verschoven – mogelijk slijtage van het apparaat of onderhoud nodig (bijv. ontkalken, filterreiniging).", "show_all_settings": "Toon alle instellingen", "showing_suggestions": "Instelling {count} met suggesties weergegeven.", "split_intro": "Klik op de grafiek om een ​​splitspunt toe te voegen of te verwijderen, of om automatisch gaten in de grafiek te detecteren. Elk resulterend segment kan zijn eigen profiel krijgen.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Terugzetten mislukt: {error}", "toast_reverted": "{key} teruggezet", "toast_save_failed": "Opslaan mislukt: {error}", - "trend_duration_longer": "Duur trending langer ({pct}%/cyclus) — recent gemiddelde {avg}", - "trend_duration_shorter": "Duur trending korter ({pct}%/cyclus) — recent gemiddelde {avg}", + "trend_duration_longer": "Duur trending langer ({pct}%/cyclus) – recent gemiddelde {avg}", + "trend_duration_shorter": "Duur trending korter ({pct}%/cyclus) – recent gemiddelde {avg}", "trend_energy_down": "Energietrend omlaag ({pct}%/cyclus)", - "trend_energy_up": "Energie stijgt ({pct}%/cyclus) — recent gemiddelde {avg}", + "trend_energy_up": "Energie stijgt ({pct}%/cyclus) – recent gemiddelde {avg}", "trim_intro": "Versleep de rode grepen of voer waarden in. Alles buiten het raam wordt verwijderd.", "tuning_suggestions_available": "{count} afstemmingssuggestie beschikbaar op basis van waargenomen cycli. Ze verschijnen naast de relevante velden.", "unsure_detected_prefix": "WashData weet niet zeker of het is gedetecteerd", @@ -857,7 +862,9 @@ "share_guidelines_title": "Voordat u deelt", "store_download_device_intro": "Neem elk gedeeld programma en zijn referentiecycli over op uw apparaat. Uw eigen opgenomen cycli en statistieken worden niet beïnvloed.", "store_share_device_intro": "Upload {brand} {model} met de referentiecycli die u selecteert. Anderen met hetzelfde apparaat kunnen uw programma's overnemen. Vermeldingen worden beoordeeld voordat ze openbaar verschijnen.", - "share_profile_no_cycles": "Geen referentiecycli -- markeer een cyclus als ⭐ op het tabblad Cycli om dit profiel op te nemen" + "share_profile_no_cycles": "Geen referentiecycli -- markeer een cyclus als ⭐ op het tabblad Cycli om dit profiel op te nemen", + "advisory_phase_inconsistent": "'{name}' lijkt verschillende programma's of temperaturen te combineren - de cycli verwarmen gedurende sterk uiteenlopende tijden. Door het op te splitsen in aparte profielen (bijv. per temperatuur) verbeteren de matching en tijdschattingen.", + "advisory_phase_inconsistent_title": "⚠ Mogelijk gemengde programma's" }, "pg_desc": { "abrupt_drop_watts": "Plotselinge daling behandeld als onmiddellijk einde", @@ -869,12 +876,19 @@ "start_duration_threshold": "Seconden boven de drempel om de start te bevestigen", "start_threshold_w": "Minimaal aantal watt om als gestart te tellen", "stop_threshold_w": "Hieronder telt de machine als uit", - "dtw_bandwidth": "Hoeveel tijdvervorming de vormherkenning toestaat", - "corr_weight": "Gewicht van curvevorm versus vermogensniveau in de herkenning", - "duration_weight": "Hoeveel overeenkomst in looptijd de herkenning beïnvloedt", - "energy_weight": "Hoeveel overeenkomst in energieverbruik de herkenning beïnvloedt", - "profile_match_min_duration_ratio": "Kortste cyclus (t.o.v. het profiel) die nog mag matchen", - "profile_match_max_duration_ratio": "Langste cyclus (t.o.v. het profiel) die nog mag matchen" + "dtw_bandwidth": "Stap 3: Sakoe-Chiba-vervormingsband (0 = DTW uit; standaard 0,2 = 20% van de cycluslengte)", + "corr_weight": "Stap 2: balans tussen curvevorm (correlatie) en vermogensniveau (MAE); standaard 0,45", + "duration_weight": "Stap 4: hoe sterk de duurovereenkomst de eindscore beïnvloedt (standaard 0,22)", + "energy_weight": "Stap 4: hoe sterk de energieovereenkomst de eindscore beïnvloedt (standaard 0,22)", + "profile_match_min_duration_ratio": "Stap 1: minimale cycluslengte als fractie van de profielduur; standaard 0,1 (10%)", + "profile_match_max_duration_ratio": "Stap 1: maximale cycluslengte als fractie van de profielduur; standaard 1,5 (150%)", + "keep_min_score": "Stap 2: minimale score om in de race te blijven; standaard 0,1 laat zwakke kandidaten toe, latere stappen kiezen de beste", + "dtw_blend": "Stap 3: 0 = alleen kernscore, 1 = alleen DTW-score, 0,5 = gelijke mix (standaard)", + "dtw_ensemble_w": "Stap 3 (ensemble-modus): gewicht op geschaalde L1 vs afgeleid DTW; standaard 0,7 geeft voorkeur aan niveauafhankelijke herkenning", + "dtw_ddtw_scale": "Stap 3: DDTW-halfsaturatieafstand; kleiner = gevoeliger voor vorm (standaard 30)", + "dtw_refine_top_n": "Stap 3: kandidaten die door DTW opnieuw beoordeeld worden; verhoog naar 7-9 als het juiste profiel op de 4e-5e plaats staat (standaard 5)", + "duration_scale": "Stap 4: log-verhouding waarbij de duurovereenkomst halveert; kleiner = strengere straf (standaard 0,175)", + "energy_scale": "Stap 4: log-verhouding waarbij de energieovereenkomst halveert; kleiner = strengere straf (standaard 0,25)" }, "phase_desc": { "anti_crease": "Af en toe kort tuimelen na voltooiing om rimpels te verminderen.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Optionele externe signalen: een eindtrigger, een deursensor, een pauzeknop en de uitlaadherinnering.", "label": "Triggers & Deuren" + }, + "phase_eta": { + "label": "Resterende tijd", + "intro": "Fasebewuste resterende tijd, voor machines waarvan de cyclusduur afhangt van temperatuur of centrifugeren." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Vaste prijs per kWh gebruikt voor kostencijfers wanneer er hierboven geen live prijsentiteit is ingesteld.", "label": "Vaste energieprijs (per kWh)" }, + "energy_sensor": { + "doc": "Optionele cumulatieve energieteller (total_increasing in kWh/Wh, bijvoorbeeld de eigen levensduurteller van de stekker). Indien ingesteld wordt de gerapporteerde energie van elke cyclus genomen uit het verschil van deze teller tussen begin en einde, wat de onderregistratie voorkomt die je krijgt bij het integreren van een traag rapporterende vermogenssensor. Valt terug op de geïntegreerde waarde als de meting ontbreekt, de eenheid onbekend is of het verschil niet positief is. Laat leeg om de vermogenssensor te blijven integreren.", + "label": "Energiemeterentiteit" + }, "expose_debug_entities": { "doc": "Publiceer extra diagnostische HA-entiteiten (matchvertrouwen, ambiguïteit, staatsinternals). Uit houdt de entiteitenlijst schoon voor normaal gebruik.", "label": "Foutopsporingsentiteiten blootstellen" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Toont de vermelding 'door ' op community-apparaten en referentiecycli." + }, + "enable_phase_matching": { + "label": "Fasebewuste resterende tijd", + "doc": "Verdeel elke lopende cyclus in fasen (verwarmen, wassen, centrifugeren) en begroot de resterende tijd per fase, gemengd met de klassieke schatting - vroeg in de cyclus leunend op het fasebudget en tegen het einde op de klassieke schatting. Dit stemt het aftellen af op hoe lang uw machine daadwerkelijk verwarmt en draait, wat het meest merkbaar is in de eerste helft van een cyclus. Uit = alleen de klassieke schatting. Alleen de weergave van de resterende tijd wordt beïnvloed; programma-matching en cyclusdetectie blijven ongewijzigd." + }, + "keep_min_score": { + "label": "Min. matchscore", + "doc": "Minimale gelijkenisscore die een kandidaat nodig heeft om in de race te blijven. De standaard 0,1 is bewust permissief - het laat zelfs zwakke kandidaten toe en vertrouwt op latere stappen om de beste overeenkomst te vinden. Verhoog het om onwaarschijnlijke profielen eerder te verwijderen; verlaag het om de initiële kandidatenpool te verbreden." + }, + "dtw_blend": { + "label": "Warp-mengfactor", + "doc": "Hoeveel de tijdvervormingsscore (DTW) de kernscore van stap 2 vervangt. 0 = gebruik alleen de stap 2-score, 1 = gebruik alleen de DTW-score, 0,5 (standaard) = gelijke mix. Verhoog het om meer te vertrouwen op warp-uitlijning wanneer programma's vergelijkbare vermogensniveaus maar verschillende tijdpatronen hebben." + }, + "dtw_ensemble_w": { + "label": "Warp-ensemblemix", + "doc": "In ensemble DTW-modus (de standaard), het gewicht op geschaalde L1 versus afgeleid DTW (DDTW). 1,0 = alleen geschaalde L1, 0 = alleen DDTW, 0,7 = standaard. De geschaalde L1-variant vergelijkt vermogensniveaus; de afgeleide variant reageert op hoe vermogen verandert over de tijd. Ensemble mengt beide." + }, + "dtw_ddtw_scale": { + "label": "Afgeleide warp-schaal", + "doc": "Halfsaturatieafstand voor de afgeleide DTW-beoordeling. De score halveert wanneer de DDTW-afstand gelijk is aan deze waarde. Kleiner = gevoeliger voor vormverschillen. Standaard 30 is gekalibreerd op typische vermogenssporen van apparaten. Beïnvloedt alleen ensemble- en ddtw DTW-modi." + }, + "dtw_refine_top_n": { + "label": "Warp-verfijningsaantal", + "doc": "Hoeveel top stap 2-kandidaten opnieuw worden beoordeeld door DTW. DTW is duurder en wordt daarom alleen toegepast op de beste N kandidaten. Standaard 5. Verhoog het (naar 7-9) als het juiste profiel na stap 2 soms alleen de 4e of 5e plaats bereikt - DTW kan het redden. Verlagen versnelt het matchen iets." + }, + "duration_scale": { + "label": "Duur-scherpte", + "doc": "Scherpte van de duurovereenkomststraf in stap 4. Dit is de log-verhouding waarbij de overeenkomstscore halveert. Kleiner = strenger: een duurafwijking kost meer punten. Standaard 0,175 komt overeen met ongeveer 18% duurtolerантie op half gewicht. Combineer met Duurgewicht." + }, + "energy_scale": { + "label": "Energie-scherpte", + "doc": "Scherpte van de energieovereenkomststraf. Kleiner = strenger: een energiemismatch kost meer punten. Standaard 0,25 is bewust meer vergevingsgezind dan Duurscherpte, omdat energie varieert met de belasting. Combineer met Energiegewicht." } }, "setting_group": { @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimaliseren: {param}" + }, + "pg_detail": { + "simulate": "Cyclus simuleren" + }, + "split": { + "apply": "Cyclus splitsen" + }, + "trim": { + "apply": "Cyclus bijsnijden" + }, + "merge": { + "apply": "Cycli samenvoegen" + }, + "rebuild": { + "envelopes": "Enveloppen opnieuw opbouwen" + }, + "cancelling": "Annuleren..." + }, + "setup": { + "hdr": { + "card": "Apparaat instellen", + "healthy_chip": "Instelling voltooid" + }, + "phase0": { + "washer": "WashData detecteert uw cycli al. Neem uw eerste cyclus op om programmanamen en tijdschattingen in te schakelen.", + "dishwasher": "WashData kijkt mee. Vaatwassers hebben complexe cycli – het opnemen van uw eerste cyclus wordt sterk aanbevolen. Als een gedetecteerde cyclus te lang duurt, gebruik dan de cycluseditor om hem bij te snijden vóór het opslaan als profiel.", + "generic": "WashData kijkt mee. Neem een cyclus op of label een gedetecteerde cyclus om te beginnen met het opbouwen van profielen." + }, + "phase1a": { + "labelled": "Goed begin – uw eerste programma is opgeslagen. Voor de zuiverste gegevens kunt u uw volgende cyclus opnemen met de recorder-widget." + }, + "phase1b": { + "recorded": "Uw opname is opgeslagen als {profile_name}. Neem nu uw andere veelgebruikte programma's op of label ze om de dekking uit te breiden." + }, + "phase1c": { + "verify": "U heeft {count} programma's van de community. Voer een cyclus uit om te verifiëren dat WashData deze correct herkent – de herkenning verbetert naarmate uw apparaat zijn eigen geschiedenis opbouwt." + }, + "phase2": { + "cluster": "WashData heeft {count} cycli gezien die niet overeenkomen met een opgeslagen programma – ze lijken op elkaar. Wilt u een nieuw profiel voor ze aanmaken?", + "unmatched": "Uw laatste cyclus kwam niet overeen met een opgeslagen programma. Is het een nieuw programma?" + }, + "phase3": { + "suggestions": "WashData heeft instellingsaanbevelingen op basis van uw cyclusgeschiedenis – bekijk ze om de detectienauwkeurigheid te verbeteren.", + "groups": "Sommige van uw profielen lijken op hetzelfde programma bij verschillende temperaturen. Organiseer ze in een groep voor betere herkenning.", + "phases": "Voeg programmasfases toe aan {profile_name} voor nauwkeurigere schattingen van de resterende tijd." + }, + "phase4": { + "healthy": "Dit apparaat is volledig ingesteld ({profile_count} profielen)." + }, + "cta": { + "start_recording": "Opname starten", + "label_detected_cycle": "Ik heb al een gedetecteerde cyclus – label die in plaats daarvan", + "browse_cycles": "Cycli bekijken om een andere te labelen", + "view_profiles": "Uw profielen bekijken", + "create_from_cluster": "Profiel maken van deze cycli", + "create_profile": "Maak er een profiel voor", + "review_suggestions": "Aanbevelingen bekijken", + "organise_profiles": "Profielen organiseren", + "configure_phases": "Fasen configureren", + "skip_step": "Sla deze stap over", + "skip_forever": "Niet meer weergeven", + "hide_guidance": "Begeleiding verbergen", + "show_guidance": "Begeleiding weergeven" } } } diff --git a/custom_components/ha_washdata/translations/panel/pl.json b/custom_components/ha_washdata/translations/panel/pl.json index f379833..9740ca7 100644 --- a/custom_components/ha_washdata/translations/panel/pl.json +++ b/custom_components/ha_washdata/translations/panel/pl.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "Wykryto {n} anomalii (np. drzwi otwarte w połowie cyklu) — otwórz, aby zobaczyć je na wykresie", + "artifact_tip": "Wykryto {n} anomalii (np. drzwi otwarte w połowie cyklu) – otwórz, aby zobaczyć je na wykresie", "auto": "(automatycznie wykryty)", "built_in_tag": "wbudowany", "declining": "↘ maleje", "energy_low": "Niższe zużycie energii niż zwykle", "energy_spike": "Wyższe zużycie energii niż zwykle", "fair_fit": "akceptowalne dopasowanie", - "fair_fit_tip": "Umiarkowana spójność dopasowania — niektóre cykle przypisane do tego profilu mają niższy poziom pewności. Oznacz więcej cykli lub ponownie zapisz profil, aby poprawić dokładność.", + "fair_fit_tip": "Umiarkowana spójność dopasowania – niektóre cykle przypisane do tego profilu mają niższy poziom pewności. Oznacz więcej cykli lub ponownie zapisz profil, aby poprawić dokładność.", "feedback_requested": "Poproszono o opinię", "golden_cycle": "Zarejestrowany cykl referencyjny", "improving": "↗ poprawa", @@ -57,7 +57,7 @@ "create_profile": "+ Utwórz profil", "delete": "Usuń", "delete_group": "Usuń grupę", - "delete_group_tip": "Usuń tylko tę grupę — profile członków zostaną zachowane", + "delete_group_tip": "Usuń tylko tę grupę – profile członków zostaną zachowane", "delete_profile": "Usuń profil", "discard": "Odrzuć", "discard_tip": "Odrzuć nagranie bez zapisywania", @@ -87,7 +87,7 @@ "on_cycle_finished": "Po zakończeniu cyklu", "on_cycle_started": "Po rozpoczęciu cyklu", "pause_cycle": "Pauza", - "pause_cycle_tip": "Wstrzymaj bieżący cykl — urządzenie wznowi pracę od miejsca, w którym zostało zatrzymane", + "pause_cycle_tip": "Wstrzymaj bieżący cykl – urządzenie wznowi pracę od miejsca, w którym zostało zatrzymane", "pg_apply_device": "Zastosuj do urządzenia", "pg_autofill": "Wypełnij automatycznie", "play": "Odtwórz", @@ -97,8 +97,8 @@ "process_tip": "Zapisz zarejestrowany przebieg jako nowy lub istniejący profil", "rebuild": "Odbuduj koperty", "rebuild_envelope": "Odbuduj kopertę", - "rebuild_tip": "Przelicz oczekiwaną obwiednię mocy (pasmo min/maks) dla wszystkich profili z ich oznakowanych cykli — uruchom po oznaczeniu nowych cykli lub korekcie starych", - "rec_start_tip": "Rozpocznij rejestrowanie przebiegu mocy urządzenia — uruchom tuż przed rozpoczęciem cyklu", + "rebuild_tip": "Przelicz oczekiwaną obwiednię mocy (pasmo min/maks) dla wszystkich profili z ich oznakowanych cykli – uruchom po oznaczeniu nowych cykli lub korekcie starych", + "rec_start_tip": "Rozpocznij rejestrowanie przebiegu mocy urządzenia – uruchom tuż przed rozpoczęciem cyklu", "rec_stop": "Zatrzymaj", "rec_stop_tip": "Zatrzymaj nagrywanie i zachowaj zarejestrowany przebieg do przeglądu", "record": "Rozpocznij nagrywanie", @@ -231,7 +231,7 @@ "interval": "Powinien wynosić co najmniej 2× Interwał Próbkowania ({si} s)", "sampling": "Interwał Próbkowania powinien wynosić co najwyżej połowę Interwału Watchdog ({wi} s)" }, - "settings_banner": "{n} konflikt{s} ustawień — sprawdź podświetlone sekcje i popraw przed zapisaniem.", + "settings_banner": "{n} konflikt{s} ustawień – sprawdź podświetlone sekcje i popraw przed zapisaniem.", "settings_banner_btn": "Przejdź do pierwszego" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Pokaż oczekiwaną nakładkę krzywej (dopasowany profil, pomarańczowy)", "show_raw": "Pokaż przełącznik surowych odczytów gniazdka na wykresie mocy na żywo", "sparkline": "Ostatni trend czasu trwania cykli", - "stage2": "Etap 2 — podstawowe podobieństwo", - "stage3": "Etap 3 — DTW", - "stage4": "Etap 4 — zgodność", + "stage2": "Etap 2 – podstawowe podobieństwo", + "stage3": "Etap 3 – DTW", + "stage4": "Etap 4 – zgodność", "status": "Status", "tail_trim": "Trymowanie ogona", "timer_auto_pause": "Automatyczna pauza", @@ -561,7 +561,12 @@ "flags": "Flagi", "include_phase_map": "Dołącz mapę faz", "include_settings": "Dołącz ustawienia", - "show_contributor": "Pokaż współtwórcę" + "show_contributor": "Pokaż współtwórcę", + "task_pg_detail": "Symuluj cykl", + "task_split": "Dzielenie cyklu", + "task_trim": "Przycinanie cyklu", + "task_merge": "Scalanie cykli", + "task_rebuild": "Przeliczanie obwiedni" }, "log": { "all_levels": "Wszystkie poziomy", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Moc spadła poniżej normalnego zakresu przez ~{n}s.", "artifact_footer": "Zaznaczono na powyższym wykresie. Są to artefakty przejściowe (np. otwarcie drzwi w połowie cyklu), niekoniecznie problemy.", "artifact_header": "W tym cyklu wykryto {n} anomalii", - "artifact_pause_detail": "Moc spadła prawie do zera przez ~{n}s, a następnie wzrosła — prawdopodobnie drzwi zostały otwarte w trakcie cyklu lub cykl był wstrzymany.", + "artifact_pause_detail": "Moc spadła prawie do zera przez ~{n}s, a następnie wzrosła – prawdopodobnie drzwi zostały otwarte w trakcie cyklu lub cykl był wstrzymany.", "artifact_spike_detail": "Moc przekroczyła normalny zakres przez ~{n}s.", "auto_label_intro": "Przypisz profile do nieoznakowanych cykli, których pewność dopasowania przekracza próg.", "automations_intro": "WashData uruchamia zdarzenia {start} / {end} i udostępnia encje, więc powiadomienia i akcje najlepiej budować jako normalne automatyzacje Home Assistant. Automatyzacje korzystające z tego urządzenia są widoczne poniżej.", @@ -654,10 +659,10 @@ "collecting_data": "Zbieranie danych: jeszcze {need} cykli do rozpoczęcia dostrajania ({current}/{min}).", "compare_overlay_profiles": "Profile nakładkowe (słabe)", "compare_profiles_tip": "Nałóż inne koperty profili na powyższy wykres, aby zobaczyć, która z nich najlepiej pasuje do tego cyklu.", - "compare_selected_cycles": "Wybrane cykle (pełne) — pokaż / ukryj", + "compare_selected_cycles": "Wybrane cykle (pełne) – pokaż / ukryj", "consider_new_profile": "Rozważ utworzenie nowego profilu.", "coverage_gap": "ostatnie cykle nie mają pasującego profilu ({pct}% z ostatnich 30).", - "coverage_gap_similar_cycles": "Znaleziono {count} podobnych nieoznaczonych cykli — utwórz profil, aby zacząć je dopasowywać.", + "coverage_gap_similar_cycles": "Znaleziono {count} podobnych nieoznaczonych cykli – utwórz profil, aby zacząć je dopasowywać.", "cycles_deleted": "Usunięto cykle: {count}", "enough_data": "Wystarczająco danych do nauki ({current}/{min} cykli).", "export_description": "Eksportuj wszystkie profile i cykle do formatu JSON lub przywróć dane z poprzedniego eksportu.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modele dopasowane do tej maszyny.", "ml_loading": "ładowanie ML…", "ml_settings_intro": "Dwa niezależne przełączniki: jeden stosuje modele w trakcie trwania cyklu, drugi pozwala WashData dostosować je do Twojej maszyny w miarę upływu czasu.", - "name_first_program": "Masz wystarczająco dużo cykli — nadaj nazwę pierwszemu programowi, aby rozpocząć dopasowywanie.", + "name_first_program": "Masz wystarczająco dużo cykli – nadaj nazwę pierwszemu programowi, aby rozpocząć dopasowywanie.", "near_duplicate_cluster": "Wykryto prawie zduplikowany klaster profili. Grupowanie umożliwia niezawodne wybieranie podobnych programów (np. tego samego programu w innej temperaturze/wirowaniu).", "no_cycles_match": "Żaden cykl nie pasuje do bieżącego filtra.", "no_cycles_profile": "Brak cykli dla tego profilu.", @@ -696,10 +701,10 @@ "no_devices": "Nie skonfigurowano jeszcze żadnych urządzeń WashData.", "no_envelope": "Nie ma jeszcze koperty - odbuduj po cyklach etykietowania.", "no_envelope_overlay": "Brak koperty do nałożenia.", - "no_fine_tuned": "Nic jeszcze nie zostało dopracowane — WashData korzysta z wbudowanych modeli.", + "no_fine_tuned": "Nic jeszcze nie zostało dopracowane – WashData korzysta z wbudowanych modeli.", "no_logs": "Żadne rekordy dziennika nie zostały jeszcze zbuforowane.", "no_maintenance": "Nie zarejestrowano jeszcze żadnej konserwacji.", - "no_match_yet": "Brak jeszcze próby dopasowania — komunikat pojawia się podczas trwającego cyklu.", + "no_match_yet": "Brak jeszcze próby dopasowania – komunikat pojawia się podczas trwającego cyklu.", "no_other_users": "Nie znaleziono innych użytkowników Home Assistant.", "no_phases": "Nie zdefiniowano faz.", "no_phases_assigned": "Brak przypisanych faz.", @@ -713,7 +718,7 @@ "notify_services_hint": "Użyj identyfikatorów usług {entity} (oddzielonych przecinkami dla kilku). Zmienne szablonu: {vars}.", "old_actions_warning": "Skonfigurowano przy użyciu starego edytora akcji (teraz usuniętego). Nadal uruchamiają się w przypadku wydarzeń cyklicznych, ale nie można ich już tutaj edytować. Przekształć je w normalną automatyzację lub usuń.", "onboarding_progress": "Zaobserwowano {n} / 3 cykli", - "onboarding_watching": "Używaj urządzenia normalnie — WashData obserwuje. Po 3 cyklach rozpocznie się dopasowywanie programów.", + "onboarding_watching": "Używaj urządzenia normalnie – WashData obserwuje. Po 3 cyklach rozpocznie się dopasowywanie programów.", "pending_feedback": "Oczekuje na informację zwrotną dotyczącą wykrycia", "performance_trend": "Trend wydajności ({n} cykli)", "pg_analysis_empty": "Wczytaj cykl, aby zobaczyć analizę dopasowania.", @@ -748,7 +753,7 @@ "restart_gap_footer": "Zaznaczone na wykresie. Dane mocy są niedostępne dla tych przedziałów; dopasowanie używało tylko rzeczywistych odczytów.", "restart_gap_header": "{n} przerwa restartu HA podczas tego cyklu", "restart_gap_item": "{dur} przerwa", - "review_confirm_help": "Sprawdź, czy ten cykl został prawidłowo wykryty. Twoje recenzje szkolą model na Twojej maszynie — im więcej cykli potwierdzisz, tym lepsze dopasowanie i punktacja kondycji. Wystarczy szybkie dobre/złe.", + "review_confirm_help": "Sprawdź, czy ten cykl został prawidłowo wykryty. Twoje recenzje szkolą model na Twojej maszynie – im więcej cykli potwierdzisz, tym lepsze dopasowanie i punktacja kondycji. Wystarczy szybkie dobre/złe.", "review_in_settings": "Przejrzyj w Ustawieniach", "review_notes_placeholder": "Notatki (opcjonalnie)", "review_notes_tip": "Notatki tekstowe do własnego użytku. Nieużywane podczas dopasowywania lub treningu.", @@ -763,7 +768,7 @@ "setting_changed": "Zmieniono z {old} na {new} dnia {date}", "settings_basic_note": "Wyświetlane są podstawowe ustawienia. Przełącz na Zaawansowane, aby zobaczyć pełną listę.", "shape_drift_advisory": "⚠ Dryf kształtu", - "shape_drift_detail": "Wzorzec mocy dla tego profilu zmienił się z biegiem czasu — możliwe zużycie urządzenia lub konieczna konserwacja (np. odkamienianie, czyszczenie filtra).", + "shape_drift_detail": "Wzorzec mocy dla tego profilu zmienił się z biegiem czasu – możliwe zużycie urządzenia lub konieczna konserwacja (np. odkamienianie, czyszczenie filtra).", "show_all_settings": "Pokaż wszystkie ustawienia", "showing_suggestions": "Wyświetlam ustawienie {count} z sugestiami.", "split_intro": "Kliknij wykres, aby dodać lub usunąć punkt podziału albo automatycznie wykryć na podstawie pustych przerw. Każdy powstały segment może otrzymać swój własny profil.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Przywracanie nie powiodło się: {error}", "toast_reverted": "Cofnięto: {key}", "toast_save_failed": "Zapisywanie nie powiodło się: {error}", - "trend_duration_longer": "Trwanie w trendzie dłuższym ({pct}%/cykl) — ostatnia średnia {avg}", - "trend_duration_shorter": "Trwanie w trendzie krótszym ({pct}%/cykl) — ostatnia średnia {avg}", + "trend_duration_longer": "Trwanie w trendzie dłuższym ({pct}%/cykl) – ostatnia średnia {avg}", + "trend_duration_shorter": "Trwanie w trendzie krótszym ({pct}%/cykl) – ostatnia średnia {avg}", "trend_energy_down": "Energia wykazuje tendencję spadkową ({pct}%/cykl)", - "trend_energy_up": "Trend wzrostowy w zakresie energii ({pct}%/cykl) — ostatnia średnia {avg}", + "trend_energy_up": "Trend wzrostowy w zakresie energii ({pct}%/cykl) – ostatnia średnia {avg}", "trim_intro": "Przeciągnij czerwone uchwyty lub wprowadź wartości. Wszystko za oknem jest usuwane.", "tuning_suggestions_available": "Sugestia dostrojenia {count} dostępna z obserwowanych cykli. Pojawiają się obok odpowiednich pól.", "unsure_detected_prefix": "WashData nie jest pewien, czy został wykryty", @@ -857,7 +862,9 @@ "share_guidelines_title": "Przed udostępnieniem", "store_download_device_intro": "Pobierz konfigurację urządzenia ze społeczności i zastosuj ją do nowego lub istniejącego urządzenia", "store_share_device_intro": "Udostępnij programy swojego urządzenia (profile + cykle referencyjne) społeczności. Ustawienia są opcjonalne.", - "share_profile_no_cycles": "Profil '{p}' nie ma ⭐ cykli referencyjnych -- zostanie pominięty, chyba że masz {n}+ potwierdzonych uruchomień" + "share_profile_no_cycles": "Profil '{p}' nie ma ⭐ cykli referencyjnych -- zostanie pominięty, chyba że masz {n}+ potwierdzonych uruchomień", + "advisory_phase_inconsistent": "Wygląda na to, że '{name}' miesza różne programy lub temperatury - jego cykle nagrzewają się przez bardzo różne czasy. Podzielenie go na osobne profile (np. dla każdej temperatury) poprawi dopasowanie i szacowanie czasu.", + "advisory_phase_inconsistent_title": "⚠ Prawdopodobnie pomieszane programy" }, "phase_desc": { "anti_crease": "Sporadyczne krótkie obroty bębna po zakończeniu w celu zapobiegania gnieceniu tkanin.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Opcjonalne zewnętrzne sygnały: wyzwalacz zakończenia, czujnik drzwi, przełącznik pauzy i przypomnienie o rozładunku.", "label": "Wyzwalacze i Drzwi" + }, + "phase_eta": { + "label": "Pozostały czas", + "intro": "Pozostały czas uwzględniający fazy, dla urządzeń, których długość cyklu zależy od temperatury lub wirowania." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Stała cena za kWh używana do danych o kosztach, gdy powyżej nie określono żadnej jednostki ceny rzeczywistej.", "label": "Statyczna Cena Energii (za kWh)" }, + "energy_sensor": { + "doc": "Opcjonalny skumulowany licznik energii (total_increasing kWh/Wh, np. własny licznik całkowitego zużycia wtyczki). Po ustawieniu energia raportowana dla każdego cyklu jest pobierana z różnicy wskazań tego licznika między początkiem a końcem, co pozwala uniknąć zaniżania wynikającego z całkowania wolno raportującego czujnika mocy. W razie braku odczytu, nieznanej jednostki lub niedodatniej różnicy następuje powrót do wartości całkowanej. Pozostaw puste, aby nadal całkować czujnik mocy.", + "label": "Encja Licznika Energii" + }, "expose_debug_entities": { "doc": "Publikuj dodatkowe jednostki diagnostyczne HA (pewność dopasowania, niejednoznaczność, elementy wewnętrzne stanu). Opcja Off utrzymuje listę encji w czystości podczas normalnego użytkowania.", "label": "Ujawnij Encje Debugowania" @@ -1220,7 +1235,7 @@ "label": "Strefa Martwa" }, "sampling_interval": { - "doc": "Oczekiwany czas pomiędzy odczytami czujnika — używany do określenia rozmiaru okna wygładzającego i prawidłowego rozpoczęcia odbijania. Każda aktualizacja czujnika jest rejestrowana niezależnie od tej wartości; kalibruje jedynie dalsze obliczenia. Silnik sugestii mierzy rzeczywistą kadencję czujnika z poprzednich cykli i ustawia ją automatycznie.", + "doc": "Oczekiwany czas pomiędzy odczytami czujnika – używany do określenia rozmiaru okna wygładzającego i prawidłowego rozpoczęcia odbijania. Każda aktualizacja czujnika jest rejestrowana niezależnie od tej wartości; kalibruje jedynie dalsze obliczenia. Silnik sugestii mierzy rzeczywistą kadencję czujnika z poprzednich cykli i ustawia ją automatycznie.", "label": "Interwał Próbkowania" }, "save_debug_traces": { @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Pokaż nazwę współtwórcy na profilach pobranych ze sklepu społeczności" + }, + "enable_phase_matching": { + "label": "Pozostały czas uwzględniający fazy", + "doc": "Dzieli każdy trwający cykl na fazy (podgrzewanie, pranie, wirowanie) i planuje pozostały czas dla każdej fazy, łącząc go z klasycznym szacowaniem - opierając się na budżecie faz na początku cyklu, a na klasycznym szacowaniu pod koniec. Personalizuje to odliczanie do tego, jak długo urządzenie faktycznie się nagrzewa i pracuje, co jest najbardziej zauważalne w pierwszej połowie cyklu. Wyłączone = tylko klasyczne szacowanie. Wpływa to wyłącznie na wyświetlanie pozostałego czasu; dopasowanie programów i wykrywanie cykli pozostają bez zmian." + }, + "keep_min_score": { + "label": "Min. wynik dopasowania", + "doc": "Minimalny wynik podobieństwa potrzebny kandydatowi, by pozostać w wyścigu dopasowań. Domyślna wartość 0,1 jest celowo łagodna - dopuszcza nawet słabych kandydatów i polega na późniejszych etapach w wyborze najlepszego dopasowania. Zwiększ, by wcześniej odrzucać mało prawdopodobne profile; zmniejsz, by poszerzyć początkową pulę kandydatów." + }, + "dtw_blend": { + "label": "Mieszanie znieksztalcenia", + "doc": "Jak bardzo wynik wyrównania DTW zastępuje wynik główny Etapu 2. 0 = używaj tylko wyniku Etapu 2, 1 = używaj tylko wyniku DTW, 0,5 (domyślnie) = równe mieszanie. Zwiększ, by bardziej polegać na wyrównaniu, gdy programy mają podobne poziomy mocy, ale różne wzorce czasowe." + }, + "dtw_ensemble_w": { + "label": "Mix kombinacji DTW", + "doc": "W trybie kombinacji DTW (domyślnym), waga skalowanego L1 względem pochodnego DTW (DDTW). 1,0 = tylko skalowane L1, 0 = tylko DDTW, 0,7 = domyślnie. Wariant skalowany L1 porównuje poziomy mocy; wariant pochodny reaguje na zmiany mocy w czasie. Kombinacja łączy oba." + }, + "dtw_ddtw_scale": { + "label": "Skala pochodnej DTW", + "doc": "Odległość półnasycenia dla oceniania pochodnym DTW. Wynik spada do połowy, gdy odległość DDTW równa się tej wartości. Mniejsza = większa wrażliwość na różnice kształtu. Domyślna wartość 30 jest skalibrowana do typowych przebiegów mocy urządzeń. Dotyczy tylko trybów DTW kombinacji i ddtw." + }, + "dtw_refine_top_n": { + "label": "Liczba doprecyzowanych kandydatow", + "doc": "Ile najlepszych kandydatów Etapu 2 jest ponownie ocenianych przez DTW. DTW jest kosztowniejszy, więc stosowany jest tylko do N najlepszych kandydatów. Domyślnie 5. Zwiększ (do 7-9), jeśli właściwy profil czasem zajmuje tylko 4. lub 5. miejsce po Etapie 2 - DTW może go uratować. Zmniejszenie nieznacznie przyspiesza dopasowanie." + }, + "duration_scale": { + "label": "Ostrość czasu trwania", + "doc": "Ostrość kary za niezgodność czasu trwania w Etapie 4. To logarytm stosunku, przy którym wynik zgodności spada do połowy. Mniejszy = surowszy: niezgodność czasu trwania bardziej obniża wynik. Domyślna wartość 0,175 odpowiada tolerancji ok. 18% przy połowie wagi. Używaj razem z Wagą Czasu Trwania." + }, + "energy_scale": { + "label": "Ostrość energii", + "doc": "Ostrość kary za niezgodność energii w Etapie 4. Mniejszy = surowszy: niezgodność energii bardziej obniża wynik. Domyślna wartość 0,25 jest celowo bardziej łagodna niż Ostrość Czasu Trwania, ponieważ energia zmienia się z obciążeniem. Używaj razem z Wagą Energii." } }, "setting_group": { @@ -1424,24 +1471,24 @@ "lower": "Więcej cykli jest oznaczanych automatycznie; niektóre mogą być błędnie zidentyfikowane" }, "duration_tolerance": { - "higher": "Akceptuje szerszy zakres czasu trwania — bardziej elastyczne dopasowanie", - "lower": "Wymaga bliższego dopasowania czasu trwania — bardziej rygorystyczne, ale może odrzucić nietypowe cykle" + "higher": "Akceptuje szerszy zakres czasu trwania – bardziej elastyczne dopasowanie", + "lower": "Wymaga bliższego dopasowania czasu trwania – bardziej rygorystyczne, ale może odrzucić nietypowe cykle" }, "end_energy_threshold": { "higher": "Zamyka tylko cykle o znacznym zużyciu energii", "lower": "Zamyka również krótsze lub mniej energochłonne cykle" }, "end_repeat_count": { - "higher": "Wymaga więcej powtórzeń sygnału końca — bardziej niezawodne, nieco wolniejsze", - "lower": "Kończy po mniejszej liczbie powtórzeń — szybsze wykrywanie, większe ryzyko fałszywego zakończenia" + "higher": "Wymaga więcej powtórzeń sygnału końca – bardziej niezawodne, nieco wolniejsze", + "lower": "Kończy po mniejszej liczbie powtórzeń – szybsze wykrywanie, większe ryzyko fałszywego zakończenia" }, "learning_confidence": { - "higher": "Uczy się tylko z bardzo pewnych dopasowań — wolniejsze, ale bardziej niezawodne", + "higher": "Uczy się tylko z bardzo pewnych dopasowań – wolniejsze, ale bardziej niezawodne", "lower": "Uczy się z większej liczby cykli; model może być mniej precyzyjny" }, "min_off_gap": { "higher": "Potrzeba dłuższego spokoju przed rozpoczęciem nowego cyklu", - "lower": "Krótki spokój uruchamia nowy cykl — kolejne uruchomienia mogą się rozdzielić" + "lower": "Krótki spokój uruchamia nowy cykl – kolejne uruchomienia mogą się rozdzielić" }, "min_power": { "higher": "Mniej wrażliwy na pobór mocy w stanie gotowości; może przeoczyć bardzo ciche programy", @@ -1452,24 +1499,24 @@ "lower": "Zablokowany cykl jest kończony wcześniej" }, "off_delay": { - "higher": "Więcej czasu na przerwy urządzenia — dobre dla długich faz namaczania lub suszenia", - "lower": "Szybsze wykrywanie końca — działa dobrze dla urządzeń z czystym włączaniem/wyłączaniem" + "higher": "Więcej czasu na przerwy urządzenia – dobre dla długich faz namaczania lub suszenia", + "lower": "Szybsze wykrywanie końca – działa dobrze dla urządzeń z czystym włączaniem/wyłączaniem" }, "profile_match_threshold": { "higher": "Zatwierdza tylko dopasowania o wysokiej pewności; więcej cykli pozostaje nieoznakowanych", "lower": "Dopasowuje więcej cykli; niektóre mogą być lekko błędne" }, "running_dead_zone": { - "higher": "Ignoruje krótkie skoki mocy podczas bezczynności — mniej wrażliwy na zakłócenia", - "lower": "Reaguje na krótsze impulsy mocy — wykrywa szybką aktywność przejściową" + "higher": "Ignoruje krótkie skoki mocy podczas bezczynności – mniej wrażliwy na zakłócenia", + "lower": "Reaguje na krótsze impulsy mocy – wykrywa szybką aktywność przejściową" }, "start_threshold_w": { "higher": "Zapobiega fałszywym startom z krótkich skoków mocy", "lower": "Wykrywa programy o niskiej mocy; może reagować na zakłócenia" }, "stop_threshold_w": { - "higher": "Kończy cykl szybciej — może zamknąć w trakcie przerwy", - "lower": "Czeka dłużej na zakończenie — mniej przedwczesnych zakończeń" + "higher": "Kończy cykl szybciej – może zamknąć w trakcie przerwy", + "lower": "Czeka dłużej na zakończenie – mniej przedwczesnych zakończeń" }, "watchdog_interval": { "higher": "Dopuszcza dłuższe ciche fazy; mniej fałszywych wymuszeń zatrzymania", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Sekundy powyżej progu, by potwierdzić start", "start_threshold_w": "Minimalna moc w watach, by uznać za rozpoczęty", "stop_threshold_w": "Poniżej tej wartości urządzenie liczone jest jako wyłączone", - "dtw_bandwidth": "Jak duże zniekształcenie czasowe dopuszcza dopasowanie kształtu", - "corr_weight": "Waga kształtu krzywej względem poziomu mocy w dopasowaniu", - "duration_weight": "Jak bardzo zgodność czasu trwania wpływa na dopasowanie", - "energy_weight": "Jak bardzo zgodność zużycia energii wpływa na dopasowanie", - "profile_match_min_duration_ratio": "Najkrótszy przebieg (względem profilu), który nadal może zostać dopasowany", - "profile_match_max_duration_ratio": "Najdłuższy przebieg (względem profilu), który nadal może zostać dopasowany" + "dtw_bandwidth": "Etap 3: pasmo zniekształcenia Sakoe-Chiba (0 = DTW wyłączony; domyślnie 0,2 = 20% długości cyklu)", + "corr_weight": "Etap 2: balans między kształtem krzywej (korelacja) a poziomem mocy (MAE); domyślnie 0,45", + "duration_weight": "Etap 4: jak mocno zgodność czasu trwania wpływa na wynik końcowy (domyślnie 0,22)", + "energy_weight": "Etap 4: jak mocno zgodność energii wpływa na wynik końcowy (domyślnie 0,22)", + "profile_match_min_duration_ratio": "Etap 1: minimalna długość cyklu jako ułamek czasu trwania profilu; domyślnie 0,1 (10%)", + "profile_match_max_duration_ratio": "Etap 1: maksymalna długość cyklu jako ułamek czasu trwania profilu; domyślnie 1,5 (150%)", + "keep_min_score": "Etap 2: minimalny wynik, by pozostać w wyścigu; domyślnie 0,1 dopuszcza słabe dopasowania, późniejsze etapy wybierają najlepsze", + "dtw_blend": "Etap 3: 0 = tylko wynik główny, 1 = tylko wynik DTW, 0,5 = równe mieszanie (domyślnie)", + "dtw_ensemble_w": "Etap 3 (tryb kombinacji): waga skalowanego L1 względem pochodnego DTW; domyślnie 0,7 faworyzuje poziom", + "dtw_ddtw_scale": "Etap 3: odległość półnasycenia DDTW; mniejsza = bardziej wrażliwa na kształt (domyślnie 30)", + "dtw_refine_top_n": "Etap 3: kandydaci ponownie oceniani przez DTW; zwiększ do 7-9, jeśli właściwy profil zajmuje 4-5 miejsce (domyślnie 5)", + "duration_scale": "Etap 4: logarytm stosunku, przy którym zgodność czasu trwania spada do połowy; mniejszy = surowsza kara (domyślnie 0,175)", + "energy_scale": "Etap 4: logarytm stosunku, przy którym zgodność energii spada do połowy; mniejszy = surowsza kara (domyślnie 0,25)" }, "col": { "profile_tip": "Nazwa dopasowanego programu. Brak etykiety oznacza, że na końcu cyklu nie dopasowano żadnego profilu.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optymalizacja: {param}" + }, + "pg_detail": { + "simulate": "Symuluj cykl" + }, + "split": { + "apply": "Dzielenie cyklu" + }, + "trim": { + "apply": "Przycinanie cyklu" + }, + "merge": { + "apply": "Scalanie cykli" + }, + "rebuild": { + "envelopes": "Przeliczanie obwiedni" + }, + "cancelling": "Anulowanie..." + }, + "setup": { + "hdr": { + "card": "Konfiguracja urządzenia", + "healthy_chip": "Konfiguracja ukończona" + }, + "phase0": { + "washer": "WashData już wykrywa Twoje cykle. Nagraj pierwszy cykl, aby włączyć nazwy programów i szacunki czasu.", + "dishwasher": "WashData obserwuje. Zmywarki mają złożone cykle – nagranie pierwszego cyklu jest zdecydowanie zalecane. Jeśli wykryty cykl trwa zbyt długo, użyj edytora cyklu, aby go przyciąć przed zapisaniem jako profil.", + "generic": "WashData obserwuje. Nagraj lub oznacz wykryty cykl, aby zacząć tworzyć profile." + }, + "phase1a": { + "labelled": "Dobry początek – Twój pierwszy program jest zapisany. Aby uzyskać najczystsze dane, rozważ nagranie następnego cyklu za pomocą widżetu rejestratora." + }, + "phase1b": { + "recorded": "Nagranie zostało zapisane jako {profile_name}. Teraz nagraj lub oznacz inne popularne programy, aby rozszerzyć pokrycie." + }, + "phase1c": { + "verify": "Masz {count} programów ze społeczności. Uruchom cykl, aby sprawdzić, czy WashData rozpoznaje go poprawnie – dopasowanie będzie się poprawiać w miarę jak urządzenie buduje własną historię." + }, + "phase2": { + "cluster": "WashData zauważył {count} cykli, które nie pasują do żadnego zapisanego programu – wyglądają podobnie do siebie. Chcesz utworzyć dla nich nowy profil?", + "unmatched": "Twój ostatni cykl nie pasował do żadnego zapisanego programu. Czy to nowy program?" + }, + "phase3": { + "suggestions": "WashData ma zalecenia dotyczące ustawień oparte na historii Twoich cykli – przejrzyj je, aby poprawić dokładność wykrywania.", + "groups": "Niektóre Twoje profile wyglądają jak ten sam program w różnych temperaturach. Zorganizuj je w grupę dla lepszego dopasowania.", + "phases": "Dodaj fazy programu do {profile_name} dla dokładniejszych szacunków pozostałego czasu." + }, + "phase4": { + "healthy": "To urządzenie jest w pełni skonfigurowane ({profile_count} profili)." + }, + "cta": { + "start_recording": "Rozpocznij nagrywanie", + "label_detected_cycle": "Mam już wykryty cykl – oznacz go zamiast tego", + "browse_cycles": "Przeglądaj cykle, aby oznaczyć kolejny", + "view_profiles": "Wyświetl swoje profile", + "create_from_cluster": "Utwórz profil z tych cykli", + "create_profile": "Utwórz dla niego profil", + "review_suggestions": "Przejrzyj sugestie", + "organise_profiles": "Zorganizuj profile", + "configure_phases": "Skonfiguruj fazy", + "skip_step": "Pomiń ten krok", + "skip_forever": "Nie pokazuj ponownie", + "hide_guidance": "Ukryj wskazówki", + "show_guidance": "Pokaż wskazówki" } } } diff --git a/custom_components/ha_washdata/translations/panel/pt-BR.json b/custom_components/ha_washdata/translations/panel/pt-BR.json index 407c167..11c54c8 100644 --- a/custom_components/ha_washdata/translations/panel/pt-BR.json +++ b/custom_components/ha_washdata/translations/panel/pt-BR.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "{n} anomalias detectadas (por exemplo, porta aberta no meio do ciclo) — abra para vê-las no gráfico", + "artifact_tip": "{n} anomalias detectadas (por exemplo, porta aberta no meio do ciclo) – abra para vê-las no gráfico", "auto": "(detectado automaticamente)", "built_in_tag": "integrado", "declining": "↘ em declínio", "energy_low": "Energia abaixo do habitual", "energy_spike": "Energia acima do habitual", "fair_fit": "qualidade aceitável", - "fair_fit_tip": "Consistência de correspondência moderada — alguns ciclos atribuídos a este perfil têm pontuações de confiança mais baixas. Rotule mais ciclos ou regrave o perfil para melhorar a precisão.", + "fair_fit_tip": "Consistência de correspondência moderada – alguns ciclos atribuídos a este perfil têm pontuações de confiança mais baixas. Rotule mais ciclos ou regrave o perfil para melhorar a precisão.", "feedback_requested": "Feedback solicitado", "golden_cycle": "Ciclo de referência gravado", "improving": "↗ melhorando", @@ -15,7 +15,7 @@ "needs_review": "Precisa de revisão", "overrun": "Demorou mais tempo que o normal", "poor_fit": "qualidade fraca", - "poor_fit_tip": "Histórico de correspondência inconsistente — considere reconstruir este perfil", + "poor_fit_tip": "Histórico de correspondência inconsistente – considere reconstruir este perfil", "restart_gap_tip": "{n} lacuna de reinicialização do HA: o rastro de potência tem uma lacuna", "reviewed": "Revisado", "steady": "→ estável", @@ -57,7 +57,7 @@ "create_profile": "+ Criar perfil", "delete": "Excluir", "delete_group": "Excluir grupo", - "delete_group_tip": "Excluir apenas este grupo — os perfis dos membros são mantidos", + "delete_group_tip": "Excluir apenas este grupo – os perfis dos membros são mantidos", "delete_profile": "Excluir perfil", "discard": "Descartar", "discard_tip": "Descartar a gravação capturada sem salvar", @@ -87,7 +87,7 @@ "on_cycle_finished": "Ao terminar o ciclo", "on_cycle_started": "Ao iniciar o ciclo", "pause_cycle": "Pausar", - "pause_cycle_tip": "Pausar o ciclo em andamento — o eletrodoméstico retomará onde parou", + "pause_cycle_tip": "Pausar o ciclo em andamento – o eletrodoméstico retomará onde parou", "pg_apply_device": "Aplicar ao dispositivo", "pg_autofill": "Preencher automaticamente", "play": "Reproduzir", @@ -97,8 +97,8 @@ "process_tip": "Salvar o rastro gravado como um perfil novo ou existente", "rebuild": "Reconstruir envelopes", "rebuild_envelope": "Reconstruir envelope", - "rebuild_tip": "Recalcular o envelope de potência esperado (faixa mín/máx) para todos os perfis a partir dos seus ciclos rotulados — executar após rotular novos ciclos ou corrigir os existentes", - "rec_start_tip": "Iniciar a gravação do rastro de potência do eletrodoméstico — iniciar logo antes de executar um ciclo", + "rebuild_tip": "Recalcular o envelope de potência esperado (faixa mín/máx) para todos os perfis a partir dos seus ciclos rotulados – executar após rotular novos ciclos ou corrigir os existentes", + "rec_start_tip": "Iniciar a gravação do rastro de potência do eletrodoméstico – iniciar logo antes de executar um ciclo", "rec_stop": "Parar", "rec_stop_tip": "Parar a gravação e manter o rastro capturado para revisão", "record": "Iniciar gravação", @@ -182,7 +182,7 @@ }, "attn_sub": "Corrija os conflitos antes de salvar", "attn_title": "{n} conflito{s} de configurações", - "settings_banner": "{n} conflito{s} de configurações — verifique as seções destacadas e corrija antes de salvar.", + "settings_banner": "{n} conflito{s} de configurações – verifique as seções destacadas e corrija antes de salvar.", "settings_banner_btn": "Ir para o primeiro", "confidence": { "auto": "Deve ser maior ou igual ao Limiar de correspondência ({match})", @@ -451,9 +451,9 @@ "show_expected": "Mostrar sobreposição da curva esperada (perfil correspondente, laranja)", "show_raw": "Mostrar o interruptor de sinal bruto da tomada no gráfico de potência ao vivo", "sparkline": "Tendência recente da duração dos ciclos", - "stage2": "Etapa 2 — similaridade principal", - "stage3": "Etapa 3 — DTW", - "stage4": "Etapa 4 — concordância", + "stage2": "Etapa 2 – similaridade principal", + "stage3": "Etapa 3 – DTW", + "stage4": "Etapa 4 – concordância", "status": "Status", "tail_trim": "Recorte final (s)", "timer_auto_pause": "Pausa automática", @@ -561,7 +561,12 @@ "flags": "Sinalizadores", "include_phase_map": "Incluir mapa de fases", "include_settings": "Incluir configurações de detecção e reconhecimento", - "show_contributor": "Mostrar nomes dos contribuidores" + "show_contributor": "Mostrar nomes dos contribuidores", + "task_pg_detail": "Simular ciclo", + "task_split": "Dividindo o ciclo", + "task_trim": "Recortando o ciclo", + "task_merge": "Combinando ciclos", + "task_rebuild": "Reconstruindo envelopes" }, "log": { "all_levels": "Todos os níveis", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Caiu abaixo da banda de potência habitual por ~{n}s.", "artifact_footer": "Destacadas no gráfico acima. São artefatos transitórios (por exemplo, a porta foi aberta no meio do ciclo), não necessariamente problemas.", "artifact_header": "{n} anomalias detectadas durante este ciclo", - "artifact_pause_detail": "A potência caiu quase a zero por ~{n}s e depois retomou — provavelmente a porta foi aberta no meio do ciclo ou o ciclo foi pausado.", + "artifact_pause_detail": "A potência caiu quase a zero por ~{n}s e depois retomou – provavelmente a porta foi aberta no meio do ciclo ou o ciclo foi pausado.", "artifact_spike_detail": "Ultrapassou a banda de potência habitual por ~{n}s.", "auto_label_intro": "Atribua perfis a ciclos sem rótulo cuja confiança de correspondência supere o limite.", "automations_intro": "O WashData dispara eventos {start} / {end} e expõe entidades, portanto as notificações e ações são melhor criadas como automatizações normais do Home Assistant. As automatizações que usam este dispositivo aparecem abaixo.", @@ -654,10 +659,10 @@ "collecting_data": "Coletando dados - {need} ciclo{plural} a mais antes que o ajuste fino possa começar ({current}/{min}).", "compare_overlay_profiles": "Sobrepor perfis (fraco)", "compare_profiles_tip": "Sobreponha outros envelopes de perfil no gráfico acima para ver qual deles melhor se adapta a este ciclo.", - "compare_selected_cycles": "Ciclos selecionados (sólido) — mostrar / ocultar", + "compare_selected_cycles": "Ciclos selecionados (sólido) – mostrar / ocultar", "consider_new_profile": "Considere criar um novo perfil.", "coverage_gap": "os ciclos recentes não têm perfil correspondente ({pct}% dos últimos 30).", - "coverage_gap_similar_cycles": "{count} ciclos similares sem rótulo encontrados — crie um perfil para começar a correspondê-los.", + "coverage_gap_similar_cycles": "{count} ciclos similares sem rótulo encontrados – crie um perfil para começar a correspondê-los.", "cycles_deleted": "{count} ciclo(s) excluído(s)", "enough_data": "Dados suficientes para aprender ({current}/{min} ciclos).", "export_description": "Exporte todos os perfis e ciclos para JSON ou restaure de uma exportação anterior.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modelos ajustados a esta máquina.", "ml_loading": "carregando ML…", "ml_settings_intro": "Dois botões independentes: um aplica os modelos durante um ciclo, o outro permite ao WashData ajustá-los à sua máquina ao longo do tempo.", - "name_first_program": "Você já tem ciclos suficientes — dê um nome ao seu primeiro programa para começar a correspondência.", + "name_first_program": "Você já tem ciclos suficientes – dê um nome ao seu primeiro programa para começar a correspondência.", "near_duplicate_cluster": "cluster de perfis quase duplicados detectado. O agrupamento permite que a correspondência escolha de forma confiável entre perfis semelhantes (por exemplo, o mesmo programa em temperatura/centrifugação diferente).", "no_cycles_match": "Nenhum ciclo corresponde ao filtro atual.", "no_cycles_profile": "Nenhum ciclo para este perfil.", @@ -694,12 +699,12 @@ "no_cycles_yet": "Nenhum ciclo gravado ainda.", "no_device_selected": "Nenhum dispositivo selecionado.", "no_devices": "Nenhum dispositivo WashData configurado ainda.", - "no_envelope": "Nenhum envelope ainda — reconstrua após rotular os ciclos.", + "no_envelope": "Nenhum envelope ainda – reconstrua após rotular os ciclos.", "no_envelope_overlay": "Nenhum envelope disponível para sobrepor.", - "no_fine_tuned": "Nada ajustado ainda — o WashData está usando seus modelos integrados.", + "no_fine_tuned": "Nada ajustado ainda – o WashData está usando seus modelos integrados.", "no_logs": "Nenhum registro em buffer ainda.", "no_maintenance": "Ainda não há manutenções registradas.", - "no_match_yet": "Nenhuma tentativa de correspondência ainda — isto é preenchido durante um ciclo em execução.", + "no_match_yet": "Nenhuma tentativa de correspondência ainda – isto é preenchido durante um ciclo em execução.", "no_other_users": "Nenhum outro usuário do Home Assistant encontrado.", "no_phases": "Nenhuma fase definida.", "no_phases_assigned": "Nenhuma fase atribuída.", @@ -713,7 +718,7 @@ "notify_services_hint": "Use os IDs de serviço de {entity} (separados por vírgula para múltiplos). Variáveis de template: {vars}.", "old_actions_warning": "Configurado com o antigo editor de ações (agora removido). Ainda são acionadas em eventos de ciclo, mas não podem mais ser editadas aqui. Converta-as em uma automação normal ou remova-as.", "onboarding_progress": "{n} / 3 ciclos observados", - "onboarding_watching": "Use o eletrodoméstico normalmente — o WashData está observando. Após 3 ciclos, a correspondência de programas começará.", + "onboarding_watching": "Use o eletrodoméstico normalmente – o WashData está observando. Após 3 ciclos, a correspondência de programas começará.", "pending_feedback": "Feedback de detecção pendente", "performance_trend": "Tendência de desempenho ({n} ciclos)", "pg_analysis_empty": "Carregue um ciclo para ver a análise de correspondência.", @@ -748,13 +753,13 @@ "restart_gap_footer": "Destacado no gráfico. Dados de potência estão ausentes para esses intervalos; o reconhecimento usou apenas leituras reais.", "restart_gap_header": "{n} lacuna de reinicialização do HA durante este ciclo", "restart_gap_item": "{dur} lacuna", - "review_confirm_help": "Confirme se este ciclo foi detectado corretamente. Suas revisões treinam o modelo na sua máquina — quanto mais ciclos confirmar, melhor ficam a correspondência e a pontuação de saúde. Um Bom/Ruim rápido é suficiente.", + "review_confirm_help": "Confirme se este ciclo foi detectado corretamente. Suas revisões treinam o modelo na sua máquina – quanto mais ciclos confirmar, melhor ficam a correspondência e a pontuação de saúde. Um Bom/Ruim rápido é suficiente.", "review_in_settings": "Revisar em Configurações", "review_notes_placeholder": "Observações (opcional)", "review_notes_tip": "Notas em texto livre para sua própria referência. Não são usadas pela correspondência ou pelo treinamento.", - "review_profile_tip": "O programa com que este ciclo está rotulado. Se o programa detectado automaticamente estava errado, corrija-o aqui — a rotulagem ensina a correspondência para ciclos futuros.", + "review_profile_tip": "O programa com que este ciclo está rotulado. Se o programa detectado automaticamente estava errado, corrija-o aqui – a rotulagem ensina a correspondência para ciclos futuros.", "review_quality_tip": "A qualidade deste ciclo. Bom = um exemplo perfeito deste programa; Ruim = detectado mas ruidoso ou atípico; Inutilizável = detectado incorretamente (mesclado, truncado ou espúrio). Determina a pontuação de saúde e quais ciclos são permitidos para treinar o modelo.", - "review_recorded_tip": "Marque este ciclo como referência escolhida a dedo para seu programa — o mesmo papel de um ciclo gravado manualmente. Os ciclos de referência são sempre mantidos, alimentam o modelo de correspondência e nunca são removidos pela limpeza. (Esta é a bandeira \"dourada\"/gravada; ambas são a mesma coisa.)", + "review_recorded_tip": "Marque este ciclo como referência escolhida a dedo para seu programa – o mesmo papel de um ciclo gravado manualmente. Os ciclos de referência são sempre mantidos, alimentam o modelo de correspondência e nunca são removidos pela limpeza. (Esta é a bandeira \"dourada\"/gravada; ambas são a mesma coisa.)", "review_tags_tip": "Etiquetas opcionais que descrevem o que deu errado com este ciclo, para que o treinamento e a limpeza possam levar isso em conta.", "review_to_cycles": "Abrir a fila de revisão de Ciclos", "saving_triggers_reload": "Salvar aciona um recarregamento da integração. As entidades do HA podem ficar brevemente indisponíveis.", @@ -763,7 +768,7 @@ "setting_changed": "Alterado de {old} para {new} em {date}", "settings_basic_note": "Mostrando as configurações essenciais. Mude para Avançado para ver a lista completa.", "shape_drift_advisory": "⚠ Desvio de forma", - "shape_drift_detail": "O padrão de potência para este perfil mudou ao longo do tempo — possível desgaste do eletrodoméstico ou manutenção necessária (por exemplo, descalcificação, limpeza do filtro).", + "shape_drift_detail": "O padrão de potência para este perfil mudou ao longo do tempo – possível desgaste do eletrodoméstico ou manutenção necessária (por exemplo, descalcificação, limpeza do filtro).", "show_all_settings": "Mostrar todas as configurações", "showing_suggestions": "Mostrando a configuração {count} com sugestões.", "split_intro": "Clique no gráfico para adicionar ou remover um ponto de divisão, ou detecte automaticamente por intervalos de inatividade. Cada segmento resultante pode ter seu próprio perfil.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Reversão falhou: {error}", "toast_reverted": "{key} revertido", "toast_save_failed": "Salvamento falhou: {error}", - "trend_duration_longer": "Duração com tendência crescente ({pct}%/ciclo) — média recente {avg}", - "trend_duration_shorter": "Duração com tendência decrescente ({pct}%/ciclo) — média recente {avg}", + "trend_duration_longer": "Duração com tendência crescente ({pct}%/ciclo) – média recente {avg}", + "trend_duration_shorter": "Duração com tendência decrescente ({pct}%/ciclo) – média recente {avg}", "trend_energy_down": "Energia com tendência decrescente ({pct}%/ciclo)", - "trend_energy_up": "Energia com tendência crescente ({pct}%/ciclo) — média recente {avg}", + "trend_energy_up": "Energia com tendência crescente ({pct}%/ciclo) – média recente {avg}", "trim_intro": "Arraste as alças vermelhas ou insira valores. Tudo fora da janela é removido.", "tuning_suggestions_available": "{count} sugestão de ajuste disponível a partir dos ciclos observados. Aparecem ao lado dos campos relevantes.", "unsure_detected_prefix": "O WashData não tem certeza se detectou", @@ -857,7 +862,9 @@ "share_guidelines_title": "Antes de compartilhar", "store_download_device_intro": "Adote todos os programas compartilhados e seus ciclos de referência neste dispositivo. Seus próprios ciclos gravados e estatísticas não são afetados.", "store_share_device_intro": "Envie {brand} {model} com os ciclos de referência que você selecionar. Outros usuários com o mesmo eletrodoméstico podem adotar seus programas. As entradas são revisadas antes de aparecerem publicamente.", - "share_profile_no_cycles": "Nenhum ciclo de referência -- marque um ciclo como ⭐ na aba Ciclos para incluir este perfil" + "share_profile_no_cycles": "Nenhum ciclo de referência -- marque um ciclo como ⭐ na aba Ciclos para incluir este perfil", + "advisory_phase_inconsistent": "'{name}' parece misturar diferentes programas ou temperaturas - seus ciclos aquecem por períodos de tempo muito diferentes. Dividi-lo em perfis separados (por ex. por temperatura) melhorará a correspondência e as estimativas de tempo.", + "advisory_phase_inconsistent_title": "⚠ Possíveis programas misturados" }, "phase_desc": { "anti_crease": "Giros curtos ocasionais após a conclusão para reduzir amassados.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Sinais externos opcionais: um gatilho de fim, um sensor de porta, um interruptor de pausa e o lembrete de descarga.", "label": "Gatilhos e Portas" + }, + "phase_eta": { + "label": "Tempo restante", + "intro": "Tempo restante com base nas fases, para máquinas cuja duração do ciclo depende da temperatura ou da centrifugação." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Preço fixo por kWh usado para valores de custos quando nenhuma entidade de preço real é definida acima.", "label": "Preço Estático de Energia (por kWh)" }, + "energy_sensor": { + "doc": "Contador de energia cumulativo opcional (total_increasing em kWh/Wh, por ex. o medidor de consumo total da própria tomada). Quando definido, a energia informada de cada ciclo é obtida a partir da diferença deste contador entre o início e o fim, o que evita a subcontagem resultante da integração de um sensor de potência que informa lentamente. Recorre ao valor integrado se a leitura estiver ausente, se sua unidade for desconhecida ou se o delta não for positivo. Deixe em branco para continuar integrando o sensor de potência.", + "label": "Entidade do Medidor de Energia" + }, "expose_debug_entities": { "doc": "Publique entidades de HA de diagnóstico extras (confiança de correspondência, ambiguidade, estados internos). Off mantém a lista de entidades limpa para uso normal.", "label": "Expor Entidades de Depuração" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Mostrar a atribuição \"por \" em eletrodomésticos e ciclos de referência da comunidade." + }, + "enable_phase_matching": { + "label": "Tempo restante com base nas fases", + "doc": "Divide cada ciclo em andamento em fases (aquecimento, lavagem, centrifugação) e distribui o tempo restante por fase, combinado com a estimativa clássica - apoiando-se na repartição por fases no início do ciclo e na estimativa clássica perto do fim. Isso personaliza a contagem regressiva de acordo com o tempo que sua máquina realmente aquece e funciona, o que é mais perceptível na primeira metade de um ciclo. Desativado = apenas a estimativa clássica. Apenas a exibição do tempo restante é afetada; a correspondência de programas e a detecção de ciclos permanecem inalteradas." + }, + "keep_min_score": { + "label": "Score mín de correspondência", + "doc": "Pontuação de similaridade mínima que um candidato precisa para permanecer na disputa. O padrão 0,1 é deliberadamente permissivo - admite até candidatos fracos e depende de etapas posteriores para encontrar a melhor correspondência. Aumente para eliminar perfis improváveis mais cedo; reduza para ampliar o pool inicial de candidatos." + }, + "dtw_blend": { + "label": "Fusão Warp", + "doc": "O quanto o score de alinhamento por deformação temporal (DTW) substitui o score central da etapa 2. 0 = usar apenas o score da etapa 2, 1 = usar apenas o score DTW, 0,5 (padrão) = mistura equilibrada. Aumente para confiar mais no alinhamento Warp quando os programas têm níveis de potência semelhantes mas padrões temporais diferentes." + }, + "dtw_ensemble_w": { + "label": "Mix ensemble Warp", + "doc": "No modo DTW ensemble (o padrão), o peso em L1 escalado versus DTW derivativo (DDTW). 1,0 = apenas L1 escalado, 0 = apenas DDTW, 0,7 = padrão. A variante L1 escalada compara os níveis de potência; a variante derivativa reage a como a potência muda ao longo do tempo. Ensemble combina ambos." + }, + "dtw_ddtw_scale": { + "label": "Escala Warp derivada", + "doc": "Distância de meia saturação para a pontuação DTW derivativa. A pontuação é reduzida à metade quando a distância DDTW é igual a este valor. Menor = mais sensível às diferenças de forma. O padrão 30 está calibrado para rastros de potência típicos de eletrodomésticos. Afeta apenas os modos DTW ensemble e ddtw." + }, + "dtw_refine_top_n": { + "label": "Contagem de refinamento Warp", + "doc": "Quantos candidatos principais da etapa 2 são reavaliados pelo DTW. O DTW é mais caro, por isso é aplicado apenas aos N melhores candidatos. Padrão 5. Aumente (para 7-9) se o perfil correto às vezes só chega ao 4.º ou 5.º lugar após a etapa 2 - o DTW pode recuperá-lo. Reduzir acelera ligeiramente a correspondência." + }, + "duration_scale": { + "label": "Nitidez de duração", + "doc": "Precisão da penalidade de concordância de duração na etapa 4. Este é o log-razão em que o score de concordância é reduzido à metade. Menor = mais rigoroso: uma discordância de duração penaliza mais. O padrão 0,175 corresponde a cerca de 18% de tolerância de duração a meio peso. Use em conjunto com o Peso de duração." + }, + "energy_scale": { + "label": "Nitidez de energia", + "doc": "Precisão da penalidade de concordância de energia. Menor = mais rigoroso: uma discordância de energia penaliza mais. O padrão 0,25 é intencionalmente mais permissivo do que a Precisão de duração, porque a energia varia com a carga. Use em conjunto com o Peso de energia." } }, "setting_group": { @@ -1553,24 +1600,24 @@ "lower": "Rotula mais ciclos automaticamente; alguns podem ser mal identificados" }, "duration_tolerance": { - "higher": "Aceita um intervalo de duração mais amplo — correspondência mais flexível", - "lower": "Exige duração mais próxima — mais rigoroso, pode rejeitar ciclos incomuns" + "higher": "Aceita um intervalo de duração mais amplo – correspondência mais flexível", + "lower": "Exige duração mais próxima – mais rigoroso, pode rejeitar ciclos incomuns" }, "end_energy_threshold": { "higher": "Encerra apenas ciclos que consumiram energia substancial", "lower": "Encerra também ciclos curtos ou de baixa energia" }, "end_repeat_count": { - "higher": "Exige que o sinal de fim se repita mais vezes — mais robusto, ligeiramente mais lento", - "lower": "Termina após menos repetições — detecção mais rápida, maior risco de fim falso" + "higher": "Exige que o sinal de fim se repita mais vezes – mais robusto, ligeiramente mais lento", + "lower": "Termina após menos repetições – detecção mais rápida, maior risco de fim falso" }, "learning_confidence": { - "higher": "Aprende apenas com correspondências muito confiantes — mais lento mas mais confiável", + "higher": "Aprende apenas com correspondências muito confiantes – mais lento mas mais confiável", "lower": "Aprende com mais ciclos; o modelo pode ser menos preciso" }, "min_off_gap": { "higher": "Requer mais tempo inativo antes de iniciar um novo ciclo", - "lower": "Um breve repouso inicia um novo ciclo — execuções consecutivas podem ser divididas" + "lower": "Um breve repouso inicia um novo ciclo – execuções consecutivas podem ser divididas" }, "min_power": { "higher": "Menos sensível ao consumo em repouso; pode não detectar programas muito silenciosos", @@ -1581,24 +1628,24 @@ "lower": "Para um ciclo travado mais cedo" }, "off_delay": { - "higher": "Mais tempo para pausas do eletrodoméstico — gerencia fases longas de molho ou secagem", - "lower": "Detecção de fim mais rápida — ideal para eletrodomésticos com liga/desliga limpo" + "higher": "Mais tempo para pausas do eletrodoméstico – gerencia fases longas de molho ou secagem", + "lower": "Detecção de fim mais rápida – ideal para eletrodomésticos com liga/desliga limpo" }, "profile_match_threshold": { "higher": "Confirma apenas correspondências de alta confiança; mais ciclos ficam sem rótulo", "lower": "Corresponde a mais ciclos; alguns podem estar ligeiramente errados" }, "running_dead_zone": { - "higher": "Ignora breves picos de potência em repouso — menos sensível ao ruído", - "lower": "Responde a impulsos mais curtos — detecta atividade transitória rápida" + "higher": "Ignora breves picos de potência em repouso – menos sensível ao ruído", + "lower": "Responde a impulsos mais curtos – detecta atividade transitória rápida" }, "start_threshold_w": { "higher": "Evita partidas falsas por breves picos de potência", "lower": "Detecta programas de baixa potência; pode ser ativado por ruído" }, "stop_threshold_w": { - "higher": "Encerra o ciclo mais cedo — pode fechar durante uma pausa", - "lower": "Aguarda mais para terminar — menos conclusões prematuras" + "higher": "Encerra o ciclo mais cedo – pode fechar durante uma pausa", + "lower": "Aguarda mais para terminar – menos conclusões prematuras" }, "watchdog_interval": { "higher": "Fases silenciosas mais longas permitidas; menos paradas forçadas falsas", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Segundos acima do limite para confirmar o início", "start_threshold_w": "Watts mínimos para contar como iniciado", "stop_threshold_w": "Abaixo disso, a máquina conta como desligada", - "dtw_bandwidth": "Quanta deformação temporal a correspondência de forma permite", - "corr_weight": "Peso da forma da curva em relação ao nível de potência na correspondência", - "duration_weight": "Quanto a concordância de duração afeta a correspondência", - "energy_weight": "Quanto a concordância de energia afeta a correspondência", - "profile_match_min_duration_ratio": "Ciclo mais curto (em relação ao perfil) ainda permitido para corresponder", - "profile_match_max_duration_ratio": "Ciclo mais longo (em relação ao perfil) ainda permitido para corresponder" + "dtw_bandwidth": "Etapa 3: banda de deformação Sakoe-Chiba (0 = DTW desativado; padrão 0,2 = 20% do comprimento do ciclo)", + "corr_weight": "Etapa 2: equilíbrio entre a forma da curva (correlação) e o nível de potência (MAE); padrão 0,45", + "duration_weight": "Etapa 4: o quanto a concordância de duração afeta a pontuação final (padrão 0,22)", + "energy_weight": "Etapa 4: o quanto a concordância de energia afeta a pontuação final (padrão 0,22)", + "profile_match_min_duration_ratio": "Etapa 1: duração mínima do ciclo como fração da duração do perfil; padrão 0,1 (10%)", + "profile_match_max_duration_ratio": "Etapa 1: duração máxima do ciclo como fração da duração do perfil; padrão 1,5 (150%)", + "keep_min_score": "Etapa 2: pontuação mínima para permanecer na disputa; o padrão 0,1 admite candidatos fracos, etapas posteriores escolhem o melhor", + "dtw_blend": "Etapa 3: 0 = apenas pontuação central, 1 = apenas pontuação DTW, 0,5 = mistura equilibrada (padrão)", + "dtw_ensemble_w": "Etapa 3 (modo ensemble): peso em L1 escalado vs DTW derivativo; o padrão 0,7 favorece a sensibilidade ao nível", + "dtw_ddtw_scale": "Etapa 3: distância de meia saturação DDTW; menor = mais sensível à forma (padrão 30)", + "dtw_refine_top_n": "Etapa 3: candidatos reavaliados pelo DTW; aumente para 7-9 se o perfil correto se classificar em 4.º-5.º (padrão 5)", + "duration_scale": "Etapa 4: log-razão em que a concordância de duração é reduzida à metade; menor = penalidade mais severa (padrão 0,175)", + "energy_scale": "Etapa 4: log-razão em que a concordância de energia é reduzida à metade; menor = penalidade mais severa (padrão 0,25)" }, "col": { "profile_tip": "Nome do programa correspondente. Sem rótulo significa que nenhum perfil correspondeu no fim do ciclo.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Otimizar: {param}" + }, + "pg_detail": { + "simulate": "Simular ciclo" + }, + "split": { + "apply": "Dividindo o ciclo" + }, + "trim": { + "apply": "Recortando o ciclo" + }, + "merge": { + "apply": "Combinando ciclos" + }, + "rebuild": { + "envelopes": "Reconstruindo envelopes" + }, + "cancelling": "Cancelando..." + }, + "setup": { + "hdr": { + "card": "Configuração do dispositivo", + "healthy_chip": "Configuração concluída" + }, + "phase0": { + "washer": "O WashData já está detectando seus ciclos. Grave o primeiro ciclo para ativar os nomes dos programas e as estimativas de tempo.", + "dishwasher": "O WashData está monitorando. As máquinas de lavar louça têm ciclos complexos – é altamente recomendável gravar o primeiro ciclo. Se um ciclo detectado demorar muito, use o editor de ciclos para recortá-lo antes de salvá-lo como perfil.", + "generic": "O WashData está monitorando. Grave ou rotule um ciclo detectado para começar a criar perfis." + }, + "phase1a": { + "labelled": "Ótimo começo – seu primeiro programa está salvo. Para dados mais precisos, considere gravar o próximo ciclo com o widget de gravação." + }, + "phase1b": { + "recorded": "Sua gravação foi salva como {profile_name}. Agora grave ou rotule seus outros programas habituais para ampliar a cobertura." + }, + "phase1c": { + "verify": "Você tem {count} programas da comunidade. Execute um ciclo para verificar se o WashData os reconhece corretamente – a correspondência vai melhorar à medida que o dispositivo acumula seu próprio histórico." + }, + "phase2": { + "cluster": "O WashData encontrou {count} ciclos que não correspondem a nenhum programa salvo – eles se parecem entre si. Deseja criar um novo perfil para eles?", + "unmatched": "Seu último ciclo não correspondeu a nenhum programa salvo. É um novo programa?" + }, + "phase3": { + "suggestions": "O WashData tem recomendações de configuração baseadas no histórico de ciclos – revise-as para melhorar a precisão da detecção.", + "groups": "Alguns dos seus perfis parecem o mesmo programa em temperaturas diferentes. Organize-os em um grupo para uma correspondência mais confiável.", + "phases": "Adicione fases de programa a {profile_name} para estimativas mais precisas do tempo restante." + }, + "phase4": { + "healthy": "Este dispositivo está totalmente configurado ({profile_count} perfis)." + }, + "cta": { + "start_recording": "Iniciar gravação", + "label_detected_cycle": "Já tenho um ciclo detectado – rotulá-lo em vez disso", + "browse_cycles": "Explorar ciclos para rotular outro", + "view_profiles": "Ver seus perfis", + "create_from_cluster": "Criar perfil a partir destes ciclos", + "create_profile": "Criar um perfil para este programa", + "review_suggestions": "Revisar sugestões", + "organise_profiles": "Organizar perfis", + "configure_phases": "Configurar fases", + "skip_step": "Pular esta etapa", + "skip_forever": "Não mostrar novamente", + "hide_guidance": "Ocultar guia", + "show_guidance": "Exibir guia" } } } diff --git a/custom_components/ha_washdata/translations/panel/pt.json b/custom_components/ha_washdata/translations/panel/pt.json index 2234c6b..ceb3fa7 100644 --- a/custom_components/ha_washdata/translations/panel/pt.json +++ b/custom_components/ha_washdata/translations/panel/pt.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "{n} anomalias detectadas (por exemplo, porta aberta a meio do ciclo) — abra para as ver no gráfico", + "artifact_tip": "{n} anomalias detectadas (por exemplo, porta aberta a meio do ciclo) – abra para as ver no gráfico", "auto": "(detectado automaticamente)", "built_in_tag": "incorporado", "declining": "↘ a declinar", "energy_low": "Energia abaixo do habitual", "energy_spike": "Energia acima do habitual", "fair_fit": "qualidade aceitável", - "fair_fit_tip": "Consistência de correspondência moderada — alguns ciclos atribuídos a este perfil têm pontuações de confiança mais baixas. Rotule mais ciclos ou re-grave o perfil para melhorar a precisão.", + "fair_fit_tip": "Consistência de correspondência moderada – alguns ciclos atribuídos a este perfil têm pontuações de confiança mais baixas. Rotule mais ciclos ou re-grave o perfil para melhorar a precisão.", "feedback_requested": "Feedback solicitado", "golden_cycle": "Ciclo de referência gravado", "improving": "↗ a melhorar", @@ -15,7 +15,7 @@ "needs_review": "Precisa de revisão", "overrun": "Demorou mais do que o habitual", "poor_fit": "qualidade fraca", - "poor_fit_tip": "Histórico de correspondência inconsistente — considere reconstruir este perfil", + "poor_fit_tip": "Histórico de correspondência inconsistente – considere reconstruir este perfil", "restart_gap_tip": "{n} lacuna de reinício do HA: o traçado de potência tem uma lacuna", "reviewed": "Revisto", "steady": "→ estável", @@ -57,7 +57,7 @@ "create_profile": "+ Criar perfil", "delete": "Eliminar", "delete_group": "Eliminar grupo", - "delete_group_tip": "Eliminar apenas este grupo — os perfis dos membros são mantidos", + "delete_group_tip": "Eliminar apenas este grupo – os perfis dos membros são mantidos", "delete_profile": "Eliminar perfil", "discard": "Descartar", "discard_tip": "Descartar o traço gravado sem guardar", @@ -87,7 +87,7 @@ "on_cycle_finished": "Ao terminar o ciclo", "on_cycle_started": "Ao iniciar o ciclo", "pause_cycle": "Pausar", - "pause_cycle_tip": "Pausar o ciclo em curso — o aparelho retomará onde parou", + "pause_cycle_tip": "Pausar o ciclo em curso – o aparelho retomará onde parou", "pg_apply_device": "Aplicar ao dispositivo", "pg_autofill": "Preenchimento automático", "play": "Reproduzir", @@ -97,8 +97,8 @@ "process_tip": "Guardar o traço gravado como um perfil novo ou existente", "rebuild": "Reconstruir envelopes", "rebuild_envelope": "Reconstruir envelope", - "rebuild_tip": "Recalcular o envelope de potência esperado (banda mín/máx) para todos os perfis a partir dos seus ciclos rotulados — executar após rotular novos ciclos ou corrigir os existentes", - "rec_start_tip": "Iniciar a gravação do traço de potência do aparelho — iniciar imediatamente antes de executar um ciclo", + "rebuild_tip": "Recalcular o envelope de potência esperado (banda mín/máx) para todos os perfis a partir dos seus ciclos rotulados – executar após rotular novos ciclos ou corrigir os existentes", + "rec_start_tip": "Iniciar a gravação do traço de potência do aparelho – iniciar imediatamente antes de executar um ciclo", "rec_stop": "Parar", "rec_stop_tip": "Parar a gravação e manter o traço capturado para revisão", "record": "Iniciar gravação", @@ -182,7 +182,7 @@ }, "attn_sub": "Corrija os conflitos antes de guardar", "attn_title": "{n} conflito{s} de definições", - "settings_banner": "{n} conflito{s} de definições — verifique as secções realçadas e corrija antes de guardar.", + "settings_banner": "{n} conflito{s} de definições – verifique as secções realçadas e corrija antes de guardar.", "settings_banner_btn": "Ir para o primeiro", "confidence": { "auto": "Deve ser superior ou igual ao Limiar de correspondência ({match})", @@ -451,9 +451,9 @@ "show_expected": "Mostrar sobreposição da curva esperada (perfil correspondente, laranja)", "show_raw": "Mostrar o interruptor de sinal bruto da tomada no gráfico de potência em tempo real", "sparkline": "Tendência recente da duração dos ciclos", - "stage2": "Etapa 2 — semelhança principal", - "stage3": "Etapa 3 — DTW", - "stage4": "Etapa 4 — concordância", + "stage2": "Etapa 2 – semelhança principal", + "stage3": "Etapa 3 – DTW", + "stage4": "Etapa 4 – concordância", "status": "Estado", "tail_trim": "Corte final (s)", "timer_auto_pause": "Pausa automática", @@ -561,7 +561,12 @@ "flags": "Indicadores", "include_phase_map": "Incluir mapa de fases", "include_settings": "Incluir definições de deteção e reconhecimento", - "show_contributor": "Mostrar nomes dos colaboradores" + "show_contributor": "Mostrar nomes dos colaboradores", + "task_pg_detail": "Simular ciclo", + "task_split": "A dividir o ciclo", + "task_trim": "A recortar o ciclo", + "task_merge": "A combinar ciclos", + "task_rebuild": "A reconstruir envelopes" }, "log": { "all_levels": "Todos os níveis", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Caiu abaixo da banda de potência habitual durante ~{n}s.", "artifact_footer": "Realçadas no gráfico acima. São artefactos transitórios (por exemplo, a porta foi aberta a meio do ciclo), não necessariamente problemas.", "artifact_header": "{n} anomalias detectadas durante este ciclo", - "artifact_pause_detail": "A potência caiu quase a zero durante ~{n}s e depois retomou — provavelmente a porta foi aberta a meio do ciclo ou o ciclo foi pausado.", + "artifact_pause_detail": "A potência caiu quase a zero durante ~{n}s e depois retomou – provavelmente a porta foi aberta a meio do ciclo ou o ciclo foi pausado.", "artifact_spike_detail": "Ultrapassou a banda de potência habitual durante ~{n}s.", "auto_label_intro": "Atribua perfis a ciclos sem rótulo cuja confiança de correspondência ultrapasse o limiar.", "automations_intro": "O WashData dispara os eventos {start} / {end} e expõe entidades, por isso as notificações e ações são melhor criadas como automatizações normais do Home Assistant. As automatizações que utilizam este dispositivo aparecem abaixo.", @@ -654,10 +659,10 @@ "collecting_data": "A recolher dados - mais {need} ciclo{plural} antes de o ajuste fino poder começar ({current}/{min}).", "compare_overlay_profiles": "Sobrepor perfis (ténue)", "compare_profiles_tip": "Sobreponha outros envelopes de perfil no gráfico acima para ver qual deles melhor se adequa a este ciclo.", - "compare_selected_cycles": "Ciclos seleccionados (sólido) — mostrar / ocultar", + "compare_selected_cycles": "Ciclos seleccionados (sólido) – mostrar / ocultar", "consider_new_profile": "Considere criar um novo perfil.", "coverage_gap": "os ciclos recentes não têm perfil correspondente ({pct}% dos últimos 30).", - "coverage_gap_similar_cycles": "{count} ciclos semelhantes sem rótulo encontrados — crie um perfil para começar a correspondê-los.", + "coverage_gap_similar_cycles": "{count} ciclos semelhantes sem rótulo encontrados – crie um perfil para começar a correspondê-los.", "cycles_deleted": "{count} ciclo(s) eliminado(s)", "enough_data": "Dados suficientes para aprender ({current}/{min} ciclos).", "export_description": "Exporte todos os perfis e ciclos para JSON ou restaure a partir de uma exportação anterior.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modelos ajustados a esta máquina.", "ml_loading": "a carregar ML…", "ml_settings_intro": "Dois comutadores independentes: um aplica os modelos durante um ciclo, o outro permite ao WashData ajustá-los à sua máquina ao longo do tempo.", - "name_first_program": "Já tem ciclos suficientes — atribua um nome ao seu primeiro programa para começar a corresponder.", + "name_first_program": "Já tem ciclos suficientes – atribua um nome ao seu primeiro programa para começar a corresponder.", "near_duplicate_cluster": "cluster de perfis quase duplicados detectado. O agrupamento permite que a correspondência escolha de forma fiável entre perfis semelhantes (por exemplo, o mesmo programa a diferentes temperaturas/centrifugações).", "no_cycles_match": "Nenhum ciclo corresponde ao filtro actual.", "no_cycles_profile": "Nenhum ciclo para este perfil.", @@ -694,12 +699,12 @@ "no_cycles_yet": "Nenhum ciclo gravado ainda.", "no_device_selected": "Nenhum dispositivo seleccionado.", "no_devices": "Nenhum dispositivo WashData configurado ainda.", - "no_envelope": "Nenhum envelope ainda — reconstrua após rotular os ciclos.", + "no_envelope": "Nenhum envelope ainda – reconstrua após rotular os ciclos.", "no_envelope_overlay": "Nenhum envelope disponível para sobrepor.", - "no_fine_tuned": "Nada ajustado ainda — o WashData está a usar os seus modelos incorporados.", + "no_fine_tuned": "Nada ajustado ainda – o WashData está a usar os seus modelos incorporados.", "no_logs": "Nenhum registo em memória ainda.", "no_maintenance": "Ainda não há manutenções registadas.", - "no_match_yet": "Nenhuma tentativa de correspondência ainda — isto é preenchido durante um ciclo em execução.", + "no_match_yet": "Nenhuma tentativa de correspondência ainda – isto é preenchido durante um ciclo em execução.", "no_other_users": "Nenhum outro utilizador do Home Assistant encontrado.", "no_phases": "Nenhuma fase definida.", "no_phases_assigned": "Nenhuma fase atribuída.", @@ -713,7 +718,7 @@ "notify_services_hint": "Use os IDs de serviço {entity} (separados por vírgulas para múltiplos). Variáveis de modelo: {vars}.", "old_actions_warning": "Configurado com o antigo editor de acções (agora removido). Ainda são accionadas em eventos de ciclo, mas já não podem ser editadas aqui. Converta-as numa automatização normal ou remova-as.", "onboarding_progress": "{n} / 3 ciclos observados", - "onboarding_watching": "Utilize o eletrodoméstico normalmente — o WashData está a observar. Após 3 ciclos, a correspondência de programas terá início.", + "onboarding_watching": "Utilize o eletrodoméstico normalmente – o WashData está a observar. Após 3 ciclos, a correspondência de programas terá início.", "pending_feedback": "Feedback de detecção pendente", "performance_trend": "Tendência de desempenho ({n} ciclos)", "pg_analysis_empty": "Carregue um ciclo para ver a análise de correspondência.", @@ -748,13 +753,13 @@ "restart_gap_footer": "Destacado no gráfico. Dados de potência estão em falta para estes intervalos; a correspondência usou apenas leituras reais.", "restart_gap_header": "{n} lacuna de reinício do HA durante este ciclo", "restart_gap_item": "{dur} lacuna", - "review_confirm_help": "Confirme se este ciclo foi detectado correctamente. As suas revisões treinam o modelo na sua máquina — quanto mais ciclos confirmar, melhor ficam a correspondência e a pontuação de saúde. Um Bom/Mau rápido é suficiente.", + "review_confirm_help": "Confirme se este ciclo foi detectado correctamente. As suas revisões treinam o modelo na sua máquina – quanto mais ciclos confirmar, melhor ficam a correspondência e a pontuação de saúde. Um Bom/Mau rápido é suficiente.", "review_in_settings": "Rever nas Definições", "review_notes_placeholder": "Notas (opcional)", "review_notes_tip": "Notas em texto livre para sua referência pessoal. Não são usadas pela correspondência ou pelo treino.", - "review_profile_tip": "O programa com que este ciclo está rotulado. Se o programa detectado automaticamente estava errado, corrija-o aqui — a rotulagem ensina a correspondência para ciclos futuros.", + "review_profile_tip": "O programa com que este ciclo está rotulado. Se o programa detectado automaticamente estava errado, corrija-o aqui – a rotulagem ensina a correspondência para ciclos futuros.", "review_quality_tip": "A qualidade deste ciclo. Bom = um exemplo perfeito deste programa; Mau = detectado mas ruidoso ou atípico; Inutilizável = detectado incorrectamente (combinado, truncado ou espúrio). Determina a pontuação de saúde e quais os ciclos permitidos para treinar o modelo.", - "review_recorded_tip": "Marque este ciclo como referência escolhida a dedo para o seu programa — o mesmo papel de um ciclo gravado manualmente. Os ciclos de referência são sempre mantidos, semeiam o modelo de correspondência e nunca são removidos pela limpeza. (Esta é a bandeira \"dourada\"/gravada; ambas são a mesma coisa.)", + "review_recorded_tip": "Marque este ciclo como referência escolhida a dedo para o seu programa – o mesmo papel de um ciclo gravado manualmente. Os ciclos de referência são sempre mantidos, semeiam o modelo de correspondência e nunca são removidos pela limpeza. (Esta é a bandeira \"dourada\"/gravada; ambas são a mesma coisa.)", "review_tags_tip": "Etiquetas opcionais que descrevem o que correu mal com este ciclo, para que o treino e a limpeza possam ter isso em conta.", "review_to_cycles": "Abrir a fila de revisão de Ciclos", "saving_triggers_reload": "Guardar desencadeia um recarregamento da integração. As entidades HA podem ficar brevemente indisponíveis.", @@ -763,7 +768,7 @@ "setting_changed": "Alterado de {old} para {new} em {date}", "settings_basic_note": "A mostrar as definições essenciais. Mude para Avançado para ver a lista completa.", "shape_drift_advisory": "⚠ Desvio de forma", - "shape_drift_detail": "O padrão de potência para este perfil mudou ao longo do tempo — possível desgaste do electrodoméstico ou manutenção necessária (por exemplo, descalcificação, limpeza do filtro).", + "shape_drift_detail": "O padrão de potência para este perfil mudou ao longo do tempo – possível desgaste do electrodoméstico ou manutenção necessária (por exemplo, descalcificação, limpeza do filtro).", "show_all_settings": "Mostrar todas as definições", "showing_suggestions": "A mostrar a definição {count} com sugestões.", "split_intro": "Clique no gráfico para adicionar ou remover um ponto de divisão, ou detecte automaticamente por intervalos de inactividade. Cada segmento resultante pode ter o seu próprio perfil.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Reversão falhou: {error}", "toast_reverted": "{key} revertido", "toast_save_failed": "Guardar falhou: {error}", - "trend_duration_longer": "Duração com tendência crescente ({pct}%/ciclo) — média recente {avg}", - "trend_duration_shorter": "Duração com tendência decrescente ({pct}%/ciclo) — média recente {avg}", + "trend_duration_longer": "Duração com tendência crescente ({pct}%/ciclo) – média recente {avg}", + "trend_duration_shorter": "Duração com tendência decrescente ({pct}%/ciclo) – média recente {avg}", "trend_energy_down": "Energia com tendência decrescente ({pct}%/ciclo)", - "trend_energy_up": "Energia com tendência crescente ({pct}%/ciclo) — média recente {avg}", + "trend_energy_up": "Energia com tendência crescente ({pct}%/ciclo) – média recente {avg}", "trim_intro": "Arraste os puxadores vermelhos ou introduza valores. Tudo o que estiver fora da janela é removido.", "tuning_suggestions_available": "{count} sugestão de ajuste disponível a partir dos ciclos observados. Aparecem junto dos campos relevantes.", "unsure_detected_prefix": "O WashData não tem a certeza de que detectou", @@ -857,7 +862,9 @@ "share_guidelines_title": "Antes de partilhar", "store_download_device_intro": "Adotar todos os programas partilhados e os seus ciclos de referência neste dispositivo. Os seus ciclos gravados e estatísticas não são afetados.", "store_share_device_intro": "Carregar {brand} {model} com os ciclos de referência que selecionar. Outros utilizadores com o mesmo electrodoméstico podem adotar os seus programas. As entradas são revistas antes de aparecerem publicamente.", - "share_profile_no_cycles": "Sem ciclos de referência -- marque um ciclo como ⭐ no separador Ciclos para incluir este perfil" + "share_profile_no_cycles": "Sem ciclos de referência -- marque um ciclo como ⭐ no separador Ciclos para incluir este perfil", + "advisory_phase_inconsistent": "'{name}' parece misturar diferentes programas ou temperaturas - os seus ciclos aquecem durante períodos de tempo muito diferentes. Dividi-lo em perfis separados (p. ex. por temperatura) melhorará a correspondência e as estimativas de tempo.", + "advisory_phase_inconsistent_title": "⚠ Possíveis programas misturados" }, "pg_desc": { "abrupt_drop_watts": "Queda repentina tratada como fim imediato", @@ -869,12 +876,19 @@ "start_duration_threshold": "Segundos acima do limiar para confirmar o início", "start_threshold_w": "Watts mínimos para contar como iniciado", "stop_threshold_w": "Abaixo disto, a máquina conta como desligada", - "dtw_bandwidth": "Quanta deformação temporal a correspondência de forma permite", - "corr_weight": "Peso da forma da curva face ao nível de potência na correspondência", - "duration_weight": "Quanto a concordância de duração afeta a correspondência", - "energy_weight": "Quanto a concordância de energia afeta a correspondência", - "profile_match_min_duration_ratio": "Ciclo mais curto (face ao perfil) ainda autorizado a corresponder", - "profile_match_max_duration_ratio": "Ciclo mais longo (face ao perfil) ainda autorizado a corresponder" + "dtw_bandwidth": "Fase 3: banda de deformação Sakoe-Chiba (0 = DTW desativado; predefinição 0,2 = 20% do comprimento do ciclo)", + "corr_weight": "Fase 2: equilíbrio entre a forma da curva (correlação) e o nível de potência (MAE); predefinição 0,45", + "duration_weight": "Fase 4: o quanto a concordância de duração afeta a pontuação final (predefinição 0,22)", + "energy_weight": "Fase 4: o quanto a concordância de energia afeta a pontuação final (predefinição 0,22)", + "profile_match_min_duration_ratio": "Fase 1: duração mínima do ciclo como fração da duração do perfil; predefinição 0,1 (10%)", + "profile_match_max_duration_ratio": "Fase 1: duração máxima do ciclo como fração da duração do perfil; predefinição 1,5 (150%)", + "keep_min_score": "Fase 2: pontuação mínima para permanecer em jogo; a predefinição 0,1 admite candidatos fracos, as fases seguintes escolhem o melhor", + "dtw_blend": "Fase 3: 0 = apenas pontuação central, 1 = apenas pontuação DTW, 0,5 = mistura equilibrada (predefinição)", + "dtw_ensemble_w": "Fase 3 (modo ensemble): peso em L1 escalado vs DTW derivativo; a predefinição 0,7 favorece a sensibilidade ao nível", + "dtw_ddtw_scale": "Fase 3: distância de semissaturação DDTW; menor = maior sensibilidade à forma (predefinição 30)", + "dtw_refine_top_n": "Fase 3: candidatos reavaliados pelo DTW; aumente para 7-9 se o perfil correto se classificar em 4.º-5.º (predefinição 5)", + "duration_scale": "Fase 4: log-razão em que a concordância de duração é reduzida a metade; menor = penalidade mais severa (predefinição 0,175)", + "energy_scale": "Fase 4: log-razão em que a concordância de energia é reduzida a metade; menor = penalidade mais severa (predefinição 0,25)" }, "phase_desc": { "anti_crease": "Rotações curtas ocasionais após a conclusão para reduzir vincos.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Sinais externos opcionais: um gatilho de fim, um sensor de porta, um interruptor de pausa e o lembrete de descarga.", "label": "Gatilhos e Portas" + }, + "phase_eta": { + "label": "Tempo restante", + "intro": "Tempo restante com base nas fases, para máquinas cuja duração do ciclo depende da temperatura ou da centrifugação." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Preço fixo por kWh usado para valores de custos quando nenhuma entidade de preço real é definida acima.", "label": "Preço Estático de Energia (por kWh)" }, + "energy_sensor": { + "doc": "Contador de energia cumulativo opcional (total_increasing em kWh/Wh, por ex. o contador de consumo total da própria tomada). Quando definido, a energia indicada de cada ciclo é obtida a partir da diferença deste contador entre o início e o fim, o que evita a subcontagem resultante da integração de um sensor de potência que reporta lentamente. Recorre ao valor integrado se a leitura estiver em falta, se a sua unidade for desconhecida ou se o delta não for positivo. Deixe em branco para continuar a integrar o sensor de potência.", + "label": "Entidade do Contador de Energia" + }, "expose_debug_entities": { "doc": "Publique entidades de HA de diagnóstico extras (confiança de correspondência, ambiguidade, estados internos). Off mantém a lista de entidades limpa para uso normal.", "label": "Expor Entidades de Depuração" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Mostrar a atribuição \"por \" nos electrodomésticos da comunidade e nos ciclos de referência." + }, + "enable_phase_matching": { + "label": "Tempo restante com base nas fases", + "doc": "Divide cada ciclo em curso em fases (aquecimento, lavagem, centrifugação) e distribui o tempo restante por fase, combinado com a estimativa clássica - apoiando-se na repartição por fases no início do ciclo e na estimativa clássica perto do fim. Isto personaliza a contagem decrescente de acordo com o tempo que a sua máquina realmente aquece e funciona, o que é mais percetível na primeira metade de um ciclo. Desativado = apenas a estimativa clássica. Apenas a apresentação do tempo restante é afetada; a correspondência de programas e a deteção de ciclos permanecem inalteradas." + }, + "keep_min_score": { + "label": "Score mín de correspondência", + "doc": "Pontuação de similaridade mínima que um candidato precisa para permanecer em jogo. A predefinição 0,1 é deliberadamente permissiva - admite mesmo candidatos fracos e depende das fases seguintes para encontrar a melhor correspondência. Aumente para eliminar perfis improváveis mais cedo; reduza para ampliar o pool inicial de candidatos." + }, + "dtw_blend": { + "label": "Fusão Warp", + "doc": "O quanto o score de alinhamento por deformação temporal (DTW) substitui o score central da fase 2. 0 = usar apenas o score da fase 2, 1 = usar apenas o score DTW, 0,5 (predefinição) = mistura equilibrada. Aumente para confiar mais no alinhamento Warp quando os programas têm níveis de potência semelhantes mas padrões temporais diferentes." + }, + "dtw_ensemble_w": { + "label": "Mix ensemble Warp", + "doc": "No modo DTW ensemble (a predefinição), o peso em L1 escalado versus DTW derivativo (DDTW). 1,0 = apenas L1 escalado, 0 = apenas DDTW, 0,7 = predefinição. A variante L1 escalada compara os níveis de potência; a variante derivativa reage a como a potência muda ao longo do tempo. Ensemble combina ambos." + }, + "dtw_ddtw_scale": { + "label": "Escala Warp derivativa", + "doc": "Distância de semissaturação para a pontuação DTW derivativa. A pontuação é reduzida a metade quando a distância DDTW é igual a este valor. Menor = mais sensível às diferenças de forma. A predefinição 30 está calibrada para traços de potência típicos de eletrodomésticos. Afeta apenas os modos DTW ensemble e ddtw." + }, + "dtw_refine_top_n": { + "label": "Contagem de refinamento Warp", + "doc": "Quantos candidatos principais da fase 2 são reavaliados pelo DTW. O DTW é mais dispendioso, por isso é aplicado apenas aos N melhores candidatos. Predefinição 5. Aumente (para 7-9) se o perfil correto às vezes só chega ao 4.º ou 5.º lugar após a fase 2 - o DTW pode recuperá-lo. Reduzir acelera ligeiramente a correspondência." + }, + "duration_scale": { + "label": "Nitidez de duração", + "doc": "Precisão da penalidade de concordância de duração na fase 4. Este é o log-razão em que o score de concordância é reduzido a metade. Menor = mais rigoroso: uma discordância de duração penaliza mais. A predefinição 0,175 corresponde a cerca de 18% de tolerância de duração a meio peso. Use em conjunto com o Peso de duração." + }, + "energy_scale": { + "label": "Nitidez de energia", + "doc": "Precisão da penalidade de concordância de energia. Menor = mais rigoroso: uma discordância de energia penaliza mais. A predefinição 0,25 é intencionalmente mais permissiva do que a Precisão de duração, porque a energia varia com a carga. Use em conjunto com o Peso de energia." } }, "setting_group": { @@ -1570,24 +1624,24 @@ "lower": "Etiqueta mais ciclos automaticamente; alguns podem ser mal identificados" }, "duration_tolerance": { - "higher": "Aceita um intervalo de duração mais amplo — correspondência mais flexível", - "lower": "Exige duração mais próxima — mais rigoroso, pode rejeitar ciclos invulgares" + "higher": "Aceita um intervalo de duração mais amplo – correspondência mais flexível", + "lower": "Exige duração mais próxima – mais rigoroso, pode rejeitar ciclos invulgares" }, "end_energy_threshold": { "higher": "Fecha apenas ciclos que consumiram energia substancial", "lower": "Também fecha ciclos curtos ou de baixa energia" }, "end_repeat_count": { - "higher": "Exige que o sinal de fim se repita mais vezes — mais robusto, ligeiramente mais lento", - "lower": "Termina após menos repetições — deteção mais rápida, maior risco de fim falso" + "higher": "Exige que o sinal de fim se repita mais vezes – mais robusto, ligeiramente mais lento", + "lower": "Termina após menos repetições – deteção mais rápida, maior risco de fim falso" }, "learning_confidence": { - "higher": "Aprende apenas com correspondências muito confiantes — mais lento mas mais fiável", + "higher": "Aprende apenas com correspondências muito confiantes – mais lento mas mais fiável", "lower": "Aprende com mais ciclos; o modelo pode ser menos preciso" }, "min_off_gap": { "higher": "Requer mais tempo inativo antes de iniciar um novo ciclo", - "lower": "Um breve repouso inicia um novo ciclo — execuções consecutivas podem ser divididas" + "lower": "Um breve repouso inicia um novo ciclo – execuções consecutivas podem ser divididas" }, "min_power": { "higher": "Menos sensível ao consumo em repouso; pode não detetar programas muito silenciosos", @@ -1598,24 +1652,24 @@ "lower": "Para um ciclo bloqueado mais cedo" }, "off_delay": { - "higher": "Mais tempo para pausas do aparelho — gere fases longas de molho ou secagem", - "lower": "Deteção de fim mais rápida — adequado para aparelhos com ligação/desligação limpa" + "higher": "Mais tempo para pausas do aparelho – gere fases longas de molho ou secagem", + "lower": "Deteção de fim mais rápida – adequado para aparelhos com ligação/desligação limpa" }, "profile_match_threshold": { "higher": "Confirma apenas correspondências de alta confiança; mais ciclos ficam sem etiqueta", "lower": "Corresponde a mais ciclos; alguns podem estar ligeiramente errados" }, "running_dead_zone": { - "higher": "Ignora breves picos de potência em repouso — menos sensível ao ruído", - "lower": "Responde a impulsos mais curtos — deteta atividade transitória rápida" + "higher": "Ignora breves picos de potência em repouso – menos sensível ao ruído", + "lower": "Responde a impulsos mais curtos – deteta atividade transitória rápida" }, "start_threshold_w": { "higher": "Evita arranques falsos por breves picos de potência", "lower": "Deteta programas de baixa potência; pode ser ativado por ruído" }, "stop_threshold_w": { - "higher": "Termina o ciclo mais cedo — pode fechar durante uma pausa", - "lower": "Aguarda mais para terminar — menos conclusões prematuras" + "higher": "Termina o ciclo mais cedo – pode fechar durante uma pausa", + "lower": "Aguarda mais para terminar – menos conclusões prematuras" }, "watchdog_interval": { "higher": "Fases silenciosas mais longas permitidas; menos paragens forçadas falsas", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Otimizar: {param}" + }, + "pg_detail": { + "simulate": "Simular ciclo" + }, + "split": { + "apply": "A dividir o ciclo" + }, + "trim": { + "apply": "A recortar o ciclo" + }, + "merge": { + "apply": "A combinar ciclos" + }, + "rebuild": { + "envelopes": "A reconstruir envelopes" + }, + "cancelling": "A cancelar..." + }, + "setup": { + "hdr": { + "card": "Configuração do dispositivo", + "healthy_chip": "Configuração concluída" + }, + "phase0": { + "washer": "O WashData já está a detectar os seus ciclos. Grave o primeiro ciclo para activar os nomes dos programas e as estimativas de tempo.", + "dishwasher": "O WashData está a monitorizar. As máquinas de lavar louça têm ciclos complexos – recomenda-se vivamente gravar o primeiro ciclo. Se um ciclo detectado demorar demasiado, utilize o editor de ciclos para o cortar antes de o guardar como perfil.", + "generic": "O WashData está a monitorizar. Grave ou rotule um ciclo detectado para começar a criar perfis." + }, + "phase1a": { + "labelled": "Bom começo – o seu primeiro programa está guardado. Para dados mais precisos, considere gravar o próximo ciclo com o widget de gravação." + }, + "phase1b": { + "recorded": "A sua gravação foi guardada como {profile_name}. Agora grave ou rotule os outros programas que utiliza habitualmente para aumentar a cobertura." + }, + "phase1c": { + "verify": "Tem {count} programas da comunidade. Execute um ciclo para verificar se o WashData os reconhece correctamente – a correspondência melhorará à medida que o dispositivo acumula o seu próprio histórico." + }, + "phase2": { + "cluster": "O WashData detectou {count} ciclos que não correspondem a nenhum programa guardado – parecem-se entre si. Quer criar um novo perfil para eles?", + "unmatched": "O seu último ciclo não correspondeu a nenhum programa guardado. É um novo programa?" + }, + "phase3": { + "suggestions": "O WashData tem recomendações de definições baseadas no histórico de ciclos – reveja-as para melhorar a precisão de detecção.", + "groups": "Alguns dos seus perfis parecem o mesmo programa a temperaturas diferentes. Organize-os num grupo para uma melhor correspondência.", + "phases": "Adicione fases de programa a {profile_name} para estimativas mais precisas do tempo restante." + }, + "phase4": { + "healthy": "Este dispositivo está totalmente configurado ({profile_count} perfis)." + }, + "cta": { + "start_recording": "Iniciar gravação", + "label_detected_cycle": "Já tenho um ciclo detectado – rotulá-lo em vez disso", + "browse_cycles": "Explorar ciclos para rotular outro", + "view_profiles": "Ver os seus perfis", + "create_from_cluster": "Criar perfil a partir destes ciclos", + "create_profile": "Criar um perfil para este programa", + "review_suggestions": "Rever sugestões", + "organise_profiles": "Organizar perfis", + "configure_phases": "Configurar fases", + "skip_step": "Ignorar este passo", + "skip_forever": "Não voltar a mostrar", + "hide_guidance": "Ocultar guia", + "show_guidance": "Mostrar guia" } } } diff --git a/custom_components/ha_washdata/translations/panel/ro.json b/custom_components/ha_washdata/translations/panel/ro.json index 4abaa3c..95bef7c 100644 --- a/custom_components/ha_washdata/translations/panel/ro.json +++ b/custom_components/ha_washdata/translations/panel/ro.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "{n} anomalii detectate (de exemplu, ușa deschisă la jumătatea ciclului) — deschideți pentru a le vedea pe grafic", + "artifact_tip": "{n} anomalii detectate (de exemplu, ușa deschisă la jumătatea ciclului) – deschideți pentru a le vedea pe grafic", "auto": "(detectat automat)", "built_in_tag": "integrat", "declining": "↘ în scădere", "energy_low": "Energie mai mică decât de obicei", "energy_spike": "Energie mai mare decât de obicei", "fair_fit": "calitate acceptabilă", - "fair_fit_tip": "Consistență moderată a potrivirii — unele cicluri atribuite acestui profil au scoruri de încredere mai mici. Etichetați mai multe cicluri sau reînregistrați profilul pentru a îmbunătăți acuratețea.", + "fair_fit_tip": "Consistență moderată a potrivirii – unele cicluri atribuite acestui profil au scoruri de încredere mai mici. Etichetați mai multe cicluri sau reînregistrați profilul pentru a îmbunătăți acuratețea.", "feedback_requested": "S-a solicitat feedback", "golden_cycle": "Ciclu de referință înregistrat", "improving": "↗ în îmbunătățire", @@ -15,7 +15,7 @@ "needs_review": "Necesită revizuire", "overrun": "A durat mai mult decât de obicei", "poor_fit": "calitate slabă", - "poor_fit_tip": "Istoric de potrivire inconsecvent — luați în considerare reconstruirea acestui profil", + "poor_fit_tip": "Istoric de potrivire inconsecvent – luați în considerare reconstruirea acestui profil", "restart_gap_tip": "{n} gol la repornirea HA: traseul de putere are o lipsă", "reviewed": "Revizuit", "steady": "→ stabil", @@ -57,7 +57,7 @@ "create_profile": "+ Creați profil", "delete": "Ștergeți", "delete_group": "Ștergeți grupul", - "delete_group_tip": "Ștergeți numai acest grup — profilurile membrilor sunt păstrate", + "delete_group_tip": "Ștergeți numai acest grup – profilurile membrilor sunt păstrate", "delete_profile": "Ștergeți profilul", "discard": "Renunțați", "discard_tip": "Renunțați la urma înregistrată fără a salva", @@ -87,7 +87,7 @@ "on_cycle_finished": "La terminarea ciclului", "on_cycle_started": "La pornirea ciclului", "pause_cycle": "Pauză", - "pause_cycle_tip": "Puneți în pauză ciclul în curs — aparatul va relua de unde a rămas", + "pause_cycle_tip": "Puneți în pauză ciclul în curs – aparatul va relua de unde a rămas", "pg_apply_device": "Aplicați pe dispozitiv", "pg_autofill": "Completare automată", "play": "Redați", @@ -97,8 +97,8 @@ "process_tip": "Salvați urma înregistrată ca profil nou sau existent", "rebuild": "Reconstruiți plicurile", "rebuild_envelope": "Reconstruiți plicul", - "rebuild_tip": "Recalculați plicul de putere așteptat (banda min/max) pentru toate profilurile din ciclurile lor etichetate — rulați după etichetarea de noi cicluri sau corectarea celor vechi", - "rec_start_tip": "Porniți înregistrarea urmei de putere a aparatului — porniți imediat înainte de a rula un ciclu", + "rebuild_tip": "Recalculați plicul de putere așteptat (banda min/max) pentru toate profilurile din ciclurile lor etichetate – rulați după etichetarea de noi cicluri sau corectarea celor vechi", + "rec_start_tip": "Porniți înregistrarea urmei de putere a aparatului – porniți imediat înainte de a rula un ciclu", "rec_stop": "Opriți", "rec_stop_tip": "Opriți înregistrarea și păstrați urma capturată pentru revizuire", "record": "Porniți înregistrarea", @@ -182,7 +182,7 @@ }, "attn_sub": "Rezolvați conflictele înainte de salvare", "attn_title": "{n} conflict{s} de setări", - "settings_banner": "{n} conflict{s} de setări — verificați secțiunile evidențiate și remediați înainte de salvare.", + "settings_banner": "{n} conflict{s} de setări – verificați secțiunile evidențiate și remediați înainte de salvare.", "settings_banner_btn": "Salt la primul", "confidence": { "auto": "Trebuie să fie cel puțin egal cu Pragul de potrivire ({match})", @@ -451,9 +451,9 @@ "show_expected": "Afișați suprapunerea curbei așteptate (profil potrivit, portocaliu)", "show_raw": "Afișați comutatorul pentru semnalul brut al prizei în graficul de putere în timp real", "sparkline": "Tendința recentă a duratei ciclurilor", - "stage2": "Etapa 2 — similaritate de bază", - "stage3": "Etapa 3 — DTW", - "stage4": "Etapa 4 — concordanță", + "stage2": "Etapa 2 – similaritate de bază", + "stage3": "Etapa 3 – DTW", + "stage4": "Etapa 4 – concordanță", "status": "Stare", "tail_trim": "Tăiere finală (s)", "timer_auto_pause": "Pauză automată", @@ -561,7 +561,12 @@ "flags": "Semnale", "include_phase_map": "Includeți harta fazelor", "include_settings": "Includeți setările de detectare și potrivire", - "show_contributor": "Afișați numele contribuitorilor" + "show_contributor": "Afișați numele contribuitorilor", + "task_pg_detail": "Simulare ciclu", + "task_split": "Se divide ciclul", + "task_trim": "Se decupează ciclul", + "task_merge": "Se îmbină ciclurile", + "task_rebuild": "Se reconstruiesc anvelopele" }, "log": { "all_levels": "Toate nivelurile", @@ -645,7 +650,7 @@ "artifact_dip_detail": "A scăzut sub banda de putere obișnuită timp de ~{n}s.", "artifact_footer": "Evidențiate în graficul de mai sus. Acestea sunt artefacte tranzitorii (de exemplu, ușa deschisă la jumătatea ciclului), nu neapărat probleme.", "artifact_header": "{n} anomalii detectate în timpul acestui ciclu", - "artifact_pause_detail": "Puterea a scăzut aproape la zero timp de ~{n}s, apoi a revenit — probabil ușa a fost deschisă în mijlocul ciclului sau ciclul a fost întrerupt.", + "artifact_pause_detail": "Puterea a scăzut aproape la zero timp de ~{n}s, apoi a revenit – probabil ușa a fost deschisă în mijlocul ciclului sau ciclul a fost întrerupt.", "artifact_spike_detail": "A depășit banda de putere obișnuită timp de ~{n}s.", "auto_label_intro": "Atribuiți profiluri ciclurilor neetichetate a căror încredere de potrivire depășește pragul.", "automations_intro": "WashData declanșează evenimente {start} / {end} și expune entități, astfel că notificările și acțiunile sunt cel mai bine construite ca automatizări normale Home Assistant. Automatizările care utilizează acest dispozitiv apar mai jos.", @@ -654,10 +659,10 @@ "collecting_data": "Se colectează date - mai sunt necesare {need} ciclu{plural} înainte ca reglajul fin să poată începe ({current}/{min}).", "compare_overlay_profiles": "Suprapuneți profiluri (estompat)", "compare_profiles_tip": "Suprapuneți alte plicuri de profil pe graficul de mai sus pentru a vedea care dintre ele se potrivește cel mai bine acestui ciclu.", - "compare_selected_cycles": "Cicluri selectate (solid) — afișați / ascundeți", + "compare_selected_cycles": "Cicluri selectate (solid) – afișați / ascundeți", "consider_new_profile": "Luați în considerare crearea unui profil nou.", "coverage_gap": "ciclurile recente nu au un profil corespunzător ({pct}% din ultimele 30).", - "coverage_gap_similar_cycles": "{count} cicluri similare neetichetate găsite — creați un profil pentru a începe potrivirea lor.", + "coverage_gap_similar_cycles": "{count} cicluri similare neetichetate găsite – creați un profil pentru a începe potrivirea lor.", "cycles_deleted": "{count} ciclu(ri) șterse", "enough_data": "Date suficiente pentru a învăța ({current}/{min} cicluri).", "export_description": "Exportați toate profilurile și ciclurile în JSON sau restaurați dintr-un export anterior.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modele ajustate la această mașină.", "ml_loading": "se încarcă ML…", "ml_settings_intro": "Două comutatoare independente: unul aplică modelele în timpul unui ciclu, celălalt permite WashData să le ajusteze la mașina dvs. în timp.", - "name_first_program": "Aveți suficiente cicluri — denumiți primul program pentru a începe potrivirea.", + "name_first_program": "Aveți suficiente cicluri – denumiți primul program pentru a începe potrivirea.", "near_duplicate_cluster": "cluster de profiluri aproape duplicate detectat. Gruparea permite potrivirii să aleagă în mod fiabil între profiluri similare (de exemplu, același program la temperaturi/viteze de centrifugare diferite).", "no_cycles_match": "Niciun ciclu nu corespunde filtrului curent.", "no_cycles_profile": "Niciun ciclu pentru acest profil.", @@ -694,12 +699,12 @@ "no_cycles_yet": "Niciun ciclu înregistrat încă.", "no_device_selected": "Niciun dispozitiv selectat.", "no_devices": "Niciun dispozitiv WashData configurat încă.", - "no_envelope": "Niciun plic încă — reconstruiți după etichetarea ciclurilor.", + "no_envelope": "Niciun plic încă – reconstruiți după etichetarea ciclurilor.", "no_envelope_overlay": "Niciun plic disponibil pentru suprapunere.", - "no_fine_tuned": "Nimic ajustat încă — WashData folosește modelele sale integrate.", + "no_fine_tuned": "Nimic ajustat încă – WashData folosește modelele sale integrate.", "no_logs": "Nicio înregistrare în memorie încă.", "no_maintenance": "Nicio întreținere înregistrată încă.", - "no_match_yet": "Nicio tentativă de potrivire încă — aceasta se completează în timpul unui ciclu activ.", + "no_match_yet": "Nicio tentativă de potrivire încă – aceasta se completează în timpul unui ciclu activ.", "no_other_users": "Niciun alt utilizator Home Assistant găsit.", "no_phases": "Nicio fază definită.", "no_phases_assigned": "Nicio fază atribuită.", @@ -713,7 +718,7 @@ "notify_services_hint": "Utilizați ID-urile de serviciu {entity} (separate prin virgulă pentru mai multe). Variabile șablon: {vars}.", "old_actions_warning": "Configurat cu vechiul editor de acțiuni (acum eliminat). Încă se declanșează la evenimentele de ciclu, dar nu mai pot fi editate aici. Convertiți-le într-o automatizare normală sau eliminați-le.", "onboarding_progress": "{n} / 3 cicluri observate", - "onboarding_watching": "Utilizați aparatul în mod normal — WashData monitorizează. După 3 cicluri, va începe potrivirea programelor.", + "onboarding_watching": "Utilizați aparatul în mod normal – WashData monitorizează. După 3 cicluri, va începe potrivirea programelor.", "pending_feedback": "Feedback de detecție în așteptare", "performance_trend": "Tendință de performanță ({n} cicluri)", "pg_analysis_empty": "Încărcați un ciclu pentru a vedea analiza potrivirii.", @@ -748,13 +753,13 @@ "restart_gap_footer": "Evidențiat pe grafic. Datele de putere lipsesc pentru aceste intervale; potrivirea a folosit numai citiri reale.", "restart_gap_header": "{n} gol la repornirea HA în timpul acestui ciclu", "restart_gap_item": "{dur} gol", - "review_confirm_help": "Confirmați dacă acest ciclu a fost detectat corect. Recenziile dvs. antrenează modelul pe mașina dvs. — cu cât confirmați mai multe cicluri, cu atât obțineți o potrivire mai bună și un scor de sănătate mai bun. Un Bine/Rău rapid este suficient.", + "review_confirm_help": "Confirmați dacă acest ciclu a fost detectat corect. Recenziile dvs. antrenează modelul pe mașina dvs. – cu cât confirmați mai multe cicluri, cu atât obțineți o potrivire mai bună și un scor de sănătate mai bun. Un Bine/Rău rapid este suficient.", "review_in_settings": "Revizuiți în Setări", "review_notes_placeholder": "Note (opțional)", "review_notes_tip": "Note cu text liber pentru referință proprie. Nu sunt folosite de potrivire sau antrenament.", - "review_profile_tip": "Programul cu care acest ciclu este etichetat. Dacă programul detectat automat a fost greșit, corectați-l aici — etichetarea învață potrivirea pentru ciclurile viitoare.", + "review_profile_tip": "Programul cu care acest ciclu este etichetat. Dacă programul detectat automat a fost greșit, corectați-l aici – etichetarea învață potrivirea pentru ciclurile viitoare.", "review_quality_tip": "Cât de curat este acest ciclu. Bine = un exemplu model al acestui program; Rău = detectat dar zgomotos sau atipic; Inutilizabil = detectat greșit (combinat, trunchiat sau fals). Determină scorul de sănătate și ce cicluri sunt permise pentru antrenarea modelului.", - "review_recorded_tip": "Marcați acest lucru ca un ciclu de referință ales manual pentru programul său — același rol ca un ciclu înregistrat manual. Ciclurile de referință sunt întotdeauna păstrate, generează șablonul de potrivire și nu sunt niciodată eliminate de curățare. (Acesta este steagul \"de aur\"/înregistrat; ambele sunt același lucru.)", + "review_recorded_tip": "Marcați acest lucru ca un ciclu de referință ales manual pentru programul său – același rol ca un ciclu înregistrat manual. Ciclurile de referință sunt întotdeauna păstrate, generează șablonul de potrivire și nu sunt niciodată eliminate de curățare. (Acesta este steagul \"de aur\"/înregistrat; ambele sunt același lucru.)", "review_tags_tip": "Semnale opționale care descriu ce a mers prost cu acest ciclu, astfel încât antrenamentul și curățarea pot ține cont de acest lucru.", "review_to_cycles": "Deschideți coada de revizuire a Ciclurilor", "saving_triggers_reload": "Salvarea declanșează o reîncărcare a integrării. Entitățile HA pot apărea pe scurt ca indisponibile.", @@ -763,7 +768,7 @@ "setting_changed": "Modificat de la {old} la {new} pe {date}", "settings_basic_note": "Se afișează setările esențiale. Comutați la Avansat pentru lista completă.", "shape_drift_advisory": "⚠ Deriva formei", - "shape_drift_detail": "Tiparul de putere al acestui profil s-a schimbat în timp — posibilă uzură a aparatului sau întreținere necesară (ex. detartrare, curățarea filtrului).", + "shape_drift_detail": "Tiparul de putere al acestui profil s-a schimbat în timp – posibilă uzură a aparatului sau întreținere necesară (ex. detartrare, curățarea filtrului).", "show_all_settings": "Afișați toate setările", "showing_suggestions": "Se afișează setarea {count} cu sugestii.", "split_intro": "Faceți clic pe grafic pentru a adăuga sau elimina un punct de divizare, sau detectați automat prin pauze de inactivitate. Fiecare segment rezultat poate primi propriul profil.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Revenire eșuată: {error}", "toast_reverted": "{key} a revenit la valoarea anterioară", "toast_save_failed": "Salvare eșuată: {error}", - "trend_duration_longer": "Durata este în creștere ({pct}%/ciclu) — medie recentă {avg}", - "trend_duration_shorter": "Durata este în scădere ({pct}%/ciclu) — medie recentă {avg}", + "trend_duration_longer": "Durata este în creștere ({pct}%/ciclu) – medie recentă {avg}", + "trend_duration_shorter": "Durata este în scădere ({pct}%/ciclu) – medie recentă {avg}", "trend_energy_down": "Energia este în scădere ({pct}%/ciclu)", - "trend_energy_up": "Energia este în creștere ({pct}%/ciclu) — medie recentă {avg}", + "trend_energy_up": "Energia este în creștere ({pct}%/ciclu) – medie recentă {avg}", "trim_intro": "Trageți mânerele roșii sau introduceți valori. Tot ceea ce este în afara ferestrei este eliminat.", "tuning_suggestions_available": "{count} sugestie de ajustare disponibilă din ciclurile observate. Acestea apar lângă câmpurile relevante.", "unsure_detected_prefix": "WashData nu este sigur că a detectat", @@ -857,7 +862,9 @@ "share_guidelines_title": "Înainte de a partaja", "store_download_device_intro": "Adoptați fiecare program partajat și ciclurile sale de referință pe dispozitivul dvs. Propriile cicluri înregistrate și statisticile nu sunt afectate.", "store_share_device_intro": "Încărcați {brand} {model} cu ciclurile de referință pe care le selectați. Alții cu același aparat vă pot adopta programele. Intrările sunt revizuite înainte de a apărea public.", - "share_profile_no_cycles": "Niciun ciclu de referință -- marcați un ciclu ca ⭐ în fila Cicluri pentru a include acest profil" + "share_profile_no_cycles": "Niciun ciclu de referință -- marcați un ciclu ca ⭐ în fila Cicluri pentru a include acest profil", + "advisory_phase_inconsistent": "'{name}' pare să amestece programe sau temperaturi diferite - ciclurile sale se încălzesc pe durate foarte diferite. Împărțirea sa în profiluri separate (de ex. pe temperatură) va îmbunătăți potrivirea și estimările de timp.", + "advisory_phase_inconsistent_title": "⚠ Posibil programe amestecate" }, "phase_desc": { "anti_crease": "Tumblări scurte ocazionale după finalizare pentru a reduce șifonarea.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Semnale externe opționale: un declanșator de sfârșit, un senzor de ușă, un comutator de pauză și reamintirea de descărcare.", "label": "Declanșatoare și Uși" + }, + "phase_eta": { + "label": "Timp rămas", + "intro": "Timp rămas ținând cont de faze, pentru mașinile a căror durată de ciclu depinde de temperatură sau de centrifugare." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Preț fix pe kWh utilizat pentru cifrele de cost atunci când nu este stabilită mai sus nicio entitate de preț activ.", "label": "Preț Static Energie (pe kWh)" }, + "energy_sensor": { + "doc": "Contor de energie cumulativ opțional (total_increasing în kWh/Wh, de ex. propriul contor de consum total al prizei). Când este setat, energia raportată a fiecărui ciclu este preluată din diferența acestui contor între început și sfârșit, ceea ce evită subevaluarea care apare la integrarea unui senzor de putere care raportează lent. Revine la valoarea integrată dacă citirea lipsește, dacă unitatea sa este necunoscută sau dacă delta nu este pozitiv. Lăsați necompletat pentru a continua să integrați senzorul de putere.", + "label": "Entitate contor de energie" + }, "expose_debug_entities": { "doc": "Publicați entități suplimentare de diagnosticare HA (încrederea potrivirii, ambiguitatea, elementele interne de stare). Off menține lista de entități curată pentru utilizare normală.", "label": "Expuneți Entitățile de Depanare" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Afișați atribuirea \"de \" pe aparatele și ciclurile de referință ale comunității." + }, + "enable_phase_matching": { + "label": "Timp rămas în funcție de faze", + "doc": "Împarte fiecare ciclu în curs în faze (încălzire, spălare, centrifugare) și repartizează timpul rămas pe fiecare fază, combinat cu estimarea clasică - bazându-se pe repartiția pe faze la începutul ciclului și pe estimarea clasică spre final. Acest lucru personalizează numărătoarea inversă în funcție de cât de mult încălzește și funcționează efectiv mașina, ceea ce se observă cel mai mult în prima jumătate a unui ciclu. Dezactivat = doar estimarea clasică. Este afectată doar afișarea timpului rămas; potrivirea programelor și detectarea ciclurilor rămân neschimbate." + }, + "keep_min_score": { + "label": "Scor Min. de Potrivire", + "doc": "Scorul minim de similaritate de care are nevoie un candidat pentru a rămâne în cursa de potrivire. Implicit 0.1 este deliberat permisiv - admite chiar și candidați slabi, lăsând etapele ulterioare să găsească cea mai bună potrivire. Măriți pentru a elimina mai devreme profilurile improbabile; micșorați pentru a lărgi grupul inițial de candidați." + }, + "dtw_blend": { + "label": "Amestec Deformare", + "doc": "Cât din scorul de aliniere prin deformare temporală (DTW) înlocuiește scorul de bază al Etapei 2. 0 = utilizați doar scorul Etapei 2, 1 = utilizați doar scorul DTW, 0.5 (implicit) = amestec egal. Măriți pentru a vă baza mai mult pe alinierea prin deformare când programele au niveluri de putere similare dar tipare de sincronizare diferite." + }, + "dtw_ensemble_w": { + "label": "Mix Ansamblu Deformare", + "doc": "În modul ansamblu DTW (implicit), ponderea pe L1 scalat față de DTW derivativ (DDTW). 1.0 = tot scalat-L1, 0 = tot DDTW, 0.7 = implicit. Varianta scalat-L1 compară nivelurile de putere; varianta derivativă reacționează la modul în care puterea se schimbă în timp. Ansamblul combină ambele." + }, + "dtw_ddtw_scale": { + "label": "Scară Deformare Derivativă", + "doc": "Distanța de semi-saturație pentru scorul DTW derivativ. Scorul se înjumătățește când distanța DDTW egalează această valoare. Mai mic = mai sensibil la diferențele de formă. Implicit 30 este calibrat pentru urme tipice de putere ale aparatelor. Afectează doar modurile DTW ansamblu și ddtw." + }, + "dtw_refine_top_n": { + "label": "Contor Rafinare DTW", + "doc": "Câți candidați de top ai Etapei 2 sunt rescorați de DTW. DTW este mai costisitor computațional, deci este aplicat doar la primii N candidați. Implicit 5. Măriți (la 7-9) dacă profilul corect ajunge uneori doar pe locul 4 sau 5 după Etapa 2 - DTW îl poate recupera. Micșorarea accelerează ușor potrivirea." + }, + "duration_scale": { + "label": "Precizie Durată", + "doc": "Rigoarea penalizării acordului de durată a Etapei 4. Acesta este raportul logaritmic la care scorul de acord se înjumătățește. Mai mic = mai strict: o nepotrivire de durată afectează mai mult. Implicit 0.175 corespunde unei toleranțe de durată de aproximativ 18% la jumătate de pondere. Utilizați împreună cu Ponderea Duratei." + }, + "energy_scale": { + "label": "Precizie Energie", + "doc": "Rigoarea penalizării acordului de energie a Etapei 4. Mai mic = mai strict: o nepotrivire de energie afectează mai mult. Implicit 0.25 este intenționat mai permisiv decât Rigoarea Duratei deoarece energia variază în funcție de sarcină. Utilizați împreună cu Ponderea Energiei." } }, "setting_group": { @@ -1553,24 +1600,24 @@ "lower": "Etichetează mai multe cicluri automat; unele pot fi identificate greșit" }, "duration_tolerance": { - "higher": "Acceptă un interval de durată mai larg — potrivire mai flexibilă", - "lower": "Necesită durată mai apropiată — mai strict, poate respinge cicluri neobișnuite" + "higher": "Acceptă un interval de durată mai larg – potrivire mai flexibilă", + "lower": "Necesită durată mai apropiată – mai strict, poate respinge cicluri neobișnuite" }, "end_energy_threshold": { "higher": "Închide doar ciclurile care au consumat energie substanțială", "lower": "Închide și cicluri scurte sau cu consum redus de energie" }, "end_repeat_count": { - "higher": "Necesită repetarea semnalului de final de mai multe ori — mai robust, puțin mai lent", - "lower": "Se termină după mai puține repetări — detectare mai rapidă, risc mai mare de final fals" + "higher": "Necesită repetarea semnalului de final de mai multe ori – mai robust, puțin mai lent", + "lower": "Se termină după mai puține repetări – detectare mai rapidă, risc mai mare de final fals" }, "learning_confidence": { - "higher": "Învață doar din potriviri foarte sigure — mai lent dar mai fiabil", + "higher": "Învață doar din potriviri foarte sigure – mai lent dar mai fiabil", "lower": "Învață din mai multe cicluri; modelul poate fi mai puțin precis" }, "min_off_gap": { "higher": "Este nevoie de mai mult timp de inactivitate înainte de a începe un nou ciclu", - "lower": "O pauză scurtă declanșează un nou ciclu — rulările consecutive pot fi împărțite" + "lower": "O pauză scurtă declanșează un nou ciclu – rulările consecutive pot fi împărțite" }, "min_power": { "higher": "Mai puțin sensibil la consumul de repaus; poate rata programe foarte silențioase", @@ -1581,24 +1628,24 @@ "lower": "Oprește mai devreme un ciclu blocat" }, "off_delay": { - "higher": "Mai mult timp pentru pauzele aparatului — gestionează faze lungi de înmuiere sau uscare", - "lower": "Detectare mai rapidă a finalului — funcționează bine pentru aparate cu pornire/oprire clară" + "higher": "Mai mult timp pentru pauzele aparatului – gestionează faze lungi de înmuiere sau uscare", + "lower": "Detectare mai rapidă a finalului – funcționează bine pentru aparate cu pornire/oprire clară" }, "profile_match_threshold": { "higher": "Confirmă doar potriviri de înaltă încredere; mai multe cicluri rămân neetichetate", "lower": "Potrivește mai multe cicluri; unele pot fi ușor greșite" }, "running_dead_zone": { - "higher": "Ignoră scurte vârfuri de putere în repaus — mai puțin sensibil la zgomot", - "lower": "Răspunde la impulsuri mai scurte — detectează activitate tranzitorie rapidă" + "higher": "Ignoră scurte vârfuri de putere în repaus – mai puțin sensibil la zgomot", + "lower": "Răspunde la impulsuri mai scurte – detectează activitate tranzitorie rapidă" }, "start_threshold_w": { "higher": "Evită pornirile false cauzate de scurte vârfuri de putere", "lower": "Detectează programe de putere redusă; poate fi declanșat de zgomot" }, "stop_threshold_w": { - "higher": "Termină ciclul mai repede — se poate închide în timpul unei pauze", - "lower": "Așteaptă mai mult să termine — mai puține finalizări premature" + "higher": "Termină ciclul mai repede – se poate închide în timpul unei pauze", + "lower": "Așteaptă mai mult să termine – mai puține finalizări premature" }, "watchdog_interval": { "higher": "Faze silențioase mai lungi permise; mai puține opriri forțate false", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Secunde peste prag pentru a confirma pornirea", "start_threshold_w": "Wați minimi pentru a fi considerat pornit", "stop_threshold_w": "Sub această valoare, mașina este considerată oprită", - "dtw_bandwidth": "Câtă deformare în timp permite potrivirea formei", - "corr_weight": "Ponderea formei curbei față de nivelul de putere în potrivire", - "duration_weight": "Cât de mult influențează concordanța duratei potrivirea", - "energy_weight": "Cât de mult influențează concordanța energiei potrivirea", - "profile_match_min_duration_ratio": "Cel mai scurt ciclu (față de profil) încă permis să corespundă", - "profile_match_max_duration_ratio": "Cel mai lung ciclu (față de profil) încă permis să corespundă" + "dtw_bandwidth": "Etapa 3: banda de deformare Sakoe-Chiba (0 = DTW dezactivat; implicit 0.2 = 20% din lungimea ciclului)", + "corr_weight": "Etapa 2: echilibru între forma curbei (corelație) și nivelul de putere (MAE); implicit 0.45", + "duration_weight": "Etapa 4: cât de puternic influențează acordul de durată scorul final (implicit 0.22)", + "energy_weight": "Etapa 4: cât de puternic influențează acordul de energie scorul final (implicit 0.22)", + "profile_match_min_duration_ratio": "Etapa 1: lungimea minimă a ciclului ca fracție din durata profilului; implicit 0.1 (10%)", + "profile_match_max_duration_ratio": "Etapa 1: lungimea maximă a ciclului ca fracție din durata profilului; implicit 1.5 (150%)", + "keep_min_score": "Etapa 2: prag minim de scor pentru a rămâne în cursă; implicit 0.1 admite potriviri slabe, etapele ulterioare aleg cel mai bun", + "dtw_blend": "Etapa 3: 0 = doar scor de bază, 1 = doar scor DTW, 0.5 = amestec egal (implicit)", + "dtw_ensemble_w": "Etapa 3 (modul ansamblu): ponderea pe L1 scalat față de DTW derivativ; implicit 0.7 favorizează nivelul de putere", + "dtw_ddtw_scale": "Etapa 3: distanța de semi-saturație DDTW; mai mic = mai sensibil la formă (implicit 30)", + "dtw_refine_top_n": "Etapa 3: candidați rescorați de DTW; măriți la 7-9 dacă profilul corect se clasează pe locul 4-5 (implicit 5)", + "duration_scale": "Etapa 4: raportul logaritmic la care acordul de durată se înjumătățește; mai mic = penalizare mai strictă (implicit 0.175)", + "energy_scale": "Etapa 4: raportul logaritmic la care acordul de energie se înjumătățește; mai mic = penalizare mai strictă (implicit 0.25)" }, "col": { "profile_tip": "Numele programului corespunzător. Neetichetat înseamnă că niciun profil nu a corespuns la sfârșitul ciclului.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimizează: {param}" + }, + "pg_detail": { + "simulate": "Simulare ciclu" + }, + "split": { + "apply": "Se divide ciclul" + }, + "trim": { + "apply": "Se decupează ciclul" + }, + "merge": { + "apply": "Se îmbină ciclurile" + }, + "rebuild": { + "envelopes": "Se reconstruiesc anvelopele" + }, + "cancelling": "Se anulează..." + }, + "setup": { + "hdr": { + "card": "Configurarea dispozitivului", + "healthy_chip": "Configurare finalizată" + }, + "phase0": { + "washer": "WashData detectează deja ciclurile dvs. Înregistrați primul ciclu pentru a activa numele programelor și estimările de timp.", + "dishwasher": "WashData monitorizează. Mașinile de spălat vase au cicluri complexe – se recomandă insistent înregistrarea primului ciclu. Dacă un ciclu detectat durează prea mult, folosiți editorul de cicluri pentru a-l tăia înainte de a-l salva ca profil.", + "generic": "WashData monitorizează. Înregistrați sau etichetați un ciclu detectat pentru a începe să creați profiluri." + }, + "phase1a": { + "labelled": "Început bun – primul dvs. program este salvat. Pentru date mai curate, luați în considerare înregistrarea următorului ciclu cu widgetul de înregistrare." + }, + "phase1b": { + "recorded": "Înregistrarea dvs. a fost salvată ca {profile_name}. Acum înregistrați sau etichetați celelalte programe obișnuite pentru a extinde acoperirea." + }, + "phase1c": { + "verify": "Aveți {count} programe din comunitate. Rulați un ciclu pentru a verifica dacă WashData îl recunoaște corect – potrivirea se va îmbunătăți pe măsură ce dispozitivul își construiește propriul istoric." + }, + "phase2": { + "cluster": "WashData a observat {count} cicluri care nu corespund niciunui program salvat – acestea se aseamănă între ele. Doriți să creați un profil nou pentru ele?", + "unmatched": "Ultimul dvs. ciclu nu a corespuns niciunui program salvat. Este un program nou?" + }, + "phase3": { + "suggestions": "WashData are recomandări de setări bazate pe istoricul ciclurilor – revizuiți-le pentru a îmbunătăți acuratețea detectării.", + "groups": "Unele profiluri ale dvs. seamănă cu același program la temperaturi diferite. Organizați-le într-un grup pentru o potrivire mai bună.", + "phases": "Adăugați faze de program la {profile_name} pentru estimări mai precise ale timpului rămas." + }, + "phase4": { + "healthy": "Acest dispozitiv este complet configurat ({profile_count} profiluri)." + }, + "cta": { + "start_recording": "Porniți înregistrarea", + "label_detected_cycle": "Am deja un ciclu detectat – etichetați-l în schimb", + "browse_cycles": "Răsfoiți ciclurile pentru a eticheta altul", + "view_profiles": "Vizualizați profilurile", + "create_from_cluster": "Creați profil din aceste cicluri", + "create_profile": "Creați un profil pentru acesta", + "review_suggestions": "Revizuiți sugestiile", + "organise_profiles": "Organizați profilurile", + "configure_phases": "Configurați fazele", + "skip_step": "Omiteți acest pas", + "skip_forever": "Nu mai afișa", + "hide_guidance": "Ascundeți ghidul", + "show_guidance": "Afișați ghidul" } } } diff --git a/custom_components/ha_washdata/translations/panel/ru.json b/custom_components/ha_washdata/translations/panel/ru.json index 0ec9802..0689b17 100644 --- a/custom_components/ha_washdata/translations/panel/ru.json +++ b/custom_components/ha_washdata/translations/panel/ru.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "Обнаружено {n} аномалий (например, дверь открылась в середине цикла) — откройте, чтобы увидеть их на графике", + "artifact_tip": "Обнаружено {n} аномалий (например, дверь открылась в середине цикла) – откройте, чтобы увидеть их на графике", "auto": "(авто-определение)", "built_in_tag": "встроенный", "declining": "↘ снижается", "energy_low": "Энергопотребление ниже обычного", "energy_spike": "Энергопотребление выше обычного", "fair_fit": "приемлемое качество", - "fair_fit_tip": "Умеренная согласованность совпадений — некоторые циклы, назначенные этому профилю, имеют более низкие оценки достоверности. Пометьте больше циклов или перезапишите профиль, чтобы повысить точность.", + "fair_fit_tip": "Умеренная согласованность совпадений – некоторые циклы, назначенные этому профилю, имеют более низкие оценки достоверности. Пометьте больше циклов или перезапишите профиль, чтобы повысить точность.", "feedback_requested": "Запрошен отзыв", "golden_cycle": "Записанный эталонный цикл", "improving": "↗ улучшается", @@ -15,7 +15,7 @@ "needs_review": "Требуется проверка", "overrun": "Работал дольше обычного", "poor_fit": "низкое качество", - "poor_fit_tip": "Непоследовательная история совпадений — рассмотрите возможность восстановления этого профиля", + "poor_fit_tip": "Непоследовательная история совпадений – рассмотрите возможность восстановления этого профиля", "restart_gap_tip": "{n} пропуск при перезапуске HA: в графике мощности есть разрыв", "reviewed": "Рассмотрено", "steady": "→ стабильно", @@ -57,7 +57,7 @@ "create_profile": "+ Создать профиль", "delete": "Удалить", "delete_group": "Удалить группу", - "delete_group_tip": "Удалить только эту группу — профили участников останутся", + "delete_group_tip": "Удалить только эту группу – профили участников останутся", "delete_profile": "Удалить профиль", "discard": "Отменить", "discard_tip": "Отменить запись без сохранения", @@ -87,7 +87,7 @@ "on_cycle_finished": "По завершении цикла", "on_cycle_started": "При запуске цикла", "pause_cycle": "Пауза", - "pause_cycle_tip": "Приостановить текущий цикл — прибор продолжит работу с того места, где остановился", + "pause_cycle_tip": "Приостановить текущий цикл – прибор продолжит работу с того места, где остановился", "pg_apply_device": "Применить к устройству", "pg_autofill": "Автозаполнение", "play": "Воспроизвести", @@ -97,8 +97,8 @@ "process_tip": "Сохранить запись как новый или существующий профиль", "rebuild": "Перестроить огибающие", "rebuild_envelope": "Перестроить огибающую", - "rebuild_tip": "Пересчитать ожидаемую огибающую мощности (полоса мин./макс.) для всех профилей по помеченным циклам — запустить после пометки новых циклов или исправления старых", - "rec_start_tip": "Начать запись потребления мощности прибора — запустить непосредственно перед началом цикла", + "rebuild_tip": "Пересчитать ожидаемую огибающую мощности (полоса мин./макс.) для всех профилей по помеченным циклам – запустить после пометки новых циклов или исправления старых", + "rec_start_tip": "Начать запись потребления мощности прибора – запустить непосредственно перед началом цикла", "rec_stop": "Стоп", "rec_stop_tip": "Остановить запись и сохранить захваченные данные для просмотра", "record": "Начать запись", @@ -182,7 +182,7 @@ }, "attn_sub": "Исправьте конфликты перед сохранением", "attn_title": "{n} конфликт{s} настроек", - "settings_banner": "{n} конфликт{s} настроек — проверьте выделенные разделы и исправьте перед сохранением.", + "settings_banner": "{n} конфликт{s} настроек – проверьте выделенные разделы и исправьте перед сохранением.", "settings_banner_btn": "К первому", "confidence": { "auto": "Должен быть не ниже Порога Совпадения ({match})", @@ -451,9 +451,9 @@ "show_expected": "Показать ожидаемую кривую (сопоставленный профиль, оранжевый)", "show_raw": "Показать переключатель необработанных данных розетки на графике мощности в реальном времени", "sparkline": "Недавняя динамика длительности циклов", - "stage2": "Этап 2 — базовое сходство", - "stage3": "Этап 3 — DTW", - "stage4": "Этап 4 — совпадение", + "stage2": "Этап 2 – базовое сходство", + "stage3": "Этап 3 – DTW", + "stage4": "Этап 4 – совпадение", "status": "Статус", "tail_trim": "Обрезка конца (с)", "timer_auto_pause": "Авто-пауза", @@ -561,7 +561,12 @@ "flags": "Метки", "include_phase_map": "Включить карту фаз", "include_settings": "Включить настройки", - "show_contributor": "Показать автора" + "show_contributor": "Показать автора", + "task_pg_detail": "Смоделировать цикл", + "task_split": "Разделение цикла", + "task_trim": "Обрезка цикла", + "task_merge": "Объединение циклов", + "task_rebuild": "Пересчёт огибающих" }, "log": { "all_levels": "Все уровни", @@ -645,19 +650,19 @@ "artifact_dip_detail": "Мощность упала ниже обычного диапазона на ~{n}с.", "artifact_footer": "Выделено на графике выше. Это временные артефакты (например, дверь открылась в середине цикла), а не обязательно проблемы.", "artifact_header": "В этом цикле обнаружено {n} аномалий", - "artifact_pause_detail": "Мощность упала почти до нуля на ~{n}с, затем возобновилась — вероятно, дверца была открыта в середине цикла или цикл был приостановлен.", + "artifact_pause_detail": "Мощность упала почти до нуля на ~{n}с, затем возобновилась – вероятно, дверца была открыта в середине цикла или цикл был приостановлен.", "artifact_spike_detail": "Мощность вышла за пределы обычного диапазона на ~{n}с.", "auto_label_intro": "Назначить профили не помеченным циклам, чьё совпадение превышает порог.", "automations_intro": "WashData запускает события {start} / {end} и предоставляет объекты, поэтому уведомления и действия лучше всего строить как обычные автоматизации Home Assistant. Автоматизации, использующие это устройство, отображаются ниже.", "cleanup_intro": "Все помеченные циклы наложены. Отметьте выбросы и удалите для очистки профиля.", "clear_debug_hint": "Удалите сохраненные данные отладки, чтобы освободить место.", - "collecting_data": "Сбор данных — ещё {need} цикл{plural} до начала точной настройки ({current}/{min}).", + "collecting_data": "Сбор данных – ещё {need} цикл{plural} до начала точной настройки ({current}/{min}).", "compare_overlay_profiles": "Наложить профили (бледные)", "compare_profiles_tip": "Наложите конверты других профилей на диаграмму выше, чтобы увидеть, какой из них лучше всего подходит для этого цикла.", - "compare_selected_cycles": "Выбранные циклы (чёткие) — показать / скрыть", + "compare_selected_cycles": "Выбранные циклы (чёткие) – показать / скрыть", "consider_new_profile": "Рассмотрите возможность создания нового профиля.", "coverage_gap": "последние циклы не имеют соответствующего профиля ({pct}% из последних 30).", - "coverage_gap_similar_cycles": "Найдено {count} похожих непомеченных циклов — создайте профиль, чтобы начать их сопоставление.", + "coverage_gap_similar_cycles": "Найдено {count} похожих непомеченных циклов – создайте профиль, чтобы начать их сопоставление.", "cycles_deleted": "Удалено циклов: {count}", "enough_data": "Достаточно данных для обучения ({current}/{min} циклов).", "export_description": "Экспортируйте все профили и циклы в JSON или восстановите данные из предыдущего экспорта.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Модели, настроенные под эту машину.", "ml_loading": "загрузка ML…", "ml_settings_intro": "Два независимых переключателя: один применяет модели во время работы цикла, другой позволяет WashData постепенно настраивать их под вашу машину.", - "name_first_program": "Циклов достаточно — назовите первую программу, чтобы начать сопоставление.", + "name_first_program": "Циклов достаточно – назовите первую программу, чтобы начать сопоставление.", "near_duplicate_cluster": "Обнаружен почти повторяющийся кластер профилей. Группировка позволяет надежно выбирать одинаковые программы (например, одна и та же программа при разной температуре/отжиме).", "no_cycles_match": "Нет циклов, соответствующих текущему фильтру.", "no_cycles_profile": "Нет циклов для этого профиля.", @@ -694,12 +699,12 @@ "no_cycles_yet": "Циклы пока не записаны.", "no_device_selected": "Устройство не выбрано.", "no_devices": "Устройства WashData ещё не настроены.", - "no_envelope": "Огибающей пока нет — перестройте после пометки циклов.", + "no_envelope": "Огибающей пока нет – перестройте после пометки циклов.", "no_envelope_overlay": "Нет доступной огибающей для наложения.", - "no_fine_tuned": "Ещё ничего не настроено — WashData использует встроенные модели.", + "no_fine_tuned": "Ещё ничего не настроено – WashData использует встроенные модели.", "no_logs": "Журналы пока не буферизованы.", "no_maintenance": "Обслуживание ещё не записано.", - "no_match_yet": "Попыток совпадения пока нет — заполняется во время работы цикла.", + "no_match_yet": "Попыток совпадения пока нет – заполняется во время работы цикла.", "no_other_users": "Других пользователей Home Assistant не найдено.", "no_phases": "Фазы не определены.", "no_phases_assigned": "Фазы не назначены.", @@ -713,7 +718,7 @@ "notify_services_hint": "Используйте ID сервисов {entity} (через запятую для нескольких). Переменные шаблона: {vars}.", "old_actions_warning": "Настроен со старым редактором действий (теперь удален). Они по-прежнему срабатывают при событиях цикла, но их больше нельзя редактировать здесь. Преобразуйте их в обычную автоматику или удалите.", "onboarding_progress": "Наблюдалось циклов: {n} / 3", - "onboarding_watching": "Используйте прибор как обычно — WashData наблюдает. После 3 циклов начнётся сопоставление программ.", + "onboarding_watching": "Используйте прибор как обычно – WashData наблюдает. После 3 циклов начнётся сопоставление программ.", "pending_feedback": "Ожидает отзыва об обнаружении", "performance_trend": "Тенденция производительности ({n} циклов)", "pg_analysis_empty": "Загрузите цикл, чтобы увидеть анализ совпадения.", @@ -752,18 +757,18 @@ "review_in_settings": "Просмотр в настройках", "review_notes_placeholder": "Примечания (необязательно)", "review_notes_tip": "Свободные текстовые заметки для справки. Не используется при сопоставлении или обучении.", - "review_profile_tip": "Программа, под которой помечен этот цикл. Если автоматически определенная программа была неправильной, исправьте ее здесь — маркировка учит сопоставлению для будущих циклов.", + "review_profile_tip": "Программа, под которой помечен этот цикл. Если автоматически определенная программа была неправильной, исправьте ее здесь – маркировка учит сопоставлению для будущих циклов.", "review_quality_tip": "Насколько чист этот цикл. Хорошо = хрестоматийный пример этой программы; Плохо = обнаружено, но шумно или нетипично; Непригодный = обнаружен неправильно (объединен, усечен или поддельен). Управляет показателем работоспособности и тем, какие циклы разрешены для обучения модели.", - "review_recorded_tip": "Отметьте это как выбранный вручную эталонный цикл для своей программы — та же роль, что и цикл, записанный вручную. Ссылочные циклы всегда сохраняются, заполняют соответствующий шаблон и никогда не удаляются при очистке. (Это «золотой»/записанный флаг; оба — одно и то же.)", + "review_recorded_tip": "Отметьте это как выбранный вручную эталонный цикл для своей программы – та же роль, что и цикл, записанный вручную. Ссылочные циклы всегда сохраняются, заполняют соответствующий шаблон и никогда не удаляются при очистке. (Это «золотой»/записанный флаг; оба – одно и то же.)", "review_tags_tip": "Необязательные флаги, описывающие, что пошло не так в этом цикле, чтобы это можно было учесть при обучении и очистке.", "review_to_cycles": "Открыть очередь проверки циклов", "saving_triggers_reload": "Сохранение вызывает перезагрузку интеграции. Объекты HA могут кратко отображаться как недоступные.", "search_placeholder": "Поиск настроек…", "see_recorder": "Смотрите виджет записи ниже", - "setting_changed": "Изменено с {old} на {new} — {date}", + "setting_changed": "Изменено с {old} на {new} – {date}", "settings_basic_note": "Показаны основные настройки. Переключитесь на «Дополнительно» для полного списка.", "shape_drift_advisory": "⚠ Смещение формы", - "shape_drift_detail": "Профиль мощности этого профиля изменился со временем — возможный износ прибора или необходимость технического обслуживания (например, удаление накипи, чистка фильтра).", + "shape_drift_detail": "Профиль мощности этого профиля изменился со временем – возможный износ прибора или необходимость технического обслуживания (например, удаление накипи, чистка фильтра).", "show_all_settings": "Показать все настройки", "showing_suggestions": "Показана настройка {count} с предложениями.", "split_intro": "Нажмите на график, чтобы добавить или удалить точку разбивки, или авто-определите по паузам. Каждый результирующий сегмент может получить свой профиль.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Откат не выполнен: {error}", "toast_reverted": "Возвращено: {key}", "toast_save_failed": "Сохранение не выполнено: {error}", - "trend_duration_longer": "Тенденция увеличения продолжительности ({pct}%/цикл) — среднее значение за последнее время {avg}", - "trend_duration_shorter": "Тенденция к сокращению продолжительности ({pct}%/цикл) — среднее значение за последнее время {avg}", + "trend_duration_longer": "Тенденция увеличения продолжительности ({pct}%/цикл) – среднее значение за последнее время {avg}", + "trend_duration_shorter": "Тенденция к сокращению продолжительности ({pct}%/цикл) – среднее значение за последнее время {avg}", "trend_energy_down": "Тенденция к снижению энергопотребления ({pct}%/цикл)", - "trend_energy_up": "Тенденция роста энергопотребления ({pct}%/цикл) — последнее среднее значение {avg}", + "trend_energy_up": "Тенденция роста энергопотребления ({pct}%/цикл) – последнее среднее значение {avg}", "trim_intro": "Перетащите красные маркеры или введите значения. Всё за пределами окна удаляется.", "tuning_suggestions_available": "{count} предложение по настройке, доступное из наблюдаемых циклов. Они появляются рядом с соответствующими полями.", "unsure_detected_prefix": "WashData не уверен, что обнаружил", @@ -857,7 +862,9 @@ "share_guidelines_title": "Перед публикацией", "store_download_device_intro": "Скачать конфигурацию устройства из сообщества и применить её к новому или существующему устройству", "store_share_device_intro": "Поделиться программами вашего устройства (профили + эталонные циклы) с сообществом. Настройки являются необязательными.", - "share_profile_no_cycles": "Профиль '{p}' не имеет ⭐ эталонных циклов -- он будет пропущен, если у вас нет {n}+ подтверждённых запусков" + "share_profile_no_cycles": "Профиль '{p}' не имеет ⭐ эталонных циклов -- он будет пропущен, если у вас нет {n}+ подтверждённых запусков", + "advisory_phase_inconsistent": "Похоже, что '{name}' смешивает разные программы или температуры - его циклы нагреваются в течение очень разного времени. Разделение на отдельные профили (напр. по температуре) улучшит сопоставление и оценки времени.", + "advisory_phase_inconsistent_title": "⚠ Возможно, смешанные программы" }, "phase_desc": { "anti_crease": "Периодические короткие вращения барабана после завершения для уменьшения складок.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Опциональные внешние сигналы: триггер завершения, датчик дверцы, переключатель паузы и напоминание о выгрузке.", "label": "Триггеры и дверцы" + }, + "phase_eta": { + "label": "Оставшееся время", + "intro": "Оценка оставшегося времени с учётом фаз, для машин, длительность цикла которых зависит от температуры или отжима." } }, "setting": { @@ -1004,7 +1015,7 @@ "label": "Допуск Оценки" }, "enable_ml_models": { - "doc": "Пока цикл выполняется, позвольте моделям уточнить результаты в реальном времени: более стабильную оценку оставшегося времени и энергии/затрат, а также защиту от преждевременной остановки при обнаружении окончания (она может только откладывать завершение, никогда не заканчивать его раньше и ограничена). Использует ваши точно настроенные модели, если они доступны, в противном случае — встроенные. Выкл. = только классическая силовая логика (все еще надежная).", + "doc": "Пока цикл выполняется, позвольте моделям уточнить результаты в реальном времени: более стабильную оценку оставшегося времени и энергии/затрат, а также защиту от преждевременной остановки при обнаружении окончания (она может только откладывать завершение, никогда не заканчивать его раньше и ограничена). Использует ваши точно настроенные модели, если они доступны, в противном случае – встроенные. Выкл. = только классическая силовая логика (все еще надежная).", "label": "Применять умные модели во время цикла" }, "end_energy_threshold": { @@ -1023,6 +1034,10 @@ "doc": "Фиксированная цена за кВтч, используемая для расчета себестоимости, если выше не указан объект реальной цены.", "label": "Фиксированная Цена Электроэнергии (за кВт·ч)" }, + "energy_sensor": { + "doc": "Необязательный накопительный счётчик энергии (total_increasing kWh/Wh, например, собственный счётчик суммарного потребления розетки). Если задан, энергия, отображаемая для каждого цикла, берётся из разности показаний этого счётчика между началом и концом, что позволяет избежать занижения, возникающего при интегрировании медленно обновляющегося датчика мощности. Если показание отсутствует, единица измерения неизвестна или разность не положительна, используется интегрированное значение. Оставьте пустым, чтобы продолжать интегрировать датчик мощности.", + "label": "Объект Счётчика Энергии" + }, "expose_debug_entities": { "doc": "Публикуйте дополнительные диагностические объекты высокой доступности (достоверность соответствия, неоднозначность, внутреннее состояние). Выкл. сохраняет список объектов чистым для нормального использования.", "label": "Открыть Отладочные Объекты" @@ -1164,7 +1179,7 @@ "label": "Задержка Напоминания о Выгрузке" }, "off_delay": { - "doc": "Время ожидания после отключения питания, прежде чем объявить цикл завершенным. Если подача электроэнергии возобновится в пределах этого окна, цикл продолжится плавно — это устраняет паузы между этапами стирки. Посудомоечные машины имеют длительные фазы сушки (отключение питания на 20–60 минут), поэтому задержка выключения должна превышать это значение, чтобы вся стирка+сушка выполнялась как один цикл.", + "doc": "Время ожидания после отключения питания, прежде чем объявить цикл завершенным. Если подача электроэнергии возобновится в пределах этого окна, цикл продолжится плавно – это устраняет паузы между этапами стирки. Посудомоечные машины имеют длительные фазы сушки (отключение питания на 20–60 минут), поэтому задержка выключения должна превышать это значение, чтобы вся стирка+сушка выполнялась как один цикл.", "label": "Задержка Выключения" }, "pause_cuts_power": { @@ -1216,11 +1231,11 @@ "label": "Длительность Застревания Насоса" }, "running_dead_zone": { - "doc": "После начала цикла провалы мощности в этом окне игнорируются. Стиральные машины наполняются холодной водой (перед нагревом ее мощность падает около 0 Вт) — без этой защиты фаза наполнения выглядит как завершение цикла. При этом НЕ происходит пропуск данных: кривая полной мощности записывается от T=0. Механизм подсказок измеряет фактическую схему запуска вашей машины и автоматически определяет ее размер.", + "doc": "После начала цикла провалы мощности в этом окне игнорируются. Стиральные машины наполняются холодной водой (перед нагревом ее мощность падает около 0 Вт) – без этой защиты фаза наполнения выглядит как завершение цикла. При этом НЕ происходит пропуск данных: кривая полной мощности записывается от T=0. Механизм подсказок измеряет фактическую схему запуска вашей машины и автоматически определяет ее размер.", "label": "Мёртвая Зона Работы" }, "sampling_interval": { - "doc": "Ожидаемое время между показаниями датчика — используется для определения размера окна сглаживания и правильного запуска устранения дребезга. Каждое обновление датчика фиксируется независимо от этого значения; он только калибрует последующие вычисления. Механизм подсказок измеряет фактическую частоту вращения педалей вашего датчика на основе прошлых циклов и устанавливает ее автоматически.", + "doc": "Ожидаемое время между показаниями датчика – используется для определения размера окна сглаживания и правильного запуска устранения дребезга. Каждое обновление датчика фиксируется независимо от этого значения; он только калибрует последующие вычисления. Механизм подсказок измеряет фактическую частоту вращения педалей вашего датчика на основе прошлых циклов и устанавливает ее автоматически.", "label": "Интервал Выборки" }, "save_debug_traces": { @@ -1244,7 +1259,7 @@ "label": "Порог Запуска" }, "stop_threshold_w": { - "doc": "Мощность должна упасть ниже этого уровня, прежде чем начнется обратный отсчет задержки выключения. Установите его ниже Start Threshold — промежуток между ними и есть полоса гистерезиса, предотвращающая мерцание. Если установлено слишком высокое значение, фазы низкой мощности (задержка полоскания, предотвращение сминания) ошибочно запускают последовательность завершения.", + "doc": "Мощность должна упасть ниже этого уровня, прежде чем начнется обратный отсчет задержки выключения. Установите его ниже Start Threshold – промежуток между ними и есть полоса гистерезиса, предотвращающая мерцание. Если установлено слишком высокое значение, фазы низкой мощности (задержка полоскания, предотвращение сминания) ошибочно запускают последовательность завершения.", "label": "Порог Остановки" }, "switch_entity": { @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Показать имя автора на профилях, загруженных из магазина сообщества" + }, + "enable_phase_matching": { + "label": "Оставшееся время с учётом фаз", + "doc": "Разбивает каждый активный цикл на фазы (нагрев, стирка, отжим) и распределяет оставшееся время по фазам, сочетая это с классической оценкой - опираясь на распределение по фазам в начале цикла и на классическую оценку ближе к концу. Это персонализирует отсчёт под то, сколько на самом деле нагревается и работает ваша машина, что наиболее заметно в первой половине цикла. Выключено = только классическая оценка. Влияет только на отображение оставшегося времени; сопоставление программ и обнаружение цикла остаются без изменений." + }, + "keep_min_score": { + "label": "Мин. оценка совпадения", + "doc": "Минимальный балл сходства, необходимый кандидату для участия в соревновании по совпадению. Значение по умолчанию 0,1 намеренно мягкое - допускает даже слабых кандидатов и полагается на поздние этапы для нахождения наилучшего совпадения. Повысьте для более раннего отсева маловероятных профилей; понизьте для расширения начального пула кандидатов." + }, + "dtw_blend": { + "label": "Смешивание деформации", + "doc": "Насколько балл выравнивания DTW заменяет основной балл Этапа 2. 0 = используй только балл Этапа 2, 1 = используй только балл DTW, 0,5 (по умолчанию) = равное смешивание. Повысьте для большей опоры на выравнивание с деформацией, когда программы имеют схожие уровни мощности, но разные временные паттерны." + }, + "dtw_ensemble_w": { + "label": "Микс комбинации DTW", + "doc": "В режиме ансамбля DTW (по умолчанию), вес масштабированного L1 против производного DTW (DDTW). 1,0 = только масштабированный L1, 0 = только DDTW, 0,7 = по умолчанию. Масштабированный вариант L1 сравнивает уровни мощности; производный вариант реагирует на изменения мощности во времени. Ансамбль объединяет оба." + }, + "dtw_ddtw_scale": { + "label": "Масштаб производной деформации", + "doc": "Расстояние полунасыщения для оценки производным DTW. Балл уменьшается вдвое, когда расстояние DDTW равно этому значению. Меньше = чувствительнее к различиям формы. Значение по умолчанию 30 откалибровано для типичных кривых мощности бытовых приборов. Влияет только на режимы ensemble и ddtw DTW." + }, + "dtw_refine_top_n": { + "label": "Количество уточнённых кандидатов", + "doc": "Сколько ведущих кандидатов Этапа 2 DTW переоценивает. DTW дороже, поэтому применяется только к N лучшим кандидатам. По умолчанию 5. Повысьте (до 7-9), если правильный профиль иногда достигает только 4-го или 5-го места после Этапа 2 - DTW может его спасти. Снижение немного ускоряет сопоставление." + }, + "duration_scale": { + "label": "Резкость длительности", + "doc": "Резкость штрафа за несоответствие длительности на Этапе 4. Это логарифмическое отношение, при котором балл соответствия уменьшается вдвое. Меньше = строже: несоответствие длительности сильнее снижает балл. Значение по умолчанию 0,175 соответствует допуску длительности около 18% при половинном весе. Используйте вместе с Весом длительности." + }, + "energy_scale": { + "label": "Резкость энергии", + "doc": "Резкость штрафа за несоответствие энергии на Этапе 4. Меньше = строже: несоответствие энергии сильнее снижает балл. Значение по умолчанию 0,25 намеренно мягче, чем Резкость длительности, поскольку энергия варьируется с нагрузкой. Используйте вместе с Весом энергии." } }, "setting_group": { @@ -1553,24 +1600,24 @@ "lower": "Автоматически маркирует больше циклов; некоторые могут быть определены неверно" }, "duration_tolerance": { - "higher": "Принимает более широкий диапазон длительности — более гибкое сопоставление", - "lower": "Требует более точного совпадения длительности — строже, но может отклонить необычные циклы" + "higher": "Принимает более широкий диапазон длительности – более гибкое сопоставление", + "lower": "Требует более точного совпадения длительности – строже, но может отклонить необычные циклы" }, "end_energy_threshold": { "higher": "Закрывает только циклы, использовавшие значительное количество энергии", "lower": "Закрывает и короткие или маломощные циклы" }, "end_repeat_count": { - "higher": "Требует повторения сигнала завершения больше раз — надёжнее, немного медленнее", - "lower": "Завершает после меньшего числа повторений — быстрее, выше риск ложного завершения" + "higher": "Требует повторения сигнала завершения больше раз – надёжнее, немного медленнее", + "lower": "Завершает после меньшего числа повторений – быстрее, выше риск ложного завершения" }, "learning_confidence": { - "higher": "Учится только на очень уверенных совпадениях — медленнее, но надёжнее", + "higher": "Учится только на очень уверенных совпадениях – медленнее, но надёжнее", "lower": "Учится на большем числе циклов; модель может быть более шумной" }, "min_off_gap": { "higher": "Перед новым циклом требуется более длительный простой", - "lower": "Короткий простой запускает новый цикл — последовательные запуски могут разделиться" + "lower": "Короткий простой запускает новый цикл – последовательные запуски могут разделиться" }, "min_power": { "higher": "Менее чувствителен к фоновому потреблению; может пропустить тихие программы", @@ -1581,24 +1628,24 @@ "lower": "Принудительно останавливает зависший цикл быстрее" }, "off_delay": { - "higher": "Больше времени для пауз устройства — справляется с длительным замачиванием или сушкой", - "lower": "Более быстрое определение конца — подходит для устройств с чётким вкл./выкл." + "higher": "Больше времени для пауз устройства – справляется с длительным замачиванием или сушкой", + "lower": "Более быстрое определение конца – подходит для устройств с чётким вкл./выкл." }, "profile_match_threshold": { "higher": "Подтверждает только высокоуверенные совпадения; больше циклов остаётся без метки", "lower": "Сопоставляет больше циклов; некоторые могут быть немного неверны" }, "running_dead_zone": { - "higher": "Игнорирует кратковременные скачки мощности в простое — менее чувствителен к шуму", - "lower": "Реагирует на более короткие всплески мощности — улавливает быструю переходную активность" + "higher": "Игнорирует кратковременные скачки мощности в простое – менее чувствителен к шуму", + "lower": "Реагирует на более короткие всплески мощности – улавливает быструю переходную активность" }, "start_threshold_w": { "higher": "Исключает ложные запуски от кратковременных скачков мощности", "lower": "Улавливает маломощные программы; может сработать на шум" }, "stop_threshold_w": { - "higher": "Завершает цикл быстрее — может закрыть во время паузы", - "lower": "Дольше ждёт завершения — меньше преждевременных завершений" + "higher": "Завершает цикл быстрее – может закрыть во время паузы", + "lower": "Дольше ждёт завершения – меньше преждевременных завершений" }, "watchdog_interval": { "higher": "Допускает более длительные тихие фазы; меньше ложных принудительных остановок", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Секунды выше порога для подтверждения старта", "start_threshold_w": "Минимум ватт, чтобы считать запущенным", "stop_threshold_w": "Ниже этого машина считается выключенной", - "dtw_bandwidth": "Насколько сильное растяжение по времени допускает сопоставление по форме", - "corr_weight": "Вес формы кривой относительно уровня мощности при сопоставлении", - "duration_weight": "Насколько совпадение по длительности влияет на сопоставление", - "energy_weight": "Насколько совпадение по энергии влияет на сопоставление", - "profile_match_min_duration_ratio": "Кратчайший запуск (относительно профиля), который ещё может совпасть", - "profile_match_max_duration_ratio": "Самый длинный запуск (относительно профиля), который ещё может совпасть" + "dtw_bandwidth": "Этап 3: полоса деформации Sakoe-Chiba (0 = DTW выключен; по умолчанию 0,2 = 20% длины цикла)", + "corr_weight": "Этап 2: баланс между формой кривой (корреляция) и уровнем мощности (MAE); по умолчанию 0,45", + "duration_weight": "Этап 4: насколько сильно совпадение по длительности влияет на итоговый балл (по умолчанию 0,22)", + "energy_weight": "Этап 4: насколько сильно совпадение по энергии влияет на итоговый балл (по умолчанию 0,22)", + "profile_match_min_duration_ratio": "Этап 1: минимальная длина цикла как доля длительности профиля; по умолчанию 0,1 (10%)", + "profile_match_max_duration_ratio": "Этап 1: максимальная длина цикла как доля длительности профиля; по умолчанию 1,5 (150%)", + "keep_min_score": "Этап 2: минимальный балл для участия в соревновании; по умолчанию 0,1 допускает слабые совпадения, поздние этапы выбирают лучшее", + "dtw_blend": "Этап 3: 0 = только основной балл, 1 = только балл DTW, 0,5 = равное смешивание (по умолчанию)", + "dtw_ensemble_w": "Этап 3 (режим ансамбля): вес масштабированного L1 против производного DTW; по умолчанию 0,7 отдаёт предпочтение уровню", + "dtw_ddtw_scale": "Этап 3: расстояние полунасыщения DDTW; меньше = чувствительнее к форме (по умолчанию 30)", + "dtw_refine_top_n": "Этап 3: кандидаты, переоцениваемые DTW; повысьте до 7-9, если правильный профиль занимает 4-5 место (по умолчанию 5)", + "duration_scale": "Этап 4: логарифмическое отношение, при котором совпадение по длительности уменьшается вдвое; меньше = более строгий штраф (по умолчанию 0,175)", + "energy_scale": "Этап 4: логарифмическое отношение, при котором совпадение по энергии уменьшается вдвое; меньше = более строгий штраф (по умолчанию 0,25)" }, "col": { "profile_tip": "Название сопоставленной программы. «Без метки» означает, что в конце цикла не совпал ни один профиль.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Оптимизация: {param}" + }, + "pg_detail": { + "simulate": "Смоделировать цикл" + }, + "split": { + "apply": "Разделение цикла" + }, + "trim": { + "apply": "Обрезка цикла" + }, + "merge": { + "apply": "Объединение циклов" + }, + "rebuild": { + "envelopes": "Пересчёт огибающих" + }, + "cancelling": "Отмена..." + }, + "setup": { + "hdr": { + "card": "Настройка устройства", + "healthy_chip": "Настройка завершена" + }, + "phase0": { + "washer": "WashData уже обнаруживает ваши циклы. Запишите первый цикл, чтобы включить названия программ и оценки времени.", + "dishwasher": "WashData наблюдает. Посудомоечные машины имеют сложные циклы – запись первого цикла настоятельно рекомендуется. Если обнаруженный цикл длится слишком долго, обрежьте его в редакторе циклов перед сохранением как профиль.", + "generic": "WashData наблюдает. Запишите или пометьте обнаруженный цикл, чтобы начать создавать профили." + }, + "phase1a": { + "labelled": "Хорошее начало – ваша первая программа сохранена. Для наиболее чистых данных рассмотрите запись следующего цикла с помощью виджета записи." + }, + "phase1b": { + "recorded": "Ваша запись сохранена как {profile_name}. Теперь запишите или пометьте другие распространённые программы для расширения охвата." + }, + "phase1c": { + "verify": "У вас {count} программ от сообщества. Запустите цикл, чтобы убедиться, что WashData правильно их распознаёт – сопоставление будет улучшаться по мере того, как устройство накапливает собственную историю." + }, + "phase2": { + "cluster": "WashData обнаружил {count} циклов, которые не соответствуют ни одной сохранённой программе – они похожи друг на друга. Создать для них новый профиль?", + "unmatched": "Ваш последний цикл не совпал ни с одной сохранённой программой. Это новая программа?" + }, + "phase3": { + "suggestions": "WashData имеет рекомендации по настройкам на основе истории ваших циклов – просмотрите их для повышения точности обнаружения.", + "groups": "Некоторые ваши профили похожи на одну и ту же программу при разных температурах. Объедините их в группу для улучшения сопоставления.", + "phases": "Добавьте фазы программы к {profile_name} для более точных оценок оставшегося времени." + }, + "phase4": { + "healthy": "Это устройство полностью настроено ({profile_count} профилей)." + }, + "cta": { + "start_recording": "Начать запись", + "label_detected_cycle": "У меня уже есть обнаруженный цикл – пометить его вместо записи", + "browse_cycles": "Просматривать циклы для пометки следующего", + "view_profiles": "Просмотреть свои профили", + "create_from_cluster": "Создать профиль из этих циклов", + "create_profile": "Создать для него профиль", + "review_suggestions": "Просмотреть предложения", + "organise_profiles": "Упорядочить профили", + "configure_phases": "Настроить фазы", + "skip_step": "Пропустить этот шаг", + "skip_forever": "Больше не показывать", + "hide_guidance": "Скрыть подсказки", + "show_guidance": "Показать подсказки" } } } diff --git a/custom_components/ha_washdata/translations/panel/sk.json b/custom_components/ha_washdata/translations/panel/sk.json index 38294c9..87b62e4 100644 --- a/custom_components/ha_washdata/translations/panel/sk.json +++ b/custom_components/ha_washdata/translations/panel/sk.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "Zistilo sa {n} anomálií (napr. dvere otvorené uprostred cyklu) — otvorte ich, aby ste ich videli na grafe", + "artifact_tip": "Zistilo sa {n} anomálií (napr. dvere otvorené uprostred cyklu) – otvorte ich, aby ste ich videli na grafe", "auto": "(automaticky zistené)", "built_in_tag": "vstavaný", "declining": "↘ klesajúci", "energy_low": "Nižšia spotreba energie ako zvyčajne", "energy_spike": "Vyššia spotreba energie ako zvyčajne", "fair_fit": "prijateľná kvalita", - "fair_fit_tip": "Stredná konzistencia zhody — niektoré cykly priradené k tomuto profilu majú nižšie skóre spoľahlivosti. Označte viac cyklov alebo znova nahrajte profil, aby ste zlepšili presnosť.", + "fair_fit_tip": "Stredná konzistencia zhody – niektoré cykly priradené k tomuto profilu majú nižšie skóre spoľahlivosti. Označte viac cyklov alebo znova nahrajte profil, aby ste zlepšili presnosť.", "feedback_requested": "Vyžiadaná spätná väzba", "golden_cycle": "Zaznamenaný referenčný cyklus", "improving": "↗ zlepšujúci sa", @@ -15,7 +15,7 @@ "needs_review": "Vyžaduje preskúmanie", "overrun": "Trvalo dlhšie ako zvyčajne", "poor_fit": "zlá kvalita", - "poor_fit_tip": "Nekonzistentná história zhôd — zvážte prestavbu tohto profilu", + "poor_fit_tip": "Nekonzistentná história zhôd – zvážte prestavbu tohto profilu", "restart_gap_tip": "{n} medzera reštartu HA: priebeh výkonu má dieru", "reviewed": "Skontrolované", "steady": "→ stabilný", @@ -57,7 +57,7 @@ "create_profile": "+ Vytvoriť profil", "delete": "Odstrániť", "delete_group": "Odstrániť skupinu", - "delete_group_tip": "Odstrániť iba túto skupinu — profily členov sa zachovajú", + "delete_group_tip": "Odstrániť iba túto skupinu – profily členov sa zachovajú", "delete_profile": "Odstrániť profil", "discard": "Zahodiť", "discard_tip": "Zahodiť zaznamenaný priebeh bez uloženia", @@ -87,7 +87,7 @@ "on_cycle_finished": "Po dokončení cyklu", "on_cycle_started": "Pri spustení cyklu", "pause_cycle": "Pozastaviť", - "pause_cycle_tip": "Pozastaviť bežiaci cyklus — spotrebič pokračuje tam, kde prestal", + "pause_cycle_tip": "Pozastaviť bežiaci cyklus – spotrebič pokračuje tam, kde prestal", "pg_apply_device": "Použiť na zariadenie", "pg_autofill": "Automaticky vyplniť", "play": "Prehrať", @@ -97,8 +97,8 @@ "process_tip": "Uložiť zaznamenaný priebeh ako nový alebo existujúci profil", "rebuild": "Prebudovať obálky", "rebuild_envelope": "Prebudovať obálku", - "rebuild_tip": "Prepočítať očakávanú výkonovú obálku (pásmo min/max) pre všetky profily z ich označených cyklov — spustiť po označení nových cyklov alebo oprave starých", - "rec_start_tip": "Začať nahrávanie priebehu výkonu spotrebiča — spustiť tesne pred začatím cyklu", + "rebuild_tip": "Prepočítať očakávanú výkonovú obálku (pásmo min/max) pre všetky profily z ich označených cyklov – spustiť po označení nových cyklov alebo oprave starých", + "rec_start_tip": "Začať nahrávanie priebehu výkonu spotrebiča – spustiť tesne pred začatím cyklu", "rec_stop": "Zastaviť", "rec_stop_tip": "Zastaviť nahrávanie a podržať zachytený priebeh na kontrolu", "record": "Začať nahrávanie", @@ -231,7 +231,7 @@ "interval": "Mal by byť aspoň 2× Interval vzorkovania ({si} s)", "sampling": "Interval vzorkovania by mal byť najviac polovica Intervalu watchdog ({wi} s)" }, - "settings_banner": "{n} konflikt{s} nastavení — skontrolujte zvýraznené sekcie a opravte pred uložením.", + "settings_banner": "{n} konflikt{s} nastavení – skontrolujte zvýraznené sekcie a opravte pred uložením.", "settings_banner_btn": "Prejsť na prvý" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Zobraziť očakávanú krivku (zhodný profil, oranžový)", "show_raw": "Zobraziť prepínač surových dát zásuvky v živom grafe výkonu", "sparkline": "Nedávny trend trvania cyklov", - "stage2": "Etapa 2 — základná podobnosť", - "stage3": "Etapa 3 — DTW", - "stage4": "Etapa 4 — zhoda", + "stage2": "Etapa 2 – základná podobnosť", + "stage3": "Etapa 3 – DTW", + "stage4": "Etapa 4 – zhoda", "status": "Stav", "tail_trim": "Orezanie konca (s)", "timer_auto_pause": "Automatické pozastavenie", @@ -561,7 +561,12 @@ "flags": "Príznaky", "include_phase_map": "Zahrnúť mapu fáz", "include_settings": "Zahrnúť nastavenia", - "show_contributor": "Zobraziť prispievateľa" + "show_contributor": "Zobraziť prispievateľa", + "task_pg_detail": "Simulovať cyklus", + "task_split": "Rozdeľovanie cyklu", + "task_trim": "Orezávanie cyklu", + "task_merge": "Zlučovanie cyklov", + "task_rebuild": "Prepočítavanie obálok" }, "log": { "all_levels": "Všetky úrovne", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Poklesol pod bežné pásmo výkonu počas ~{n} s.", "artifact_footer": "Zvýraznené na grafe vyššie. Sú to prechodné artefakty (napr. dvere otvorené uprostred cyklu), nie nevyhnutne problémy.", "artifact_header": "Počet anomálií zistených počas tohto cyklu: {n}", - "artifact_pause_detail": "Výkon poklesol takmer na nulu počas ~{n} s a potom sa obnovil — pravdepodobne boli otvorené dvere uprostred cyklu alebo bol cyklus pozastavený.", + "artifact_pause_detail": "Výkon poklesol takmer na nulu počas ~{n} s a potom sa obnovil – pravdepodobne boli otvorené dvere uprostred cyklu alebo bol cyklus pozastavený.", "artifact_spike_detail": "Bol nad bežným pásmom výkonu počas ~{n} s.", "auto_label_intro": "Priradiť profily neoznačeným cyklom, ktorých zhoda prekračuje prah.", "automations_intro": "WashData spúšťa udalosti {start} / {end} a sprístupňuje entity, takže notifikácie a akcie sa najlepšie vytvárajú ako bežné automatizácie Home Assistant. Automatizácie používajúce toto zariadenie sú zobrazené nižšie.", @@ -654,10 +659,10 @@ "collecting_data": "Zbieranie dát: ešte {need} cyklov do spustenia doladenia ({current}/{min}).", "compare_overlay_profiles": "Prekryté profily (bledé)", "compare_profiles_tip": "Prekryte ostatné profilové obálky v tabuľke vyššie, aby ste videli, ktorá z nich najlepšie vyhovuje tomuto cyklu.", - "compare_selected_cycles": "Vybraté cykly (tučné) — zobraziť / skryť", + "compare_selected_cycles": "Vybraté cykly (tučné) – zobraziť / skryť", "consider_new_profile": "Zvážte vytvorenie nového profilu.", "coverage_gap": "nedávne cykly nemajú žiadny zodpovedajúci profil ({pct} % z posledných 30).", - "coverage_gap_similar_cycles": "Nájdených {count} podobných neoznačených cyklov — vytvorte profil, aby sa začali párovať.", + "coverage_gap_similar_cycles": "Nájdených {count} podobných neoznačených cyklov – vytvorte profil, aby sa začali párovať.", "cycles_deleted": "Odstránených cyklov: {count}", "enough_data": "Dostatok dát na učenie ({current}/{min} cyklov).", "export_description": "Exportujte všetky profily a cykly do JSON alebo obnovte z predchádzajúceho exportu.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modely doladené pre tento stroj.", "ml_loading": "načítava sa ML…", "ml_settings_intro": "Dva nezávislé prepínače: jeden používa modely počas behu cyklu, druhý umožňuje WashData časom ich doladiť na váš stroj.", - "name_first_program": "Máte dostatok cyklov — pomenujte svoj prvý program a spustite priraďovanie.", + "name_first_program": "Máte dostatok cyklov – pomenujte svoj prvý program a spustite priraďovanie.", "near_duplicate_cluster": "zistený takmer duplicitný klaster profilov. Zoskupenie umožňuje zhodu spoľahlivo vybrať medzi podobnými (napr. rovnaký program pri inej teplote/odstreďovaní).", "no_cycles_match": "Žiadne cykly nezodpovedajú aktuálnemu filtru.", "no_cycles_profile": "Pre tento profil nie sú žiadne cykly.", @@ -694,12 +699,12 @@ "no_cycles_yet": "Zatiaľ neboli zaznamenané žiadne cykly.", "no_device_selected": "Nebolo vybrané žiadne zariadenie.", "no_devices": "Zatiaľ nie sú nakonfigurované žiadne zariadenia WashData.", - "no_envelope": "Zatiaľ žiadna obálka — prebudujte po označení cyklov.", + "no_envelope": "Zatiaľ žiadna obálka – prebudujte po označení cyklov.", "no_envelope_overlay": "Žiadna obálka dostupná na prekrytie.", - "no_fine_tuned": "Zatiaľ nič nedoladené — WashData používa vstavané modely.", + "no_fine_tuned": "Zatiaľ nič nedoladené – WashData používa vstavané modely.", "no_logs": "Zatiaľ nie sú uložené žiadne záznamy.", "no_maintenance": "Zatiaľ nebola zaznamenaná žiadna údržba.", - "no_match_yet": "Zatiaľ žiadny pokus o zhodu — zobrazí sa počas bežiaceho cyklu.", + "no_match_yet": "Zatiaľ žiadny pokus o zhodu – zobrazí sa počas bežiaceho cyklu.", "no_other_users": "Neboli nájdení žiadni iní používatelia Home Assistant.", "no_phases": "Nie sú definované žiadne fázy.", "no_phases_assigned": "Nie sú priradené žiadne fázy.", @@ -713,7 +718,7 @@ "notify_services_hint": "Použite ID služieb {entity} (oddelené čiarkami pre viacero). Premenné šablóny: {vars}.", "old_actions_warning": "Nakonfigurované pomocou starého editora akcií (teraz odstránený). Stále sa spúšťajú pri udalostiach cyklu, ale už ich tu nemožno upravovať. Premeňte ich na normálnu automatizáciu alebo ich odstráňte.", "onboarding_progress": "Pozorovaných cyklov: {n} / 3", - "onboarding_watching": "Používajte spotrebič bežne — WashData sleduje. Po 3 cykloch sa spustí priraďovanie programov.", + "onboarding_watching": "Používajte spotrebič bežne – WashData sleduje. Po 3 cykloch sa spustí priraďovanie programov.", "pending_feedback": "Čaká sa na spätnú väzbu k detekcii", "performance_trend": "Trend výkonnosti ({n} cyklov)", "pg_analysis_empty": "Načítajte cyklus na zobrazenie analýzy zhody.", @@ -748,13 +753,13 @@ "restart_gap_footer": "Zvýraznené v grafe. Údaje o výkone chýbajú pre tieto intervaly; párovanie použilo iba skutočné merania.", "restart_gap_header": "{n} medzera reštartu HA počas tohto cyklu", "restart_gap_item": "{dur} medzera", - "review_confirm_help": "Potvrďte, či bol tento cyklus rozpoznaný správne. Vaše recenzie trénujú model na vašom stroji — čím viac cyklov potvrdíte, tým lepšie bude porovnávanie a hodnotenie zdravia. Stačí rýchle dobré/zlé.", + "review_confirm_help": "Potvrďte, či bol tento cyklus rozpoznaný správne. Vaše recenzie trénujú model na vašom stroji – čím viac cyklov potvrdíte, tým lepšie bude porovnávanie a hodnotenie zdravia. Stačí rýchle dobré/zlé.", "review_in_settings": "Skontrolovať v nastaveniach", "review_notes_placeholder": "Poznámky (voliteľné)", "review_notes_tip": "Poznámky s voľným textom pre vašu vlastnú potrebu. Nepoužíva sa priraďovaním alebo tréningom.", - "review_profile_tip": "Program, pod ktorým je tento cyklus označený. Ak bol automaticky zistený program nesprávny, opravte ho tu — označovanie učí párovanie pre budúce cykly.", + "review_profile_tip": "Program, pod ktorým je tento cyklus označený. Ak bol automaticky zistený program nesprávny, opravte ho tu – označovanie učí párovanie pre budúce cykly.", "review_quality_tip": "Aký čistý je tento cyklus. Dobrý = učebnicový príklad tohto programu; Zlý = detekovaný, ale hlučný alebo atypický; Nepoužiteľný = nesprávne zistený (zlúčený, skrátený alebo falošný). Riadi skóre zdravia a ktoré cykly sú povolené na trénovanie modelu.", - "review_recorded_tip": "Označte to ako ručne vybraný referenčný cyklus pre svoj program — rovnakú úlohu ako manuálne zaznamenaný cyklus. Referenčné cykly sa vždy uchovávajú, nasadzujú zodpovedajúcu šablónu a pri čistení sa nikdy nezrušia. (Toto je zlatá/zaznamenaná vlajka; obe sú to isté.)", + "review_recorded_tip": "Označte to ako ručne vybraný referenčný cyklus pre svoj program – rovnakú úlohu ako manuálne zaznamenaný cyklus. Referenčné cykly sa vždy uchovávajú, nasadzujú zodpovedajúcu šablónu a pri čistení sa nikdy nezrušia. (Toto je zlatá/zaznamenaná vlajka; obe sú to isté.)", "review_tags_tip": "Voliteľné príznaky popisujúce, čo sa v tomto cykle pokazilo, aby to tréning a čistenie mohli zohľadniť.", "review_to_cycles": "Otvoriť front kontroly cyklov", "saving_triggers_reload": "Uloženie spustí opätovné načítanie integrácie. Entity HA sa môžu nakrátko zobraziť ako nedostupné.", @@ -763,7 +768,7 @@ "setting_changed": "Zmenené z {old} na {new} dňa {date}", "settings_basic_note": "Zobrazujú sa základné nastavenia. Prepnite na Rozšírené pre úplný zoznam.", "shape_drift_advisory": "⚠ Posun tvaru", - "shape_drift_detail": "Vzor výkonu tohto profilu sa časom zmenil — možné opotrebenie spotrebiča alebo potreba údržby (napr. odstraňovanie vodného kameňa, čistenie filtra).", + "shape_drift_detail": "Vzor výkonu tohto profilu sa časom zmenil – možné opotrebenie spotrebiča alebo potreba údržby (napr. odstraňovanie vodného kameňa, čistenie filtra).", "show_all_settings": "Zobraziť všetky nastavenia", "showing_suggestions": "Zobrazuje sa nastavenie {count} s návrhmi.", "split_intro": "Kliknite na graf a pridajte alebo odstráňte bod rozdelenia, alebo automaticky zistite nečinné medzery. Každý výsledný segment môže mať vlastný profil.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Vrátenie zmien zlyhalo: {error}", "toast_reverted": "{key} vrátené späť", "toast_save_failed": "Uloženie zlyhalo: {error}", - "trend_duration_longer": "Trvanie je dlhšie trendy ({pct} %/cyklus) — posledný priemer {avg}", - "trend_duration_shorter": "Trvanie je kratšie ({pct} %/cyklus) — posledný priemer {avg}", + "trend_duration_longer": "Trvanie je dlhšie trendy ({pct} %/cyklus) – posledný priemer {avg}", + "trend_duration_shorter": "Trvanie je kratšie ({pct} %/cyklus) – posledný priemer {avg}", "trend_energy_down": "Energetický trend klesá ({pct} %/cyklus)", - "trend_energy_up": "Energetický trend stúpa ({pct} %/cyklus) — posledný priemer {avg}", + "trend_energy_up": "Energetický trend stúpa ({pct} %/cyklus) – posledný priemer {avg}", "trim_intro": "Potiahnite červené rukoväti alebo zadajte hodnoty. Všetko mimo okna sa odstráni.", "tuning_suggestions_available": "{count} návrh ladenia dostupný z pozorovaných cyklov. Zobrazujú sa vedľa príslušných polí.", "unsure_detected_prefix": "WashData si nie je istý, či zistil", @@ -857,7 +862,9 @@ "share_guidelines_title": "Pred zdieľaním", "store_download_device_intro": "Stiahnuť nastavenie zariadenia komunity a použiť ho na nové alebo existujúce zariadenie", "store_share_device_intro": "Zdieľať programy vášho zariadenia (profily + referenčné cykly) s komunitou. Nastavenia sú voliteľné.", - "share_profile_no_cycles": "Profil '{p}' nemá žiadne ⭐ referenčné cykly -- bude preskočený, ak nemáte {n}+ potvrdených spustení" + "share_profile_no_cycles": "Profil '{p}' nemá žiadne ⭐ referenčné cykly -- bude preskočený, ak nemáte {n}+ potvrdených spustení", + "advisory_phase_inconsistent": "Zdá sa, že '{name}' mieša rôzne programy alebo teploty - jeho cykly sa ohrievajú veľmi rôzne dlho. Rozdelenie na samostatné profily (napr. podľa teploty) zlepší zhodu aj odhady času.", + "advisory_phase_inconsistent_title": "⚠ Možno zmiešané programy" }, "phase_desc": { "anti_crease": "Príležitostné krátke vírenia po dokončení na predchádzanie krčeniu oblečenia.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Voliteľné externé signály: spúšť ukončenia, senzor dverí, prepínač pauzy a pripomienka vyloženia.", "label": "Spúšte a dvere" + }, + "phase_eta": { + "label": "Zostávajúci čas", + "intro": "Zostávajúci čas zohľadňujúci fázy, pre spotrebiče, ktorých dĺžka cyklu závisí od teploty alebo odstreďovania." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Pevná cena za kWh použitá pre číselné údaje o nákladoch, keď vyššie nie je nastavená žiadna aktuálna cena.", "label": "Statická cena energie (za kWh)" }, + "energy_sensor": { + "doc": "Voliteľné kumulatívne počítadlo energie (total_increasing kWh/Wh, napr. vlastný celoživotný merač zásuvky). Keď je nastavené, energia hlásená za každý cyklus sa odvodzuje z rozdielu tohto počítadla medzi začiatkom a koncom, čo zabraňuje podhodnoteniu, ktoré vzniká integráciou pomaly hlásiaceho senzora výkonu. Vráti sa k integrovanej hodnote, ak údaj chýba, jeho jednotka je neznáma alebo rozdiel nie je kladný. Nechajte prázdne, ak chcete naďalej integrovať senzor výkonu.", + "label": "Entita merača energie" + }, "expose_debug_entities": { "doc": "Zverejniť ďalšie diagnostické entity HA (dôvera v zhodu, nejednoznačnosť, vnútorné údaje). Vypnuté udržiava zoznam entít čistý na bežné použitie.", "label": "Zobraziť ladiace entity" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Zobraziť meno prispievateľa pri profiloch stiahnutých z obchodu komunity" + }, + "enable_phase_matching": { + "label": "Zostávajúci čas zohľadňujúci fázy", + "doc": "Rozdelí každý bežiaci cyklus do fáz (ohrev, pranie, odstreďovanie) a rozpočíta zostávajúci čas na jednotlivé fázy, zmiešaný s klasickým odhadom - na začiatku cyklu sa opiera o rozpočet fáz a ku koncu o klasický odhad. Prispôsobí odpočet tomu, ako dlho sa váš spotrebič skutočne ohrieva a beží, čo je najviac badateľné v prvej polovici cyklu. Vypnuté = iba klasický odhad. Ovplyvnené je len zobrazenie zostávajúceho času; zhoda programov a detekcia cyklu sa nemenia." + }, + "keep_min_score": { + "label": "Min. skóre zhody", + "doc": "Minimálne skóre podobnosti, ktoré musí mať kandidát, aby zostal v závode o zhodu. Predvolená hodnota 0,1 je zámerne benevolentná - pripúšťa aj slabých kandidátov a spolieha sa na neskoršie fázy pri výbere najlepšej zhody. Zvýšte ju pre skoršie vyradenie nepravdepodobných profilov; znížte ju pre rozšírenie počiatočnej skupiny kandidátov." + }, + "dtw_blend": { + "label": "Miešanie deformácie", + "doc": "Ako veľmi skóre zarovnania DTW nahrádza základné skóre Fázy 2. 0 = použite iba skóre Fázy 2, 1 = použite iba skóre DTW, 0,5 (predvolené) = rovnaké miešanie. Zvýšte, ak chcete viac spoliehať na zarovnanie, keď majú programy podobné úrovne výkonu, ale rôzne časové vzory." + }, + "dtw_ensemble_w": { + "label": "Mix kombinácie DTW", + "doc": "V kombinačnom režime DTW (predvolenom) je to váha škálovaného L1 oproti derivátneho DTW (DDTW). 1,0 = iba škálované L1, 0 = iba DDTW, 0,7 = predvolené. Škálovaná varianta L1 porovnáva úrovne výkonu; derivátna varianta reaguje na zmeny výkonu v čase. Kombinácia spája obe." + }, + "dtw_ddtw_scale": { + "label": "Škála derivátnej deformácie", + "doc": "Vzdialenosť polosaturácie pre hodnotenie derivátnym DTW. Skóre sa zníži na polovicu, keď sa vzdialenosť DDTW rovná tejto hodnote. Menšia = citlivejšia na rozdiely v tvare. Predvolená hodnota 30 je kalibrovaná pre typické priebehy výkonu spotrebičov. Ovplyvňuje iba kombinačný a ddtw režim DTW." + }, + "dtw_refine_top_n": { + "label": "Pocet spresnenych kandidátov", + "doc": "Koľko najlepších kandidátov z Fázy 2 je prehodnotených DTW. DTW je nákladnejší, takže sa používa iba pre N najlepších kandidátov. Predvolených 5. Zvýšte (na 7-9), ak správny profil po Fáze 2 niekedy dosiahne iba 4. alebo 5. miesto - DTW ho môže zachrániť. Zníženie mierne urýchli porovnávanie." + }, + "duration_scale": { + "label": "Ostrost doby trvania", + "doc": "Ostrosť penalizácie za nezhodu dĺžky trvania vo Fáze 4. Je to logaritmický pomer, pri ktorom sa skóre zhody zníži na polovicu. Menší = prísnejší: nezhoda dĺžky trvania viac znižuje skóre. Predvolená hodnota 0,175 zodpovedá približne 18% tolerancii dĺžky trvania pri polovičnej váhe. Kombinujte s Váhou dĺžky trvania." + }, + "energy_scale": { + "label": "Ostrost energie", + "doc": "Ostrosť penalizácie za nezhodu energie vo Fáze 4. Menší = prísnejší: nezhoda energie viac znižuje skóre. Predvolená hodnota 0,25 je zámerne benevolentnejšia ako Ostrosť dĺžky trvania, pretože energia sa líši podľa záťaže. Kombinujte s Váhou energie." } }, "setting_group": { @@ -1424,24 +1471,24 @@ "lower": "Viac cyklov je označených automaticky; niektoré môžu byť nesprávne určené" }, "duration_tolerance": { - "higher": "Akceptuje širší rozsah trvania — flexibilnejšie porovnávanie", - "lower": "Vyžaduje bližšiu zhodu trvania — prísnejšie, ale môže odmietnuť nezvyčajné cykly" + "higher": "Akceptuje širší rozsah trvania – flexibilnejšie porovnávanie", + "lower": "Vyžaduje bližšiu zhodu trvania – prísnejšie, ale môže odmietnuť nezvyčajné cykly" }, "end_energy_threshold": { "higher": "Uzatvára iba cykly so značnou spotrebou energie", "lower": "Uzatvára aj kratšie alebo menej energetické cykly" }, "end_repeat_count": { - "higher": "Vyžaduje viac opakovaní signálu konca — spoľahlivejšie, mierne pomalšie", - "lower": "Ukončí po menej opakovaniach — rýchlejšia detekcia, väčšie riziko predčasného ukončenia" + "higher": "Vyžaduje viac opakovaní signálu konca – spoľahlivejšie, mierne pomalšie", + "lower": "Ukončí po menej opakovaniach – rýchlejšia detekcia, väčšie riziko predčasného ukončenia" }, "learning_confidence": { - "higher": "Učí sa iba z veľmi istých zhôd — pomalšie, ale spoľahlivejšie", + "higher": "Učí sa iba z veľmi istých zhôd – pomalšie, ale spoľahlivejšie", "lower": "Učí sa z viacerých cyklov; model môže byť menej presný" }, "min_off_gap": { "higher": "Pred začiatkom nového cyklu je potrebný dlhší pokoj", - "lower": "Krátky pokoj spustí nový cyklus — po sebe idúce cykly sa môžu rozdeliť" + "lower": "Krátky pokoj spustí nový cyklus – po sebe idúce cykly sa môžu rozdeliť" }, "min_power": { "higher": "Menej citlivý na kľudový príkon; môže prehliadnuť veľmi tichý program", @@ -1452,24 +1499,24 @@ "lower": "Zaseknutý cyklus je ukončený skôr" }, "off_delay": { - "higher": "Viac času na prestávky spotrebiča — vhodné pre dlhé fázy namáčania alebo sušenia", - "lower": "Rýchlejšia detekcia konca — vhodné pre spotrebiče s čistým zapínaním/vypínaním" + "higher": "Viac času na prestávky spotrebiča – vhodné pre dlhé fázy namáčania alebo sušenia", + "lower": "Rýchlejšia detekcia konca – vhodné pre spotrebiče s čistým zapínaním/vypínaním" }, "profile_match_threshold": { "higher": "Potvrdzuje iba vysoko isté zhody; viac cyklov zostane neoznačených", "lower": "Páruje viac cyklov; niektoré môžu byť mierne nesprávne" }, "running_dead_zone": { - "higher": "Ignoruje krátke výkonové špičky počas nečinnosti — menej citlivý na rušenie", - "lower": "Reaguje na kratšie výkonové impulzy — zachytí rýchlu prechodnú aktivitu" + "higher": "Ignoruje krátke výkonové špičky počas nečinnosti – menej citlivý na rušenie", + "lower": "Reaguje na kratšie výkonové impulzy – zachytí rýchlu prechodnú aktivitu" }, "start_threshold_w": { "higher": "Zabraňuje falošným štartom z krátkych výkonových špiček", "lower": "Zachytí programy s nízkym príkonom; môže reagovať na šum" }, "stop_threshold_w": { - "higher": "Ukončí cyklus rýchlejšie — môže uzatvoriť počas pauzy", - "lower": "Čaká dlhšie na ukončenie — menej predčasných dokončení" + "higher": "Ukončí cyklus rýchlejšie – môže uzatvoriť počas pauzy", + "lower": "Čaká dlhšie na ukončenie – menej predčasných dokončení" }, "watchdog_interval": { "higher": "Umožňuje dlhšie tiché fázy; menej falošných vynútených zastavení", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Sekundy nad prahom na potvrdenie spustenia", "start_threshold_w": "Minimálny výkon vo wattoch na započítanie spustenia", "stop_threshold_w": "Pod touto hodnotou sa stroj počíta ako vypnutý", - "dtw_bandwidth": "Aké veľké časové deformácie zhoda tvaru umožňuje", - "corr_weight": "Váha tvaru krivky verzus úrovne výkonu pri priraďovaní", - "duration_weight": "Ako veľmi zhoda dĺžky trvania ovplyvňuje priradenie", - "energy_weight": "Ako veľmi zhoda spotreby energie ovplyvňuje priradenie", - "profile_match_min_duration_ratio": "Najkratší beh (oproti profilu), ktorý ešte môže zodpovedať", - "profile_match_max_duration_ratio": "Najdlhší beh (oproti profilu), ktorý ešte môže zodpovedať" + "dtw_bandwidth": "Fáza 3: deformačné pásmo Sakoe-Chiba (0 = DTW vypnuté; predvolené 0,2 = 20 % dĺžky cyklu)", + "corr_weight": "Fáza 2: rovnováha medzi tvarom krivky (korelácia) a úrovňou výkonu (MAE); predvolené 0,45", + "duration_weight": "Fáza 4: ako silno zhoda dĺžky trvania ovplyvňuje výsledné skóre (predvolené 0,22)", + "energy_weight": "Fáza 4: ako silno zhoda energie ovplyvňuje výsledné skóre (predvolené 0,22)", + "profile_match_min_duration_ratio": "Fáza 1: minimálna dĺžka cyklu ako zlomok dĺžky trvania profilu; predvolené 0,1 (10 %)", + "profile_match_max_duration_ratio": "Fáza 1: maximálna dĺžka cyklu ako zlomok dĺžky trvania profilu; predvolené 1,5 (150 %)", + "keep_min_score": "Fáza 2: minimálne skóre na zostanie v závode; predvolené 0,1 pripúšťa slabé zhody, neskoršie fázy vyberú najlepšie", + "dtw_blend": "Fáza 3: 0 = iba základné skóre, 1 = iba skóre DTW, 0,5 = rovnaké miešanie (predvolené)", + "dtw_ensemble_w": "Fáza 3 (režim kombinácie): váha škálovaného L1 oproti derivátneho DTW; predvolené 0,7 uprednostňuje úroveň", + "dtw_ddtw_scale": "Fáza 3: vzdialenosť polosaturácie DDTW; menšia = citlivejšia na tvar (predvolené 30)", + "dtw_refine_top_n": "Fáza 3: kandidáti, ktorých DTW prehodnotí; zvýšte na 7-9, ak správny profil skončí na 4.-5. mieste (predvolené 5)", + "duration_scale": "Fáza 4: logaritmický pomer, pri ktorom sa zhoda dĺžky trvania zníži na polovicu; menší = prísnejšia penalizácia (predvolené 0,175)", + "energy_scale": "Fáza 4: logaritmický pomer, pri ktorom sa zhoda energie zníži na polovicu; menší = prísnejšia penalizácia (predvolené 0,25)" }, "col": { "profile_tip": "Názov zhodného programu. Bez štítku znamená, že na konci cyklu sa nezhodoval žiadny profil.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimalizácia: {param}" + }, + "pg_detail": { + "simulate": "Simulovať cyklus" + }, + "split": { + "apply": "Rozdeľovanie cyklu" + }, + "trim": { + "apply": "Orezávanie cyklu" + }, + "merge": { + "apply": "Zlučovanie cyklov" + }, + "rebuild": { + "envelopes": "Prepočítavanie obálok" + }, + "cancelling": "Ruším..." + }, + "setup": { + "hdr": { + "card": "Nastavenie zariadenia", + "healthy_chip": "Nastavenie dokončené" + }, + "phase0": { + "washer": "WashData už deteguje vaše cykly. Nahrajte prvý cyklus, aby ste povolili názvy programov a odhady doby trvania.", + "dishwasher": "WashData sleduje. Umývačky riadu majú zložité cykly – nahranie prvého cyklu je dôrazne odporúčané. Ak detegovaný cyklus trvá príliš dlho, orežte ho v editore cyklov pred uložením ako profil.", + "generic": "WashData sleduje. Nahrajte alebo označte detegovaný cyklus a začnite vytvárať profily." + }, + "phase1a": { + "labelled": "Dobrý začiatok – váš prvý program je uložený. Pre najčistejšie dáta zvážte nahranie ďalšieho cyklu pomocou widgetu nahrávania." + }, + "phase1b": { + "recorded": "Vaša nahrávka bola uložená ako {profile_name}. Teraz nahrajte alebo označte ďalšie bežné programy, aby ste rozšírili pokrytie." + }, + "phase1c": { + "verify": "Máte {count} programov od komunity. Spustite cyklus, aby ste overili, že ich WashData správne rozoznáva – porovnávanie sa bude zlepšovať, ako zariadenie buduje vlastnú históriu." + }, + "phase2": { + "cluster": "WashData zaznamenal {count} cyklov, ktoré nezodpovedajú žiadnemu uloženému programu – navzájom si podobajú. Chcete pre ne vytvoriť nový profil?", + "unmatched": "Váš posledný cyklus nezodpovedal žiadnemu uloženému programu. Je to nový program?" + }, + "phase3": { + "suggestions": "WashData má odporúčania nastavení na základe vašej histórie cyklov – skontrolujte ich pre zlepšenie presnosti detekcie.", + "groups": "Niektoré vaše profily vyzerajú ako rovnaký program pri rôznych teplotách. Usporiadajte ich do skupiny pre lepšie porovnávanie.", + "phases": "Pridajte fázy programu k {profile_name} pre presnejšie odhady zostávajúceho času." + }, + "phase4": { + "healthy": "Toto zariadenie je plne nastavené ({profile_count} profilov)." + }, + "cta": { + "start_recording": "Spustiť nahrávanie", + "label_detected_cycle": "Už mám detegovaný cyklus – namiesto toho ho označte", + "browse_cycles": "Prechádzať cykly a označiť ďalší", + "view_profiles": "Zobraziť vaše profily", + "create_from_cluster": "Vytvoriť profil z týchto cyklov", + "create_profile": "Vytvoriť preňho profil", + "review_suggestions": "Skontrolovať návrhy", + "organise_profiles": "Usporiadať profily", + "configure_phases": "Nakonfigurovať fázy", + "skip_step": "Preskočiť tento krok", + "skip_forever": "Nezobrazovať znova", + "hide_guidance": "Skryť pokyny", + "show_guidance": "Zobraziť pokyny" } } } diff --git a/custom_components/ha_washdata/translations/panel/sl.json b/custom_components/ha_washdata/translations/panel/sl.json index 09f2a57..399aeb3 100644 --- a/custom_components/ha_washdata/translations/panel/sl.json +++ b/custom_components/ha_washdata/translations/panel/sl.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "{n} zaznanih nepravilnosti (npr. odprta vrata sredi cikla) — odprite, da jih vidite na grafu", + "artifact_tip": "{n} zaznanih nepravilnosti (npr. odprta vrata sredi cikla) – odprite, da jih vidite na grafu", "auto": "(samodejno zaznano)", "built_in_tag": "vgrajeno", "declining": "↘ upada", "energy_low": "Nižja poraba energije kot običajno", "energy_spike": "Višja poraba energije kot običajno", "fair_fit": "sprejemljiva kakovost", - "fair_fit_tip": "Zmerna doslednost ujemanja — nekateri cikli, dodeljeni temu profilu, imajo nižje ocene zaupanja. Označite več ciklov ali znova posnemite profil, da izboljšate natančnost.", + "fair_fit_tip": "Zmerna doslednost ujemanja – nekateri cikli, dodeljeni temu profilu, imajo nižje ocene zaupanja. Označite več ciklov ali znova posnemite profil, da izboljšate natančnost.", "feedback_requested": "Zahtevane povratne informacije", "golden_cycle": "Posneti referenčni cikel", "improving": "↗ se izboljšuje", @@ -15,7 +15,7 @@ "needs_review": "Potrebuje pregled", "overrun": "Trajalo dlje kot običajno", "poor_fit": "slaba kakovost", - "poor_fit_tip": "Nedosledna zgodovina ujemanj — razmislite o ponovni izdelavi tega profila", + "poor_fit_tip": "Nedosledna zgodovina ujemanj – razmislite o ponovni izdelavi tega profila", "restart_gap_tip": "{n} vrzel ponovnega zagona HA: sled moči ima luknjo", "reviewed": "Pregledano", "steady": "→ stabilno", @@ -57,7 +57,7 @@ "create_profile": "+ Ustvari profil", "delete": "Izbriši", "delete_group": "Izbriši skupino", - "delete_group_tip": "Izbriši samo to skupino — profili članov se ohranijo", + "delete_group_tip": "Izbriši samo to skupino – profili članov se ohranijo", "delete_profile": "Izbriši profil", "discard": "Zavrzi", "discard_tip": "Zavrzi posneto sled brez shranjevanja", @@ -87,7 +87,7 @@ "on_cycle_finished": "Ob koncu cikla", "on_cycle_started": "Ob začetku cikla", "pause_cycle": "Premor", - "pause_cycle_tip": "Zaustavi delujoči cikel — naprava se bo nadaljevala tam, kjer je ostala", + "pause_cycle_tip": "Zaustavi delujoči cikel – naprava se bo nadaljevala tam, kjer je ostala", "pg_apply_device": "Uporabi na napravi", "pg_autofill": "Samodejno izpolni", "play": "Predvajaj", @@ -97,8 +97,8 @@ "process_tip": "Shrani posneto sled kot nov ali obstoječ profil", "rebuild": "Obnovi ovojnice", "rebuild_envelope": "Obnovi ovojnico", - "rebuild_tip": "Znova izračunaj pričakovano ovojnico moči (min/maks pas) za vse profile iz njihovih označenih ciklov — zaženite po označevanju novih ciklov ali popravljanju starih", - "rec_start_tip": "Začni snemanje sledi moči naprave — zaženite tik preden zaženete cikel", + "rebuild_tip": "Znova izračunaj pričakovano ovojnico moči (min/maks pas) za vse profile iz njihovih označenih ciklov – zaženite po označevanju novih ciklov ali popravljanju starih", + "rec_start_tip": "Začni snemanje sledi moči naprave – zaženite tik preden zaženete cikel", "rec_stop": "Ustavi", "rec_stop_tip": "Ustavi snemanje in obdrži posneto sled za pregled", "record": "Začni snemanje", @@ -231,7 +231,7 @@ "interval": "Mora biti vsaj 2× Interval vzorčenja ({si} s)", "sampling": "Interval vzorčenja mora biti največ polovica Intervala nadzornika ({wi} s)" }, - "settings_banner": "{n} konflikt{s} nastavitev — preverite označene razdelke in popravite pred shranjevanjem.", + "settings_banner": "{n} konflikt{s} nastavitev – preverite označene razdelke in popravite pred shranjevanjem.", "settings_banner_btn": "Pojdi na prvega" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Prikaži pričakovano krivuljo (ujemajoči profil, oranžna)", "show_raw": "Prikaži stikalo za surove odčitke vtičnice v živem grafu moči", "sparkline": "Nedavni trend trajanja ciklov", - "stage2": "Stopnja 2 — osnovna podobnost", - "stage3": "Stopnja 3 — DTW", - "stage4": "Stopnja 4 — ujemanje", + "stage2": "Stopnja 2 – osnovna podobnost", + "stage3": "Stopnja 3 – DTW", + "stage4": "Stopnja 4 – ujemanje", "status": "Stanje", "tail_trim": "Obrezovanje konca (s)", "timer_auto_pause": "Samodejna pavza", @@ -561,7 +561,12 @@ "flags": "Oznake", "include_phase_map": "Vključi fazno karto", "include_settings": "Vključi nastavitve", - "show_contributor": "Prikaži sodelavca" + "show_contributor": "Prikaži sodelavca", + "task_pg_detail": "Simuliraj cikel", + "task_split": "Deljenje cikla", + "task_trim": "Obrezovanje cikla", + "task_merge": "Združevanje ciklov", + "task_rebuild": "Ponovni izračun ovojnic" }, "log": { "all_levels": "Vse ravni", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Padel pod normalni pas moči za ~{n} s.", "artifact_footer": "Označeno na zgornjem grafu. To so prehodni artefakti (npr. vrata so se odprla sredi cikla), ne pa nujno težave.", "artifact_header": "{n} nepravilnosti, odkritih v tem ciklu", - "artifact_pause_detail": "Moč je padla skoraj na nič za ~{n} s in se nato obnovila — verjetno so bila odprta vrata sredi cikla ali je bil cikel zaustavljen.", + "artifact_pause_detail": "Moč je padla skoraj na nič za ~{n} s in se nato obnovila – verjetno so bila odprta vrata sredi cikla ali je bil cikel zaustavljen.", "artifact_spike_detail": "Presegl normalni pas moči za ~{n} s.", "auto_label_intro": "Dodelite profile neoznačenim ciklom, katerih ujemanje presega prag.", "automations_intro": "WashData sproži dogodke {start} / {end} in razkrije entitete, zato je obvestila in dejanja najbolje graditi kot navadne avtomatizacije Home Assistant. Spodaj so prikazane avtomatizacije, ki uporabljajo to napravo.", @@ -654,10 +659,10 @@ "collecting_data": "Zbiranje podatkov: še {need} ciklov pred začetkom finega nastavljanja ({current}/{min}).", "compare_overlay_profiles": "Prekriti profili (bledi)", "compare_profiles_tip": "Prekrijte druge ovojnice profila na zgornji tabeli, da vidite, katera najbolj ustreza temu ciklu.", - "compare_selected_cycles": "Izbrani cikli (polni) — prikaži / skrij", + "compare_selected_cycles": "Izbrani cikli (polni) – prikaži / skrij", "consider_new_profile": "Razmislite o ustvarjanju novega profila.", "coverage_gap": "nedavni cikli nimajo ustreznega profila ({pct} % od zadnjih 30).", - "coverage_gap_similar_cycles": "Najdenih {count} podobnih neoznačenih ciklov — ustvarite profil za začetek ujemanja.", + "coverage_gap_similar_cycles": "Najdenih {count} podobnih neoznačenih ciklov – ustvarite profil za začetek ujemanja.", "cycles_deleted": "Izbrisanih ciklov: {count}", "enough_data": "Dovolj podatkov za učenje ({current}/{min} ciklov).", "export_description": "Izvozite vse profile in cikle v JSON ali obnovite iz prejšnjega izvoza.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modeli natančno prilagojeni tej napravi.", "ml_loading": "nalaganje ML…", "ml_settings_intro": "Dve neodvisni stikali: ena uporablja modele med delovanjem cikla, druga omogoča WashData, da jih sčasoma prilagodi vaši napravi.", - "name_first_program": "Imate dovolj ciklov — poimenujte svoj prvi program in začnite z ujemanjem.", + "name_first_program": "Imate dovolj ciklov – poimenujte svoj prvi program in začnite z ujemanjem.", "near_duplicate_cluster": "zaznana skoraj podvojena gruča profilov. Združevanje omogoča ujemanje, ki zanesljivo izbira med podobnimi programi (npr. isti program pri različni temperaturi/ožemanju).", "no_cycles_match": "Noben cikel se ne ujema z aktualnim filtrom.", "no_cycles_profile": "Za ta profil ni ciklov.", @@ -694,12 +699,12 @@ "no_cycles_yet": "Še ni posnetih ciklov.", "no_device_selected": "Ni izbrane naprave.", "no_devices": "Nobena naprava WashData še ni konfigurirana.", - "no_envelope": "Ovojnice še ni — obnovite po označevanju ciklov.", + "no_envelope": "Ovojnice še ni – obnovite po označevanju ciklov.", "no_envelope_overlay": "Ni ovojnice za prekrivanje.", - "no_fine_tuned": "Še ni natančno prilagojenega — WashData uporablja vgrajene modele.", + "no_fine_tuned": "Še ni natančno prilagojenega – WashData uporablja vgrajene modele.", "no_logs": "Še ni pufiranih zapisov dnevnika.", "no_maintenance": "Vzdrževanje še ni zabeleženo.", - "no_match_yet": "Še ni poskusa ujemanja — zapolni se med delujočim ciklom.", + "no_match_yet": "Še ni poskusa ujemanja – zapolni se med delujočim ciklom.", "no_other_users": "Ni najdenih drugih uporabnikov Home Assistant.", "no_phases": "Ni definiranih faz.", "no_phases_assigned": "Ni dodeljenih faz.", @@ -713,7 +718,7 @@ "notify_services_hint": "Uporabite ID-je storitev {entity} (ločene z vejicami za več storitev). Spremenljivke predloge: {vars}.", "old_actions_warning": "Konfigurirano s starim urejevalnikom dejanj (zdaj odstranjen). Še vedno se sprožijo ob dogodkih cikla, vendar jih tukaj ni več mogoče urejati. Pretvorite jih v običajno avtomatizacijo ali jih odstranite.", "onboarding_progress": "Opazovanih ciklov: {n} / 3", - "onboarding_watching": "Napravo uporabljajte običajno — WashData opazuje. Po 3 ciklih se bo začelo ujemanje programov.", + "onboarding_watching": "Napravo uporabljajte običajno – WashData opazuje. Po 3 ciklih se bo začelo ujemanje programov.", "pending_feedback": "Čakajoče povratne informacije o zaznavi", "performance_trend": "Trend uspešnosti ({n} ciklov)", "pg_analysis_empty": "Naložite cikel za prikaz analize ujemanja.", @@ -748,13 +753,13 @@ "restart_gap_footer": "Označeno na grafu. Podatki o moči manjkajo za te intervale; ujemanje je uporabilo samo dejanske meritve.", "restart_gap_header": "{n} vrzel ponovnega zagona HA med tem ciklom", "restart_gap_item": "{dur} vrzel", - "review_confirm_help": "Preverite, ali je bil ta cikel pravilno zaznan. Vaši pregledi usposabljajo model na vašem stroju — več ciklov kot potrdite, boljše je ujemanje in točkovanje zdravja. Dovolj je hitro Dobro/Slabo.", + "review_confirm_help": "Preverite, ali je bil ta cikel pravilno zaznan. Vaši pregledi usposabljajo model na vašem stroju – več ciklov kot potrdite, boljše je ujemanje in točkovanje zdravja. Dovolj je hitro Dobro/Slabo.", "review_in_settings": "Pregled v nastavitvah", "review_notes_placeholder": "Opombe (neobvezno)", "review_notes_tip": "Opombe s prostim besedilom za lastno referenco. Ne uporablja se pri ujemanju ali usposabljanju.", - "review_profile_tip": "Program, kot je ta cikel označen. Če je bil samodejno zaznan program napačen, ga popravite tukaj — označevanje uči ujemanja za prihodnje cikle.", + "review_profile_tip": "Program, kot je ta cikel označen. Če je bil samodejno zaznan program napačen, ga popravite tukaj – označevanje uči ujemanja za prihodnje cikle.", "review_quality_tip": "Kako čist je ta cikel. Dobro = učbeniški primer tega programa; Slabo = zaznano, vendar hrupno ali netipično; Neuporabno = napačno zaznano (združeno, okrnjeno ali lažno). Poganja rezultat zdravja in kateri cikli so dovoljeni za usposabljanje modela.", - "review_recorded_tip": "Označite to kot ročno izbran referenčni cikel za svoj program — enaka vloga kot ročno posnet cikel. Referenčni cikli so vedno shranjeni, zasejejo ujemajočo se predlogo in se nikoli ne izbrišejo s čiščenjem. (To je zlata/posneta zastava; obe sta enaki.)", + "review_recorded_tip": "Označite to kot ročno izbran referenčni cikel za svoj program – enaka vloga kot ročno posnet cikel. Referenčni cikli so vedno shranjeni, zasejejo ujemajočo se predlogo in se nikoli ne izbrišejo s čiščenjem. (To je zlata/posneta zastava; obe sta enaki.)", "review_tags_tip": "Neobvezne zastavice, ki opisujejo, kaj je šlo narobe s tem ciklom, tako da lahko to pojasnita usposabljanje in čiščenje.", "review_to_cycles": "Odpri čakalno vrsto pregleda ciklov", "saving_triggers_reload": "Shranjevanje sproži ponovni zagon integracije. Entitete HA se lahko za kratek čas prikažejo kot nedostopne.", @@ -763,7 +768,7 @@ "setting_changed": "Spremenjeno iz {old} v {new} dne {date}", "settings_basic_note": "Prikazane so bistvene nastavitve. Za celoten seznam preklopite na Napredno.", "shape_drift_advisory": "⚠ Odmik oblike", - "shape_drift_detail": "Vzorec moči tega profila se je s časom spremenil — možna obraba naprave ali potrebno vzdrževanje (npr. razkamianje, čiščenje filtra).", + "shape_drift_detail": "Vzorec moči tega profila se je s časom spremenil – možna obraba naprave ali potrebno vzdrževanje (npr. razkamianje, čiščenje filtra).", "show_all_settings": "Pokaži vse nastavitve", "showing_suggestions": "Prikaz nastavitve {count} s predlogi.", "split_intro": "Kliknite na graf, da dodate ali odstranite točko razdelitve, ali samodejno zaznajte po nedejavnih vrzelih. Vsak rezultirajoči segment lahko dobi lasten profil.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Razveljavitev ni uspela: {error}", "toast_reverted": "{key} povrnjeno", "toast_save_failed": "Shranjevanje ni uspelo: {error}", - "trend_duration_longer": "Trajanje se povečuje ({pct}%/cikel) — zadnje povprečje {avg}", - "trend_duration_shorter": "Trajanje se skrajšuje ({pct}%/cikel) — zadnje povprečje {avg}", + "trend_duration_longer": "Trajanje se povečuje ({pct}%/cikel) – zadnje povprečje {avg}", + "trend_duration_shorter": "Trajanje se skrajšuje ({pct}%/cikel) – zadnje povprečje {avg}", "trend_energy_down": "Energija pada ({pct}%/cikel)", - "trend_energy_up": "Energija narašča ({pct}%/cikel) — zadnje povprečje {avg}", + "trend_energy_up": "Energija narašča ({pct}%/cikel) – zadnje povprečje {avg}", "trim_intro": "Povlecite rdeče ročke ali vnesite vrednosti. Vse zunaj okna se odstrani.", "tuning_suggestions_available": "{count} predlog za nastavitev je na voljo iz opazovanih ciklov. Pojavijo se poleg ustreznih polj.", "unsure_detected_prefix": "WashData ni prepričan, da je zaznal", @@ -857,7 +862,9 @@ "share_guidelines_title": "Pred deljenjem", "store_download_device_intro": "Prenesi nastavitve skupnostne naprave in jih uporabi za novo ali obstoječo napravo", "store_share_device_intro": "Deli programe svoje naprave (profile + referenčne cikle) s skupnostjo. Nastavitve so neobvezne.", - "share_profile_no_cycles": "Profil '{p}' nima ⭐ referenčnih ciklov -- preskočen bo, razen če imate {n}+ potrjenih zagonov" + "share_profile_no_cycles": "Profil '{p}' nima ⭐ referenčnih ciklov -- preskočen bo, razen če imate {n}+ potrjenih zagonov", + "advisory_phase_inconsistent": "Videti je, da '{name}' meša različne programe ali temperature - njegovi cikli se segrevajo zelo različno dolgo. Če ga razdelite na ločene profile (npr. po temperaturi), boste izboljšali ujemanje in ocene časa.", + "advisory_phase_inconsistent_title": "⚠ Morda pomešani programi" }, "phase_desc": { "anti_crease": "Občasni kratki premiki po zaključku za preprečevanje gub na oblačilih.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Izbirni zunanji signali: sprožilec za konec, senzor vrat, stikalo za premor in opomnik za raztovarjanje.", "label": "Sprožilci in vrata" + }, + "phase_eta": { + "label": "Preostali čas", + "intro": "Preostali čas z upoštevanjem faz, za naprave, katerih dolžina cikla je odvisna od temperature ali ožemanja." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Fiksna cena na kWh, ki se uporablja za podatke o stroških, kadar zgoraj ni nastavljen noben subjekt v živo.", "label": "Statična cena energije (za kWh)" }, + "energy_sensor": { + "doc": "Neobvezni kumulativni števec energije (total_increasing kWh/Wh, npr. lasten števec skupne porabe vtičnice). Ko je nastavljen, se energija, prikazana za vsak cikel, vzame iz razlike odčitkov tega števca med začetkom in koncem, kar prepreči podštevanje, do katerega pride pri integriranju počasi poročajočega senzorja moči. Vrne se na integrirano vrednost, če odčitek manjka, je njegova enota neznana ali razlika ni pozitivna. Pustite prazno, da še naprej integrirate senzor moči.", + "label": "Entiteta merilnika energije" + }, "expose_debug_entities": { "doc": "Objavite dodatne diagnostične entitete HA (zaupanje ujemanja, dvoumnost, notranje stanje). Izključeno ohranja seznam entitet čist za običajno uporabo.", "label": "Izpostavi entitete za odpravljanje napak" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Prikaži ime sodelavca pri profilih, prenesenih iz skupnostne trgovine" + }, + "enable_phase_matching": { + "label": "Preostali čas z upoštevanjem faz", + "doc": "Vsak tekoči cikel razdeli na faze (ogrevanje, pranje, ožemanje) in razporedi preostali čas po fazah, združen s klasično oceno - na začetku cikla se opira na proračun faz, proti koncu pa na klasično oceno. To prilagodi odštevanje temu, kako dolgo se vaša naprava dejansko segreva in deluje, kar je najbolj opazno v prvi polovici cikla. Izklopljeno = samo klasična ocena. Vpliva le na prikaz preostalega časa; ujemanje programov in zaznavanje cikla ostaneta nespremenjena." + }, + "keep_min_score": { + "label": "Min. ocena ujemanja", + "doc": "Minimalna ocena podobnosti, ki jo mora imeti kandidat, da ostane v tekmi za ujemanje. Privzeta vrednost 0,1 je namerno dovoljna - sprejme celo šibke kandidate in se zanaša na kasnejše stopnje za iskanje najboljšega ujemanja. Zvišajte za zgodnejše izločanje malo verjetnih profilov; znižajte za razširitev začetnega nabora kandidatov." + }, + "dtw_blend": { + "label": "Mešanje deformacije", + "doc": "Koliko DTW ocena poravnave nadomešča osnovno oceno Stopnje 2. 0 = uporabi samo oceno Stopnje 2, 1 = uporabi samo DTW oceno, 0,5 (privzeto) = enakomerno mešanje. Zvišajte za večje zanašanje na poravnavo, ko imajo programi podobne ravni moči, a različne časovne vzorce." + }, + "dtw_ensemble_w": { + "label": "Mešanica kombinacije DTW", + "doc": "V kombiniranem načinu DTW (privzetem) je to utež skaliranega L1 v primerjavi z izvedenim DTW (DDTW). 1,0 = vse skalirano L1, 0 = vse DDTW, 0,7 = privzeto. Skalirana varianta L1 primerja ravni moči; izvedena varianta se odziva na spremembe moči skozi čas. Kombinirani način združuje oba." + }, + "dtw_ddtw_scale": { + "label": "Merilo izvedene deformacije", + "doc": "Razdalja pol-nasičenja za ocenjevanje z izvedenim DTW. Ocena se prepolovi, ko je razdalja DDTW enaka tej vrednosti. Manjša = bolj občutljiva na razlike v obliki. Privzeta vrednost 30 je umerjena za tipične sledilce moči gospodinjskih aparatov. Vpliva samo na kombinirani in ddtw način DTW." + }, + "dtw_refine_top_n": { + "label": "Stevilo natancnejših kandidatov", + "doc": "Koliko najboljših kandidatov Stopnje 2 DTW znova oceni. DTW je dražji, zato se uporablja samo za N najboljših kandidatov. Privzeto 5. Zvišajte (na 7-9), če pravi profil po Stopnji 2 včasih doseže samo 4. ali 5. mesto - DTW ga lahko reši. Znižanje rahlo pospeši ujemanje." + }, + "duration_scale": { + "label": "Ostrina trajanja", + "doc": "Ostrina kazni za neujemanje trajanja na Stopnji 4. To je logaritmično razmerje, pri katerem se ocena ujemanja prepolovi. Manjše = strožje: neujemanje trajanja bolj znižuje oceno. Privzeta vrednost 0,175 ustreza toleranci trajanja približno 18% pri pol-uteži. Kombinirajte z Utežjo trajanja." + }, + "energy_scale": { + "label": "Ostrina energije", + "doc": "Ostrina kazni za neujemanje energije na Stopnji 4. Manjše = strožje: neujemanje energije bolj znižuje oceno. Privzeta vrednost 0,25 je namerno bolj dovoljna od Ostrine trajanja, ker energija niha z obremenitvijo. Kombinirajte z Utežjo energije." } }, "setting_group": { @@ -1424,24 +1471,24 @@ "lower": "Več ciklov je samodejno označenih; nekateri so lahko napačno prepoznani" }, "duration_tolerance": { - "higher": "Sprejme širši razpon trajanja — bolj prilagodljivo ujemanje", - "lower": "Zahteva bližje ujemanje trajanja — strožje, a lahko zavrne neobičajne cikle" + "higher": "Sprejme širši razpon trajanja – bolj prilagodljivo ujemanje", + "lower": "Zahteva bližje ujemanje trajanja – strožje, a lahko zavrne neobičajne cikle" }, "end_energy_threshold": { "higher": "Zaključi samo cikle z znatno porabo energije", "lower": "Zaključi tudi krajše ali manj energijsko zahtevne cikle" }, "end_repeat_count": { - "higher": "Zahteva več ponovitev signala konca — bolj zanesljivo, nekoliko počasneje", - "lower": "Konča po manj ponovitvah — hitrejše zaznavanje, večje tveganje lažnega konca" + "higher": "Zahteva več ponovitev signala konca – bolj zanesljivo, nekoliko počasneje", + "lower": "Konča po manj ponovitvah – hitrejše zaznavanje, večje tveganje lažnega konca" }, "learning_confidence": { - "higher": "Uči se le iz zelo zanesljivih ujemanj — počasneje, a zanesljiveje", + "higher": "Uči se le iz zelo zanesljivih ujemanj – počasneje, a zanesljiveje", "lower": "Uči se iz več ciklov; model je lahko manj natančen" }, "min_off_gap": { "higher": "Pred začetkom novega cikla je potreben daljši mir", - "lower": "Kratek mir sproži nov cikel — zaporedni zagoni se lahko razdelijo" + "lower": "Kratek mir sproži nov cikel – zaporedni zagoni se lahko razdelijo" }, "min_power": { "higher": "Manj občutljiv na porabo moči v mirovanju; lahko spregleda zelo tihe programe", @@ -1452,24 +1499,24 @@ "lower": "Zataknjen cikel se prisili zaključiti prej" }, "off_delay": { - "higher": "Več časa za premor naprave — primerno za dolge faze namakanja ali sušenja", - "lower": "Hitrejše zaznavanje konca — primerno za naprave z jasnim vklopom/izklopom" + "higher": "Več časa za premor naprave – primerno za dolge faze namakanja ali sušenja", + "lower": "Hitrejše zaznavanje konca – primerno za naprave z jasnim vklopom/izklopom" }, "profile_match_threshold": { "higher": "Potrdi le visoko zanesljiva ujemanja; več ciklov ostane neoznačenih", "lower": "Ujema več ciklov; nekateri so lahko rahlo napačni" }, "running_dead_zone": { - "higher": "Prezre kratke skoke moči med mirovanjem — manj občutljiv na šum", - "lower": "Odziva se na krajše impulze moči — zazna hitro prehodno aktivnost" + "higher": "Prezre kratke skoke moči med mirovanjem – manj občutljiv na šum", + "lower": "Odziva se na krajše impulze moči – zazna hitro prehodno aktivnost" }, "start_threshold_w": { "higher": "Preprečuje lažne zagone iz kratkih skokov moči", "lower": "Zazna programe z nizko močjo; lahko se odzove na šum" }, "stop_threshold_w": { - "higher": "Zaključi cikel hitreje — lahko zapre med premorom", - "lower": "Čaka dlje na zaključek — manj prezgodnjih zaključkov" + "higher": "Zaključi cikel hitreje – lahko zapre med premorom", + "lower": "Čaka dlje na zaključek – manj prezgodnjih zaključkov" }, "watchdog_interval": { "higher": "Dovoli daljše tihe faze; manj lažnih prisilnih ustavitev", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Sekunde nad pragom za potrditev zagona", "start_threshold_w": "Najmanjša moč v vatih, da šteje kot zagnano", "stop_threshold_w": "Pod to vrednostjo naprava šteje kot izklopljena", - "dtw_bandwidth": "Koliko časovnega raztegovanja dovoljuje ujemanje oblike", - "corr_weight": "Utež oblike krivulje glede na raven moči pri ujemanju", - "duration_weight": "Koliko skladnost trajanja vpliva na ujemanje", - "energy_weight": "Koliko skladnost porabe energije vpliva na ujemanje", - "profile_match_min_duration_ratio": "Najkrajše izvajanje (glede na profil), ki se še lahko ujema", - "profile_match_max_duration_ratio": "Najdaljše izvajanje (glede na profil), ki se še lahko ujema" + "dtw_bandwidth": "Stopnja 3: deformacijski pas Sakoe-Chiba (0 = DTW izklopljen; privzeto 0,2 = 20% dolžine cikla)", + "corr_weight": "Stopnja 2: ravnovesje med obliko krivulje (korelacija) in ravnijo moči (MAE); privzeto 0,45", + "duration_weight": "Stopnja 4: kako močno ujemanje trajanja vpliva na končno oceno (privzeto 0,22)", + "energy_weight": "Stopnja 4: kako močno ujemanje energije vpliva na končno oceno (privzeto 0,22)", + "profile_match_min_duration_ratio": "Stopnja 1: minimalna dolžina cikla kot del trajanja profila; privzeto 0,1 (10%)", + "profile_match_max_duration_ratio": "Stopnja 1: maksimalna dolžina cikla kot del trajanja profila; privzeto 1,5 (150%)", + "keep_min_score": "Stopnja 2: minimalna ocena za ostanok v tekmi; privzeto 0,1 sprejme šibka ujemanja, kasnejše stopnje izberejo najboljše", + "dtw_blend": "Stopnja 3: 0 = samo osnovna ocena, 1 = samo DTW ocena, 0,5 = enakomerno mešanje (privzeto)", + "dtw_ensemble_w": "Stopnja 3 (kombinirani način): utež skaliranega L1 v primerjavi z izvedenim DTW; privzeto 0,7 daje prednost ravni", + "dtw_ddtw_scale": "Stopnja 3: razdalja pol-nasičenja DDTW; manjša = bolj občutljiva na obliko (privzeto 30)", + "dtw_refine_top_n": "Stopnja 3: kandidati, ki jih DTW znova oceni; zvišajte na 7-9, če pravi profil zasede 4.-5. mesto (privzeto 5)", + "duration_scale": "Stopnja 4: logaritmično razmerje, pri katerem se ujemanje trajanja prepolovi; manjše = strožja kazen (privzeto 0,175)", + "energy_scale": "Stopnja 4: logaritmično razmerje, pri katerem se ujemanje energije prepolovi; manjše = strožja kazen (privzeto 0,25)" }, "col": { "profile_tip": "Ime ujemajočega programa. Brez oznake pomeni, da se ob koncu cikla ni ujemal noben profil.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimizacija: {param}" + }, + "pg_detail": { + "simulate": "Simuliraj cikel" + }, + "split": { + "apply": "Deljenje cikla" + }, + "trim": { + "apply": "Obrezovanje cikla" + }, + "merge": { + "apply": "Združevanje ciklov" + }, + "rebuild": { + "envelopes": "Ponovni izračun ovojnic" + }, + "cancelling": "Prekinjam..." + }, + "setup": { + "hdr": { + "card": "Nastavitev naprave", + "healthy_chip": "Nastavitev dokončana" + }, + "phase0": { + "washer": "WashData že zazna vaše cikle. Posnemite prvi cikel, da omogočite imena programov in ocene časa.", + "dishwasher": "WashData opazuje. Pomivalni stroji imajo zapletene cikle – snemanje prvega cikla je izrazito priporočeno. Če zaznani cikel teče predolgo, ga obrežite v urejevalniku ciklov, preden ga shranite kot profil.", + "generic": "WashData opazuje. Posnemite ali označite zaznani cikel in začnite graditi profile." + }, + "phase1a": { + "labelled": "Dober začetek – vaš prvi program je shranjen. Za najčistejše podatke razmislite o snemanju naslednjega cikla s pripomočkom za snemanje." + }, + "phase1b": { + "recorded": "Vaš posnetek je bil shranjen kot {profile_name}. Zdaj posnemite ali označite ostale pogoste programe, da razširite pokritost." + }, + "phase1c": { + "verify": "Imate {count} programov od skupnosti. Zaženite cikel, da preverite, ali jih WashData pravilno prepozna – ujemanje se bo izboljšalo, ko naprava gradi lastno zgodovino." + }, + "phase2": { + "cluster": "WashData je videl {count} ciklov, ki ne ustrezajo nobenemu shranjenemu programu – med seboj so si podobni. Ali želite zanje ustvariti nov profil?", + "unmatched": "Vaš zadnji cikel ni ustrezal nobenemu shranjenemu programu. Je to nov program?" + }, + "phase3": { + "suggestions": "WashData ima priporočila za nastavitve na podlagi vaše zgodovine ciklov – preglejte jih za izboljšanje natančnosti zaznavanja.", + "groups": "Nekateri vaši profili izgledajo kot isti program pri različnih temperaturah. Organizirajte jih v skupino za boljše ujemanje.", + "phases": "Dodajte programske faze k {profile_name} za natančnejše ocene preostalega časa." + }, + "phase4": { + "healthy": "Ta naprava je v celoti nastavljena ({profile_count} profilov)." + }, + "cta": { + "start_recording": "Začni snemanje", + "label_detected_cycle": "Imam že zaznani cikel – namesto tega ga označi", + "browse_cycles": "Prebrskaj cikle za označevanje naslednjega", + "view_profiles": "Oglej si svoje profile", + "create_from_cluster": "Ustvari profil iz teh ciklov", + "create_profile": "Ustvari zanj profil", + "review_suggestions": "Preglej predloge", + "organise_profiles": "Organiziraj profile", + "configure_phases": "Nastavi faze", + "skip_step": "Preskoči ta korak", + "skip_forever": "Ne prikaži znova", + "hide_guidance": "Skrij navodila", + "show_guidance": "Prikaži navodila" } } } diff --git a/custom_components/ha_washdata/translations/panel/sq.json b/custom_components/ha_washdata/translations/panel/sq.json index 68ae68e..cb8e4ba 100644 --- a/custom_components/ha_washdata/translations/panel/sq.json +++ b/custom_components/ha_washdata/translations/panel/sq.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "U zbuluan {n} anomali (p.sh. dera e hapur në mes të ciklit) — hapeni për t'i parë ato në grafik", + "artifact_tip": "U zbuluan {n} anomali (p.sh. dera e hapur në mes të ciklit) – hapeni për t'i parë ato në grafik", "auto": "(zbuluar automatikisht)", "built_in_tag": "i integruar", "declining": "↘ në rënie", "energy_low": "Energji më e ulët se zakonisht", "energy_spike": "Energji më e lartë se zakonisht", "fair_fit": "cilësi e pranueshme", - "fair_fit_tip": "Konsistencë e moderuar e ndeshjes — disa cikle të caktuara për këtë profil kanë rezultate më të ulëta besimi. Etiketoni më shumë cikle ose riregjistroni profilin për të përmirësuar saktësinë.", + "fair_fit_tip": "Konsistencë e moderuar e ndeshjes – disa cikle të caktuara për këtë profil kanë rezultate më të ulëta besimi. Etiketoni më shumë cikle ose riregjistroni profilin për të përmirësuar saktësinë.", "feedback_requested": "Kërkohet koment", "golden_cycle": "Cikli referues i regjistruar", "improving": "↗ në rritje", @@ -15,7 +15,7 @@ "needs_review": "Ka nevojë për rishikim", "overrun": "Zgjati më shumë se zakonisht", "poor_fit": "cilësi e keqe", - "poor_fit_tip": "Histori jokonsistente e ndeshjeve — merrni parasysh rindërtimin e këtij profili", + "poor_fit_tip": "Histori jokonsistente e ndeshjeve – merrni parasysh rindërtimin e këtij profili", "restart_gap_tip": "{n} boshllëk rinisje të HA: gjurma e energjisë ka një vrimë", "reviewed": "Shqyrtuar", "steady": "→ i qëndrueshëm", @@ -87,7 +87,7 @@ "on_cycle_finished": "Kur cikli përfundon", "on_cycle_started": "Kur cikli fillon", "pause_cycle": "Ndalo", - "pause_cycle_tip": "Ndalo ciklin aktiv — pajisja do të vazhdojë nga aty ku kishte mbetur", + "pause_cycle_tip": "Ndalo ciklin aktiv – pajisja do të vazhdojë nga aty ku kishte mbetur", "pg_apply_device": "Apliko te pajisja", "pg_autofill": "Mbushje automatike", "play": "Luaj", @@ -97,8 +97,8 @@ "process_tip": "Ruaj gjurmën e regjistruar si profil të ri ose ekzistues", "rebuild": "Rindërto zarfet", "rebuild_envelope": "Rindërto zarfin", - "rebuild_tip": "Rillogaris zarfin e pritshmë të energjisë (brezin min/maks) për të gjitha profilet nga ciklet e tyre të etiketuara — ekzekuto pas etiketimit të cikleve të reja ose korrigjimit të tjerave", - "rec_start_tip": "Fillo regjistrimin e gjurmës së energjisë të pajisjes — fillo pak para ekzekutimit të një cikli", + "rebuild_tip": "Rillogaris zarfin e pritshmë të energjisë (brezin min/maks) për të gjitha profilet nga ciklet e tyre të etiketuara – ekzekuto pas etiketimit të cikleve të reja ose korrigjimit të tjerave", + "rec_start_tip": "Fillo regjistrimin e gjurmës së energjisë të pajisjes – fillo pak para ekzekutimit të një cikli", "rec_stop": "Ndalo", "rec_stop_tip": "Ndalo regjistrimin dhe mbaj gjurmën e kapur për shqyrtim", "record": "Fillo regjistrimin", @@ -231,7 +231,7 @@ "interval": "Duhet të jetë të paktën 2× Intervali i Kampionimit ({si} s)", "sampling": "Intervali i Kampionimit duhet të jetë të paktën gjysma e Intervalit të Rojtarit ({wi} s)" }, - "settings_banner": "{n} konflikt{s} cilësimi — kontrolloni seksionet e theksuara dhe korrigjojini para ruajtjes.", + "settings_banner": "{n} konflikt{s} cilësimi – kontrolloni seksionet e theksuara dhe korrigjojini para ruajtjes.", "settings_banner_btn": "Shko te i pari" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Shfaq mbivendosjen e kurbës së pritshme (profili i përputhshëm, portokalli)", "show_raw": "Shfaq çelësin e të dhënave bruto të prizës në grafikun e fuqisë në kohë reale", "sparkline": "Prirja e fundit e kohëzgjatjes së cikleve", - "stage2": "Etapa 2 — ngjashmëria bazë", - "stage3": "Etapa 3 — DTW", - "stage4": "Etapa 4 — përputhja", + "stage2": "Etapa 2 – ngjashmëria bazë", + "stage3": "Etapa 3 – DTW", + "stage4": "Etapa 4 – përputhja", "status": "Statusi", "tail_trim": "Prerja e fundit (s)", "timer_auto_pause": "Auto-pauzë", @@ -561,7 +561,12 @@ "flags": "Flamuj", "include_phase_map": "Përfshi hartën e fazave", "include_settings": "Përfshi cilësimet e zbulimit dhe përputhjes", - "show_contributor": "Trego emrat e kontribuesve" + "show_contributor": "Trego emrat e kontribuesve", + "task_pg_detail": "Simulimi i ciklit", + "task_split": "Ndarja e ciklit", + "task_trim": "Prerja e ciklit", + "task_merge": "Bashkimi i cikleve", + "task_rebuild": "Rindërtimi i zarfave" }, "log": { "all_levels": "Të gjitha nivelet", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Ra nën brezin e zakonshëm të fuqisë për ~{n} s.", "artifact_footer": "E theksuar në grafikun e mësipërm. Këto janë objekte kalimtare (p.sh. dera e hapur në mes të ciklit), jo domosdoshmërisht probleme.", "artifact_header": "{n} anomali të zbuluara gjatë këtij cikli", - "artifact_pause_detail": "Fuqia ra pothuajse në zero për ~{n} s dhe u rikuperua — ndoshta dera u hap në mes të ciklit ose cikli u ndërpre.", + "artifact_pause_detail": "Fuqia ra pothuajse në zero për ~{n} s dhe u rikuperua – ndoshta dera u hap në mes të ciklit ose cikli u ndërpre.", "artifact_spike_detail": "Shkoi mbi brezin e zakonshëm të fuqisë për ~{n} s.", "auto_label_intro": "Cakto profile te ciklet e paetiketuara që besueshmëria e tyre e përputhjes tejkalon pragun.", "automations_intro": "WashData ndez ngjarje {start} / {end} dhe ekspozon entitete, kështu njoftimet dhe veprimet ndërtohen më mirë si automatizime normale të Home Assistant. Automatizimi që përdorin këtë pajisje shfaqen më poshtë.", @@ -654,10 +659,10 @@ "collecting_data": "Duke mbledhur të dhëna: nevojiten edhe {need} cikël{plural} para se të fillojë sintonizimi i imët ({current}/{min}).", "compare_overlay_profiles": "Mbivendos profilet (të zbetura)", "compare_profiles_tip": "Mbivendosni zarfat e tjerë të profileve në grafikun e mësipërm për të parë se cili i përshtatet më mirë këtij cikli.", - "compare_selected_cycles": "Ciklet e zgjedhura (solide) — shfaq / fshih", + "compare_selected_cycles": "Ciklet e zgjedhura (solide) – shfaq / fshih", "consider_new_profile": "Konsideroni krijimin e një profili të ri.", "coverage_gap": "ciklet e fundit nuk kanë asnjë profil që përputhet ({pct}% e 30 të fundit).", - "coverage_gap_similar_cycles": "U gjetën {count} cikle të ngjashme të paetiketuara — krijo një profil për të filluar përputhjen.", + "coverage_gap_similar_cycles": "U gjetën {count} cikle të ngjashme të paetiketuara – krijo një profil për të filluar përputhjen.", "cycles_deleted": "{count} cikël(e) u fshinë", "enough_data": "Të dhëna të mjaftueshme për të mësuar ({current}/{min} cikle).", "export_description": "Eksporto të gjitha profilet dhe ciklet në JSON ose rivendos nga një eksportim i mëparshëm.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modele të rregulluara mirë për këtë makinë.", "ml_loading": "duke ngarkuar ML…", "ml_settings_intro": "Dy çelësa të pavarur: njëri aplikon modelet ndërsa një cikël ekzekutohet, tjetri lejon WashData t'i rregullojë ato me kalimin e kohës.", - "name_first_program": "Keni cikle të mjaftueshme — emërtoni programin tuaj të parë për të nisur përputhjen.", + "name_first_program": "Keni cikle të mjaftueshme – emërtoni programin tuaj të parë për të nisur përputhjen.", "near_duplicate_cluster": "u zbulua grupi i profilit pothuajse dublikatë. Grupimi lejon që përputhja të zgjedhë në mënyrë të besueshme midis pamjeve të ngjashme (p.sh. i njëjti program në temperaturë/centrifugim të ndryshëm).", "no_cycles_match": "Asnjë cikël nuk përputhet me filtrin aktual.", "no_cycles_profile": "Asnjë cikël për këtë profil.", @@ -696,7 +701,7 @@ "no_devices": "Asnjë pajisje WashData e konfiguruar ende.", "no_envelope": "Asnjë zarf ende - rindërtoni pas etiketimit të cikleve.", "no_envelope_overlay": "Asnjë zarf i disponueshëm për mbivendosje.", - "no_fine_tuned": "Asgjë e rregulluar mirë ende — WashData po përdor modelet e integruara.", + "no_fine_tuned": "Asgjë e rregulluar mirë ende – WashData po përdor modelet e integruara.", "no_logs": "Asnjë regjistrim i mbajtur në bufer ende.", "no_maintenance": "Ende nuk është regjistruar asnjë mirëmbajtje.", "no_match_yet": "Asnjë tentativë përputhje ende - kjo plotësohet gjatë një cikli aktiv.", @@ -713,7 +718,7 @@ "notify_services_hint": "Përdorni ID-të e shërbimit {entity} (të ndara me presje për shumë). Variablat e shabllonit: {vars}.", "old_actions_warning": "Konfiguruar me redaktuesin e vjetër të veprimeve (tani i hequr). Ato ende ndezin në ngjarjet e ciklit, por nuk mund të modifikohen më këtu. Shndërrojini ato në një automatizim normal ose hiqni ato.", "onboarding_progress": "{n} / 3 cikle të vëzhguara", - "onboarding_watching": "Përdorni pajisjen normalisht — WashData po vëzhgon. Pas 3 cikleve, do të nisë përputhja e programeve.", + "onboarding_watching": "Përdorni pajisjen normalisht – WashData po vëzhgon. Pas 3 cikleve, do të nisë përputhja e programeve.", "pending_feedback": "Koment në pritje të zbulimit", "performance_trend": "Tendenca e performancës ({n} cikle)", "pg_analysis_empty": "Ngarkoni një cikël për të parë analizën e përputhjes.", @@ -763,7 +768,7 @@ "setting_changed": "Ndryshuar nga {old} në {new} më {date}", "settings_basic_note": "Po shfaqen cilësimet thelbësore. Kaloni te Të avancuara për listën e plotë.", "shape_drift_advisory": "⚠ Zhvendosje forme", - "shape_drift_detail": "Modeli i energjisë i këtij profili ka ndryshuar me kalimin e kohës — mundësi të konsumit të pajisjes ose nevojë mirëmbajtjeje (p.sh. heqja e gurëve, pastrimi i filtrit).", + "shape_drift_detail": "Modeli i energjisë i këtij profili ka ndryshuar me kalimin e kohës – mundësi të konsumit të pajisjes ose nevojë mirëmbajtjeje (p.sh. heqja e gurëve, pastrimi i filtrit).", "show_all_settings": "Shfaq të gjitha cilësimet", "showing_suggestions": "Po shfaq cilësimin e {count} me sugjerime.", "split_intro": "Kliko grafikun për të shtuar ose hequr një pikë ndarjeje, ose zbulo automatikisht sipas ndërprerjeve. Çdo segment që rezulton mund të ketë profilin e tij.", @@ -789,7 +794,7 @@ "toast_revert_failed": "Kthimi prapa dështoi: {error}", "toast_reverted": "{key} u rikthye", "toast_save_failed": "Ruajtja dështoi: {error}", - "trend_duration_longer": "Kohëzgjatja në tendencë më e gjatë ({pct}%/cikël) — mesatare e fundit {avg}", + "trend_duration_longer": "Kohëzgjatja në tendencë më e gjatë ({pct}%/cikël) – mesatare e fundit {avg}", "trend_duration_shorter": "Kohëzgjatja në tendencë më e shkurtër ({pct}%/cikël) - mesatare e fundit {avg}", "trend_energy_down": "Tendenca e energjisë në rënie ({pct}%/cikël)", "trend_energy_up": "Tendenca e energjisë në rritje ({pct}%/cikël) - mesatarisht kohët e fundit {avg}", @@ -857,7 +862,9 @@ "share_guidelines_title": "Para se të ndani", "store_download_device_intro": "Adoptoni çdo program të ndarë dhe ciklet e tij të referencës në pajisjen tuaj. Ciklet dhe statistikat tuaja të regjistruara nuk preken.", "store_share_device_intro": "Ngarko {brand} {model} me ciklet e referencës që zgjidhni. Të tjerët me të njëjtën pajisje mund të adoptojnë programet tuaja. Hyrjet shqyrtohen para publikimit.", - "share_profile_no_cycles": "Nuk ka cikle referimi -- shënojeni një cikël si ⭐ tek skeda Ciklet për të përfshirë këtë profil" + "share_profile_no_cycles": "Nuk ka cikle referimi -- shënojeni një cikël si ⭐ tek skeda Ciklet për të përfshirë këtë profil", + "advisory_phase_inconsistent": "'{name}' duket se përzien programe ose temperatura të ndryshme - ciklet e tij ngrohin për periudha shumë të ndryshme kohore. Ndarja e tij në profile të veçanta (p.sh. sipas temperaturës) do të përmirësojë përputhjen dhe vlerësimet e kohës.", + "advisory_phase_inconsistent_title": "⚠ Ndoshta programe të përziera" }, "phase_desc": { "anti_crease": "Rrotullime të shkurtra të herëpashershme pas përfundimit për të reduktuar rrudhat.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Sinjalet e jashtme opsionale: aktivizues përfundimi, senzori i derës, çelësi i pauzës dhe kujtues për shkarkim.", "label": "Aktivizues dhe dera" + }, + "phase_eta": { + "label": "Koha e Mbetur", + "intro": "Kohë e mbetur e ndërgjegjshme për fazat, për makinat gjatësia e ciklit të të cilave varet nga temperatura ose centrifugimi." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Çmimi fiks për kWh i përdorur për shifrat e kostos kur nuk është vendosur më sipër asnjë subjekt i çmimit të drejtpërdrejtë.", "label": "Çmimi statik i energjisë (për kWh)" }, + "energy_sensor": { + "doc": "Numërues kumulativ opsional i energjisë (total_increasing kWh/Wh, p.sh. matësi i vetë prizës për konsumin total). Kur vendoset, energjia e raportuar e çdo cikli merret nga diferenca e këtij numëruesi nga fillimi deri në fund, gjë që shmang nënllogaritjen që merrni nga integrimi i një sensori fuqie që raporton ngadalë. Kthehet te vlera e integruar nëse leximi mungon, njësia e tij është e panjohur, ose diferenca nuk është pozitive. Lëreni bosh për të vazhduar integrimin e sensorit të fuqisë.", + "label": "Entiteti i matësit të energjisë" + }, "expose_debug_entities": { "doc": "Publikoni entitete shtesë diagnostikuese HA (besimi i ndeshjes, paqartësia, të brendshmet e gjendjes). Off e mban listën e entiteteve të pastër për përdorim normal.", "label": "Ekspono entitetet e korrigjimit" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Shfaq atribuimin \"nga \" në pajisjet dhe ciklet referuese të komunitetit." + }, + "enable_phase_matching": { + "label": "Kohë e mbetur e ndërgjegjshme për fazat", + "doc": "Ndan çdo cikël që po funksionon në faza (ngrohje, larje, centrifugim) dhe shpërndan kohën e mbetur për çdo fazë, e përzier me vlerësimin klasik - duke u mbështetur te shpërndarja sipas fazave në fillim të ciklit dhe te vlerësimi klasik afër fundit. Kjo e personalizon numërimin mbrapsht sipas kohës që makina juaj në të vërtetë ngroh dhe punon, gjë që vihet re më së shumti në gjysmën e parë të një cikli. Off = vetëm vlerësimi klasik. Preket vetëm shfaqja e kohës së mbetur; përputhja e programeve dhe zbulimi i ciklit mbeten të pandryshuara." + }, + "keep_min_score": { + "label": "Rezultat Min. Përputhjeje", + "doc": "Rezultati minimal i ngjashmërisë që i nevojitet një kandidati për të qëndruar në garën e përputhjes. Parazgjedhja 0.1 është me qëllim lejuese - pranon edhe kandidatë të dobët dhe u lë etapave të mëvonshme të gjejnë përputhjen më të mirë. Rriteni për të eliminuar profilet e pamundshme herët; uleni për të zgjeruar grupin fillestar të kandidatëve." + }, + "dtw_blend": { + "label": "Përzierje Deformimi", + "doc": "Sa e zëvendëson rezultati i bashkimit DTW rezultatin bazë të Etapës 2. 0 = përdor vetëm rezultatin e Etapës 2, 1 = përdor vetëm rezultatin DTW, 0.5 (parazgjedhja) = përzierje e barabartë. Rriteni për t'u mbështetur më shumë në bashkimin e deformimit kur programet kanë nivele të ngjashme fuqie por modele të ndryshme kohore." + }, + "dtw_ensemble_w": { + "label": "Mix Ansambël DTW", + "doc": "Në modalitetin ansambël DTW (parazgjedhja), pesë për L1-e shkallëzuar kundrejt DTW derivativ (DDTW). 1.0 = gjithë shkallëzuar-L1, 0 = gjithë DDTW, 0.7 = parazgjedhja. Varianti shkallëzuar-L1 krahason nivelet e fuqisë; varianti derivativ reagon ndaj mënyrës sesi ndryshon fuqia me kalimin e kohës. Ansambëli kombinon të dyja." + }, + "dtw_ddtw_scale": { + "label": "Shkalla e Deformimit Derivativ", + "doc": "Distanca e gjysmë-ngopjes për vlerësimin e DTW derivativ. Rezultati gjysmohet kur distanca DDTW është e barabartë me këtë vlerë. Më i vogël = më i ndjeshëm ndaj ndryshimeve të formës. Parazgjedhja 30 është kalibruar për gjurmët tipike të fuqisë së pajisjeve. Ndikon vetëm mënyrat DTW ansambël dhe ddtw." + }, + "dtw_refine_top_n": { + "label": "Numri i Rafinimit DTW", + "doc": "Sa kandidatë të parë të Etapës 2 ri-vlerësohen nga DTW. DTW është më e shtrenjtë llogaritëse, kështu që aplikohet vetëm tek kandidatët N më të mirë. Parazgjedhja 5. Rriteni (në 7-9) nëse profili i saktë ndonjëherë arrin vetëm në vendin e 4-të ose 5-të pas Etapës 2 - DTW mund ta shpëtojë. Ulja e saj shpejton pak përputhjen." + }, + "duration_scale": { + "label": "Saktësia e Kohëzgjatjes", + "doc": "Ashpërsia e ndëshkimit të marrëveshjes së kohëzgjatjes së Etapës 4. Ky është raporti logaritmik në të cilin rezultati i marrëveshjes gjysmohet. Më i vogël = më i rreptë: një mospërputhje kohëzgjatjeje ndëshkon më shumë. Parazgjedhja 0.175 korrespondon me tolerancën e kohëzgjatjes rreth 18% në gjysmë-peshë. Çiftojeni me Peshën e Kohëzgjatjes." + }, + "energy_scale": { + "label": "Saktësia e Energjisë", + "doc": "Ashpërsia e ndëshkimit të marrëveshjes së energjisë së Etapës 4. Më i vogël = më i rreptë: një mospërputhje energjie ndëshkon më shumë. Parazgjedhja 0.25 është me qëllim më lejuese se Ashpërsia e Kohëzgjatjes sepse energjia ndryshon me ngarkesën. Çiftojeni me Peshën e Energjisë." } }, "setting_group": { @@ -1553,24 +1600,24 @@ "lower": "Etiketimet automatike rriten; disa mund të identifikohen gabim" }, "duration_tolerance": { - "higher": "Pranon gamë më të gjerë kohëzgjatjeje — përputhje më fleksibël", - "lower": "Kërkon përputhje më të ngushtë kohëzgjatjeje — më rigoroze por mund të refuzojë cikle të pazakonshme" + "higher": "Pranon gamë më të gjerë kohëzgjatjeje – përputhje më fleksibël", + "lower": "Kërkon përputhje më të ngushtë kohëzgjatjeje – më rigoroze por mund të refuzojë cikle të pazakonshme" }, "end_energy_threshold": { "higher": "Mbyll vetëm ciklet që kanë konsumuar energji të konsiderueshme", "lower": "Mbyll edhe ciklet e shkurtra ose me konsum të ulët energjie" }, "end_repeat_count": { - "higher": "Kërkon që sinjali i fundit të përsëritet më shumë herë — më i qëndrueshëm, pak më i ngadaltë", - "lower": "Mbyllet pas përsëritjeve më të pakta — detektim më i shpejtë, rrezik më i lartë mbylljeje false" + "higher": "Kërkon që sinjali i fundit të përsëritet më shumë herë – më i qëndrueshëm, pak më i ngadaltë", + "lower": "Mbyllet pas përsëritjeve më të pakta – detektim më i shpejtë, rrezik më i lartë mbylljeje false" }, "learning_confidence": { - "higher": "Mëson vetëm nga përputhjet me besueshmëri shumë të lartë — më e ngadaltë por më e besueshme", + "higher": "Mëson vetëm nga përputhjet me besueshmëri shumë të lartë – më e ngadaltë por më e besueshme", "lower": "Mëson nga më shumë cikle; modeli mund të jetë më i zhurmshëm" }, "min_off_gap": { "higher": "Nevojitet pushim më i gjatë para se të fillojë një cikël i ri", - "lower": "Pushimi i shkurtër nis ciklin e ri — rrotullimet e njëpasnjëshme mund të ndahen" + "lower": "Pushimi i shkurtër nis ciklin e ri – rrotullimet e njëpasnjëshme mund të ndahen" }, "min_power": { "higher": "Më pak e ndjeshme ndaj konsumit në gjendje pritjeje; mund të humbasë programe shumë të heshtura", @@ -1581,24 +1628,24 @@ "lower": "Ndërpret ciklin e bllokuar më shpejt" }, "off_delay": { - "higher": "Më shumë kohë për pauza të pajisjes — trajton faza të gjata njomjeje ose tharjeje", - "lower": "Detektim më i shpejtë i fundit — i përshtatshëm për pajisje me ndezje/shuarje të qarta" + "higher": "Më shumë kohë për pauza të pajisjes – trajton faza të gjata njomjeje ose tharjeje", + "lower": "Detektim më i shpejtë i fundit – i përshtatshëm për pajisje me ndezje/shuarje të qarta" }, "profile_match_threshold": { "higher": "Konfirmon vetëm përputhjet me besueshmëri të lartë; më shumë cikle mbeten pa etiketë", "lower": "Përputhet me më shumë cikle; disa mund të jenë paksa të gabuara" }, "running_dead_zone": { - "higher": "Injoron kumbëzat e shkurtra të fuqisë gjatë pushimit — më pak e ndjeshme ndaj zhurmës", - "lower": "Reagron ndaj shpërthimeve më të shkurtra të fuqisë — kap aktivitetin kalimtar të shpejtë" + "higher": "Injoron kumbëzat e shkurtra të fuqisë gjatë pushimit – më pak e ndjeshme ndaj zhurmës", + "lower": "Reagron ndaj shpërthimeve më të shkurtra të fuqisë – kap aktivitetin kalimtar të shpejtë" }, "start_threshold_w": { "higher": "Shmang startet false nga kumbëzat e shkurtra të fuqisë", "lower": "Kap programet me fuqi të ulët; mund të aktivizohet nga zhurma" }, "stop_threshold_w": { - "higher": "Mbyll ciklin më shpejt — mund të mbyllet gjatë një pauze", - "lower": "Pret më gjatë para mbylljes — redukton përfundimet e parakohshme" + "higher": "Mbyll ciklin më shpejt – mund të mbyllet gjatë një pauze", + "lower": "Pret më gjatë para mbylljes – redukton përfundimet e parakohshme" }, "watchdog_interval": { "higher": "Lejon faza më të gjata të heshtura; më pak ndërprerje false", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Sekonda mbi prag për të konfirmuar nisjen", "start_threshold_w": "Watt minimalë për t'u llogaritur si nisur", "stop_threshold_w": "Nën këtë, makina llogaritet e fikur", - "dtw_bandwidth": "Sa shtrembërim kohor lejon përputhja e formës", - "corr_weight": "Pesha e formës së kurbës kundrejt nivelit të fuqisë në përputhje", - "duration_weight": "Sa ndikon përkimi i kohëzgjatjes te përputhja", - "energy_weight": "Sa ndikon përkimi i energjisë te përputhja", - "profile_match_min_duration_ratio": "Cikli më i shkurtër (krahasuar me profilin) që lejohet ende të përputhet", - "profile_match_max_duration_ratio": "Cikli më i gjatë (krahasuar me profilin) që lejohet ende të përputhet" + "dtw_bandwidth": "Etapa 3: brez deformimi Sakoe-Chiba (0 = DTW joaktiv; parazgjedhja 0.2 = 20% e gjatësisë së ciklit)", + "corr_weight": "Etapa 2: balancë mes formës së kurbës (korrelacion) dhe nivelit të fuqisë (MAE); parazgjedhja 0.45", + "duration_weight": "Etapa 4: sa fortë ndikon marrëveshja e kohëzgjatjes në rezultatin final (parazgjedhja 0.22)", + "energy_weight": "Etapa 4: sa fortë ndikon marrëveshja e energjisë në rezultatin final (parazgjedhja 0.22)", + "profile_match_min_duration_ratio": "Etapa 1: gjatësia minimale e ciklit si pjesë e kohëzgjatjes së profilit; parazgjedhja 0.1 (10%)", + "profile_match_max_duration_ratio": "Etapa 1: gjatësia maksimale e ciklit si pjesë e kohëzgjatjes së profilit; parazgjedhja 1.5 (150%)", + "keep_min_score": "Etapa 2: rezultati minimal për të qëndruar në garë; parazgjedhja 0.1 pranon përputhje të dobëta, etapat e mëvonshme zgjedhin më të mirën", + "dtw_blend": "Etapa 3: 0 = vetëm rezultati bazë, 1 = vetëm rezultati DTW, 0.5 = përzierje e barabartë (parazgjedhja)", + "dtw_ensemble_w": "Etapa 3 (modaliteti ansambël): pesë për L1-e shkallëzuar kundrejt DTW derivativ; parazgjedhja 0.7 favorizon nivelin e fuqisë", + "dtw_ddtw_scale": "Etapa 3: distanca e gjysmë-ngopjes DDTW; më e vogël = më e ndjeshme ndaj formës (parazgjedhja 30)", + "dtw_refine_top_n": "Etapa 3: kandidatë të rishënuara nga DTW; rriteni në 7-9 nëse profili i saktë renditet i 4-5 (parazgjedhja 5)", + "duration_scale": "Etapa 4: raporti logaritmik ku marrëveshja e kohëzgjatjes gjysmohet; më i vogël = ndëshkim më i rreptë (parazgjedhja 0.175)", + "energy_scale": "Etapa 4: raporti logaritmik ku marrëveshja e energjisë gjysmohet; më i vogël = ndëshkim më i rreptë (parazgjedhja 0.25)" }, "col": { "profile_tip": "Emri i programit të përputhur. I paetiketuar do të thotë se asnjë profil nuk u përputh në fund të ciklit.", @@ -1766,6 +1820,68 @@ }, "pg_sweep": { "optimize": "Optimizim: {param}" + }, + "pg_detail": { + "simulate": "Simulimi i ciklit" + }, + "split": { + "apply": "Ndarja e ciklit" + }, + "trim": { + "apply": "Prerja e ciklit" + }, + "merge": { + "apply": "Bashkimi i cikleve" + }, + "rebuild": { + "envelopes": "Rindërtimi i zarfave" + } + }, + "setup": { + "hdr": { + "card": "Konfigurimi i pajisjes", + "healthy_chip": "Konfigurimi u kompletua" + }, + "phase0": { + "washer": "WashData tashmë po zbulon ciklet tuaja. Regjistroni ciklin tuaj të parë për të aktivizuar emrat e programeve dhe vlerësimet e kohës.", + "dishwasher": "WashData po vëzhgon. Lavatriçet e enëve kanë cikle komplekse – regjistrimi i ciklit tuaj të parë rekomandohet fuqimisht. Nëse një cikël i zbuluar zgjat shumë, përdorni redaktuesin e ciklit për ta prerë para se ta ruani si profil.", + "generic": "WashData po vëzhgon. Regjistroni ose etiketoni një cikël të zbuluar për të filluar ndërtimin e profileve." + }, + "phase1a": { + "labelled": "Fillim i mirë – programi juaj i parë është ruajtur. Për të dhëna sa më të pastra, merrni parasysh regjistrimin e ciklit tuaj të ardhshëm me veglën e regjistruesit." + }, + "phase1b": { + "recorded": "Regjistrimi juaj u ruajt si {profile_name}. Tani regjistroni ose etiketoni programet tuaja të tjera të zakonshme për të ndërtuar mbulimin." + }, + "phase1c": { + "verify": "Keni {count} programe nga komuniteti. Ekzekutoni një cikël për të verifikuar nëse WashData e njeh saktë – përputhja do të përmirësohet ndërsa pajisja juaj ndërton historinë e vet." + }, + "phase2": { + "cluster": "WashData ka parë {count} cikle që nuk përputhen me asnjë program të ruajtur – ato duken të ngjashme me njëra-tjetrën. Dëshironi të krijoni një profil të ri për to?", + "unmatched": "Cikli juaj i fundit nuk u përputh me asnjë program të ruajtur. A është një program i ri?" + }, + "phase3": { + "suggestions": "WashData ka rekomandime për cilësimet bazuar në historikun tuaj të cikleve – shqyrtojini ato për të përmirësuar saktësinë e zbulimit.", + "groups": "Disa nga profilet tuaja duken si i njëjti program në temperatura të ndryshme. Organizojini ato në një grup për përputhje më të mirë.", + "phases": "Shtoni faza programi te {profile_name} për vlerësime më të sakta të kohës së mbetur." + }, + "phase4": { + "healthy": "Kjo pajisje është konfiguruar plotësisht ({profile_count} profile)." + }, + "cta": { + "start_recording": "Fillo regjistrimin", + "label_detected_cycle": "Kam tashmë një cikël të zbuluar – etiketoje atë", + "browse_cycles": "Shfleto ciklet për të etiketuar një tjetër", + "view_profiles": "Shiko profilet tuaja", + "create_from_cluster": "Krijo profil nga këto cikle", + "create_profile": "Krijo një profil për të", + "review_suggestions": "Shqyrto sugjerimet", + "organise_profiles": "Organizo profilet", + "configure_phases": "Konfiguro fazat", + "skip_step": "Anashkalo këtë hap", + "skip_forever": "Mos shfaq më", + "hide_guidance": "Fshih udhëzuesin", + "show_guidance": "Shfaq udhëzuesin" } } } diff --git a/custom_components/ha_washdata/translations/panel/sr-Latn.json b/custom_components/ha_washdata/translations/panel/sr-Latn.json index a053ed2..4008042 100644 --- a/custom_components/ha_washdata/translations/panel/sr-Latn.json +++ b/custom_components/ha_washdata/translations/panel/sr-Latn.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "Otkriveno je {n} anomalija (npr. vrata otvorena usred ciklusa) — otvorite da ih vidite na grafikonu", + "artifact_tip": "Otkriveno je {n} anomalija (npr. vrata otvorena usred ciklusa) – otvorite da ih vidite na grafikonu", "auto": "(automatski otkriveno)", "built_in_tag": "ugrađen", "declining": "↘ opadajući", "energy_low": "Manja potrošnja energije nego obično", "energy_spike": "Veća potrošnja energije nego obično", "fair_fit": "prihvatljiva kvaliteta", - "fair_fit_tip": "Umerena doslednost podudaranja — neki ciklusi dodeljeni ovom profilu imaju niže ocene pouzdanosti. Označite više ciklusa ili ponovo snimite profil da biste poboljšali tačnost.", + "fair_fit_tip": "Umerena doslednost podudaranja – neki ciklusi dodeljeni ovom profilu imaju niže ocene pouzdanosti. Označite više ciklusa ili ponovo snimite profil da biste poboljšali tačnost.", "feedback_requested": "Zatražena povratna informacija", "golden_cycle": "Snimljeni referentni ciklus", "improving": "↗ poboljšava se", @@ -15,7 +15,7 @@ "needs_review": "Potreban pregled", "overrun": "Trajao duže nego obično", "poor_fit": "loša kvaliteta", - "poor_fit_tip": "Nedosledna istorija podudaranja — razmislite o ponovnoj izgradnji ovog profila", + "poor_fit_tip": "Nedosledna istorija podudaranja – razmislite o ponovnoj izgradnji ovog profila", "restart_gap_tip": "{n} HA praznina ponovnog pokretanja: trag snage ima rupu", "reviewed": "Pregledano", "steady": "→ stabilan", @@ -87,7 +87,7 @@ "on_cycle_finished": "Po završetku ciklusa", "on_cycle_started": "Po pokretanju ciklusa", "pause_cycle": "Pauziraj", - "pause_cycle_tip": "Pauziraj aktivni ciklus — uređaj će se nastaviti od mesta gde je stao", + "pause_cycle_tip": "Pauziraj aktivni ciklus – uređaj će se nastaviti od mesta gde je stao", "pg_apply_device": "Primeni na uređaj", "pg_autofill": "Automatsko popunjavanje", "play": "Reprodukuj", @@ -97,8 +97,8 @@ "process_tip": "Sačuvaj snimljeni trag kao novi ili postojeći profil", "rebuild": "Obnovi omotače", "rebuild_envelope": "Obnovi omotač", - "rebuild_tip": "Ponovo izračunaj očekivani omotač snage (min/maks opseg) za sve profile iz njihovih označenih ciklusa — pokreni nakon označavanja novih ciklusa ili ispravljanja starih", - "rec_start_tip": "Počni snimanje traga snage uređaja — pokreni neposredno pre pokretanja ciklusa", + "rebuild_tip": "Ponovo izračunaj očekivani omotač snage (min/maks opseg) za sve profile iz njihovih označenih ciklusa – pokreni nakon označavanja novih ciklusa ili ispravljanja starih", + "rec_start_tip": "Počni snimanje traga snage uređaja – pokreni neposredno pre pokretanja ciklusa", "rec_stop": "Zaustavi", "rec_stop_tip": "Zaustavi snimanje i zadrži snimljeni trag radi pregleda", "record": "Počni snimanje", @@ -231,7 +231,7 @@ "interval": "Treba biti najmanje 2× Interval uzorkovanja ({si} s)", "sampling": "Interval uzorkovanja treba biti najviše pola Intervala nadzornika ({wi} s)" }, - "settings_banner": "{n} konflikt{s} podešavanja — proverite istaknute odeljke i ispravite pre čuvanja.", + "settings_banner": "{n} konflikt{s} podešavanja – proverite istaknute odeljke i ispravite pre čuvanja.", "settings_banner_btn": "Idi na prvi" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Prikaži preklop očekivane krive (usklađeni profil, narandžasta)", "show_raw": "Prikaži prekidač sirovih očitavanja utičnice u živom grafu snage", "sparkline": "Nedavni trend trajanja ciklusa", - "stage2": "Etapa 2 — osnovna sličnost", - "stage3": "Etapa 3 — DTW", - "stage4": "Etapa 4 — slaganje", + "stage2": "Etapa 2 – osnovna sličnost", + "stage3": "Etapa 3 – DTW", + "stage4": "Etapa 4 – slaganje", "status": "Status", "tail_trim": "Isecanje kraja (s)", "timer_auto_pause": "Automatska pauza", @@ -561,7 +561,12 @@ "flags": "Oznake", "include_phase_map": "Uključi mapu faza", "include_settings": "Uključi podešavanja", - "show_contributor": "Prikaži saradnika" + "show_contributor": "Prikaži saradnika", + "task_pg_detail": "Simuliraj ciklus", + "task_split": "Deljenje ciklusa", + "task_trim": "Obrezivanje ciklusa", + "task_merge": "Spajanje ciklusa", + "task_rebuild": "Ponovna izgradnja omotača" }, "log": { "all_levels": "Svi nivoi", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Pao ispod uobičajenog opsega snage za ~{n} s.", "artifact_footer": "Istaknuto na grafikonu iznad. Ovo su prolazni artefakti (npr. vrata se otvaraju usred ciklusa), ne nužno problemi.", "artifact_header": "{n} anomalija otkrivenih tokom ovog ciklusa", - "artifact_pause_detail": "Snaga je pala gotovo na nulu za ~{n} s i zatim se obnovila — verovatno su se otvorila vrata usred ciklusa ili je ciklus bio pauziran.", + "artifact_pause_detail": "Snaga je pala gotovo na nulu za ~{n} s i zatim se obnovila – verovatno su se otvorila vrata usred ciklusa ili je ciklus bio pauziran.", "artifact_spike_detail": "Prešao uobičajeni opseg snage za ~{n} s.", "auto_label_intro": "Dodeli profile neoznačenim ciklusima čija pouzdanost podudaranja prelazi prag.", "automations_intro": "WashData okida događaje {start} / {end} i izlaže entitete, pa je obaveštenja i akcije najbolje graditi kao normalne automatizacije Home Assistanta. Automatizacije koje koriste ovaj uređaj prikazane su ispod.", @@ -654,10 +659,10 @@ "collecting_data": "Prikupljanje podataka: još {need} ciklusa do početka finog podešavanja ({current}/{min}).", "compare_overlay_profiles": "Preklapanje profila (bledi)", "compare_profiles_tip": "Prekrijte druge omotače profila na gornjem grafikonu da vidite koji najbolje odgovara ovom ciklusu.", - "compare_selected_cycles": "Izabrani ciklusi (neprozirni) — prikaži / sakrij", + "compare_selected_cycles": "Izabrani ciklusi (neprozirni) – prikaži / sakrij", "consider_new_profile": "Razmislite o kreiranju novog profila.", "coverage_gap": "nedavni ciklusi nemaju odgovarajući profil ({pct}% od poslednjih 30).", - "coverage_gap_similar_cycles": "{count} sličnih neoznačenih ciklusa pronađeno — napravite profil da biste počeli njihovo podudaranje.", + "coverage_gap_similar_cycles": "{count} sličnih neoznačenih ciklusa pronađeno – napravite profil da biste počeli njihovo podudaranje.", "cycles_deleted": "Obrisano ciklusa: {count}", "enough_data": "Dovoljno podataka za učenje ({current}/{min} ciklusa).", "export_description": "Izvezite sve profile i cikluse u JSON ili vratite iz prethodnog izvoza.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modeli fino podešeni za ovu mašinu.", "ml_loading": "učitavanje ML…", "ml_settings_intro": "Dve nezavisne sklopke: jedna primenjuje modele dok se ciklus izvodi, druga omogućava WashData da ih fino podesi za vašu mašinu tokom vremena.", - "name_first_program": "Imate dovoljno ciklusa — imenujte svoj prvi program da biste započeli uparivanje.", + "name_first_program": "Imate dovoljno ciklusa – imenujte svoj prvi program da biste započeli uparivanje.", "near_duplicate_cluster": "otkrivena je skoro duplirana grupa profila. Grupisanje omogućava pouzdano podudaranje da bira između sličnih (npr. isti program na različitoj temperaturi/centrifugiranju).", "no_cycles_match": "Nema ciklusa koji odgovaraju trenutnom filteru.", "no_cycles_profile": "Nema ciklusa za ovaj profil.", @@ -696,7 +701,7 @@ "no_devices": "Nema WashData uređaja konfigurisanih još.", "no_envelope": "Nema omotača još - obnovi nakon označavanja ciklusa.", "no_envelope_overlay": "Nema dostupnog omotača za preklapanje.", - "no_fine_tuned": "Ništa fino podešeno još — WashData koristi ugrađene modele.", + "no_fine_tuned": "Ništa fino podešeno još – WashData koristi ugrađene modele.", "no_logs": "Nema dnevničkih zapisa baferovanih još.", "no_maintenance": "Održavanje još nije zabeleženo.", "no_match_yet": "Nema pokušaja podudaranja još - ovo se popunjava tokom aktivnog ciklusa.", @@ -713,7 +718,7 @@ "notify_services_hint": "Koristite ID-ove servisa {entity} (odvojene zarezima za više). Promenljive šablona: {vars}.", "old_actions_warning": "Konfigurisano sa starim uređivačem radnji (sada uklonjeno). I dalje se aktiviraju na događaje ciklusa, ali više ne mogu da se uređuju ovde. Pretvorite ih u normalnu automatizaciju ili ih uklonite.", "onboarding_progress": "Posmatrano ciklusa: {n} / 3", - "onboarding_watching": "Koristite uređaj normalno — WashData posmatra. Nakon 3 ciklusa počeće uparivanje programa.", + "onboarding_watching": "Koristite uređaj normalno – WashData posmatra. Nakon 3 ciklusa počeće uparivanje programa.", "pending_feedback": "Povratna informacija detekcije na čekanju", "performance_trend": "Trend učinka ({n} ciklusa)", "pg_analysis_empty": "Učitajte ciklus da vidite analizu poklapanja.", @@ -763,7 +768,7 @@ "setting_changed": "Promenjeno sa {old} na {new} dana {date}", "settings_basic_note": "Prikazuju se osnovna podešavanja. Prebacite na Napredno za potpunu listu.", "shape_drift_advisory": "⚠ Pomeraj oblika", - "shape_drift_detail": "Obrazac snage ovog profila se promenio s vremenom — moguće habanje uređaja ili potrebno održavanje (npr. uklanjanje kamenca, čišćenje filtera).", + "shape_drift_detail": "Obrazac snage ovog profila se promenio s vremenom – moguće habanje uređaja ili potrebno održavanje (npr. uklanjanje kamenca, čišćenje filtera).", "show_all_settings": "Prikaži sva podešavanja", "showing_suggestions": "Prikazuje se podešavanje {count} sa prijedlozima.", "split_intro": "Klikni na grafikon za dodavanje ili uklanjanje tačke deljenja, ili automatski otkrij po prazninama. Svaki rezultujući segment može dobiti sopstveni profil.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Povrat nije uspeo: {error}", "toast_reverted": "{key} vraćeno", "toast_save_failed": "Čuvanje nije uspelo: {error}", - "trend_duration_longer": "Trajanje je u trendu duže ({pct}%/ciklus) — nedavno u proseku {avg}", - "trend_duration_shorter": "Trajanje u trendu kraće ({pct}%/ciklus) — nedavno u proseku {avg}", + "trend_duration_longer": "Trajanje je u trendu duže ({pct}%/ciklus) – nedavno u proseku {avg}", + "trend_duration_shorter": "Trajanje u trendu kraće ({pct}%/ciklus) – nedavno u proseku {avg}", "trend_energy_down": "Energija opada ({pct}%/ciklus)", - "trend_energy_up": "Energetski trend raste ({pct}%/ciklus) — nedavni prosek {avg}", + "trend_energy_up": "Energetski trend raste ({pct}%/ciklus) – nedavni prosek {avg}", "trim_intro": "Prevuci crvene ručice ili unesi vrednosti. Sve izvan prozora se uklanja.", "tuning_suggestions_available": "{count} prijedlog podešavanja dostupan je iz posmatranih ciklusa. Pojavljuju se pored relevantnih polja.", "unsure_detected_prefix": "WashData nije siguran da je otkrio", @@ -857,7 +862,9 @@ "share_guidelines_title": "Pre deljenja", "store_download_device_intro": "Preuzmite podešavanja uređaja iz zajednice i primenite ih na novi ili postojeći uređaj", "store_share_device_intro": "Podelite programe svog uređaja (profile + referentne cikluse) sa zajednicom. Podešavanja su opcionalna.", - "share_profile_no_cycles": "Profil '{p}' nema ⭐ referentnih ciklusa -- biće preskočen osim ako nemate {n}+ potvrđenih pokretanja" + "share_profile_no_cycles": "Profil '{p}' nema ⭐ referentnih ciklusa -- biće preskočen osim ako nemate {n}+ potvrđenih pokretanja", + "advisory_phase_inconsistent": "Izgleda da '{name}' meša različite programe ili temperature - njegovi ciklusi se greju veoma različito dugo. Podela na zasebne profile (npr. po temperaturi) poboljšaće podudaranje i procene vremena.", + "advisory_phase_inconsistent_title": "⚠ Možda pomešani programi" }, "phase_desc": { "anti_crease": "Povremena kratka okretanja nakon završetka radi smanjenja gužvanja.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Opcionalni spoljni signali: okidač kraja, senzor vrata, prekidač pauze i podsetnik za istovar.", "label": "Okidači i vrata" + }, + "phase_eta": { + "label": "Preostalo vreme", + "intro": "Procena preostalog vremena zasnovana na fazama, za mašine čija dužina ciklusa zavisi od temperature ili centrifuge." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Fiksna cena po kVh koja se koristi za brojke troškova kada nije postavljen nijedan entitet cene uživo iznad.", "label": "Statična cena energije (po kWh)" }, + "energy_sensor": { + "doc": "Opciono kumulativno brojilo energije (total_increasing kWh/Wh, npr. sopstveno brojilo ukupne potrošnje utičnice). Kada je postavljeno, energija prijavljena za svaki ciklus uzima se iz razlike očitavanja ovog brojila između početka i kraja, čime se izbegava potcenjivanje koje nastaje integracijom sporo izveštavajućeg senzora snage. Vraća se na integrisanu vrednost ako očitavanje nedostaje, njegova jedinica je nepoznata ili razlika nije pozitivna. Ostavite prazno da biste nastavili da integrišete senzor snage.", + "label": "Entitet brojila energije" + }, "expose_debug_entities": { "doc": "Objavite dodatne dijagnostičke HA entitete (pouzdanost podudaranja, dvosmislenost, unutrašnje karakteristike stanja). Isključeno održava listu entiteta čistom za normalnu upotrebu.", "label": "Izloži entitete za otklanjanje grešaka" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Prikaži ime saradnika na profilima preuzetim iz prodavnice zajednice" + }, + "enable_phase_matching": { + "label": "Preostalo vreme prilagođeno fazama", + "doc": "Deli svaki aktivni ciklus na faze (zagrevanje, pranje, centrifuga) i raspoređuje preostalo vreme po fazama, u kombinaciji sa klasičnom procenom - oslanjajući se na raspored po fazama na početku ciklusa, a na klasičnu procenu pri kraju. Ovo prilagođava odbrojavanje tome koliko se vaša mašina zaista greje i radi, što je najuočljivije u prvoj polovini ciklusa. Isključeno = samo klasična procena. Utiče samo na prikaz preostalog vremena; podudaranje programa i detekcija ciklusa ostaju nepromenjeni." + }, + "keep_min_score": { + "label": "Min. rezultat podudaranja", + "doc": "Minimalni rezultat sličnosti koji kandidat mora imati da bi ostao u utrci podudaranja. Podrazumevana vrednost 0,1 je namerno benigna - prihvata čak i slabe kandidate i oslanja se na kasnijе faze za pronalazak najboljeg podudaranja. Povećajte za ranije odbacivanje neprobabilnih profila; smanjite za proširenje početnog skupa kandidata." + }, + "dtw_blend": { + "label": "Mešanje deformacije", + "doc": "Koliko DTW rezultat poravnanja zamenjuje osnovni rezultat Faze 2. 0 = koristi samo rezultat Faze 2, 1 = koristi samo DTW rezultat, 0,5 (podrazumevano) = jednako mešanje. Povećajte za veće oslanjanje na warp poravnanje kada programi imaju slične nivoe snage ali različite obrasce tajminga." + }, + "dtw_ensemble_w": { + "label": "Miks kombinacije DTW", + "doc": "U kombinovanom DTW režimu (podrazumevanom), težina skaliranog L1 naspram derivatnog DTW (DDTW). 1,0 = sve skalirano L1, 0 = sve DDTW, 0,7 = podrazumevano. Skalirana L1 varijanta upoređuje nivoe snage; derivatna varijanta reaguje na promene snage tokom vremena. Kombinovani režim spaja oba." + }, + "dtw_ddtw_scale": { + "label": "Razmera derivatne deformacije", + "doc": "Rastojanje polu-zasićenja za ocenjivanje derivatnim DTW. Rezultat se prepolovi kada rastojanje DDTW bude jednako ovoj vrednosti. Manje = osetljivije na razlike u obliku. Podrazumevana vrednost 30 je kalibrisana za tipične tragove snage kućanskih uređaja. Utiče samo na kombinovani i ddtw DTW režim." + }, + "dtw_refine_top_n": { + "label": "Broj preciziranih kandidata", + "doc": "Koliko prvih kandidata Faze 2 DTW ponovo ocenjuje. DTW je skuplji pa se primenjuje samo na N najboljih kandidata. Podrazumevano 5. Povećajte (na 7-9) ako ispravni profil ponekad dostigne samo 4. ili 5. mesto nakon Faze 2 - DTW ga može spasiti. Smanjenje malo ubrzava podudaranje." + }, + "duration_scale": { + "label": "Ostrina trajanja", + "doc": "Oštrina kazne za nepodudaranje trajanja u Fazi 4. Ovo je odnos logaritma pri kome se rezultat slaganja prepolovi. Manje = strože: nepodudaranje trajanja više snižava rezultat. Podrazumevana vrednost 0,175 odgovara toleranciji trajanja od oko 18% pri polu-težini. Koristite zajedno s Težinom trajanja." + }, + "energy_scale": { + "label": "Ostrina energije", + "doc": "Oštrina kazne za nepodudaranje energije u Fazi 4. Manje = strože: nepodudaranje energije više snižava rezultat. Podrazumevana vrednost 0,25 je namerno blaža od Oštrine trajanja jer energija varira s opterećenjem. Koristite zajedno s Težinom energije." } }, "setting_group": { @@ -1424,24 +1471,24 @@ "lower": "Više ciklusa se automatski označava; neki mogu biti pogrešno identifikovani" }, "duration_tolerance": { - "higher": "Prihvata širi opseg trajanja — fleksibilnije podudaranje", - "lower": "Zahteva bliže podudaranje trajanja — strože, ali može odbiti neobične cikluse" + "higher": "Prihvata širi opseg trajanja – fleksibilnije podudaranje", + "lower": "Zahteva bliže podudaranje trajanja – strože, ali može odbiti neobične cikluse" }, "end_energy_threshold": { "higher": "Zatvara samo cikluse sa značajnom potrošnjom energije", "lower": "Zatvara i kraće ili manje energetske cikluse" }, "end_repeat_count": { - "higher": "Zahteva više ponavljanja signala kraja — pouzdanije, malo sporije", - "lower": "Završava nakon manje ponavljanja — brže otkrivanje, veći rizik lažnog završetka" + "higher": "Zahteva više ponavljanja signala kraja – pouzdanije, malo sporije", + "lower": "Završava nakon manje ponavljanja – brže otkrivanje, veći rizik lažnog završetka" }, "learning_confidence": { - "higher": "Uči se samo iz visoko pouzdanih podudaranja — sporije, ali pouzdanije", + "higher": "Uči se samo iz visoko pouzdanih podudaranja – sporije, ali pouzdanije", "lower": "Uči se iz više ciklusa; model može biti manje precizan" }, "min_off_gap": { "higher": "Potreban je duži odmor pre početka novog ciklusa", - "lower": "Kratak odmor pokreće novi ciklus — uzastopni ciklusi se mogu razdeliti" + "lower": "Kratak odmor pokreće novi ciklus – uzastopni ciklusi se mogu razdeliti" }, "min_power": { "higher": "Manje osetljiv na potrošnju u mirovanju; može propustiti tihe programe", @@ -1452,24 +1499,24 @@ "lower": "Zaglavljen ciklus se prisilno zaustavlja ranije" }, "off_delay": { - "higher": "Više vremena za pauze uređaja — prikladno za duge faze namakanja ili sušenja", - "lower": "Brže otkrivanje kraja — dobro za uređaje s čistim uključivanjem/isključivanjem" + "higher": "Više vremena za pauze uređaja – prikladno za duge faze namakanja ili sušenja", + "lower": "Brže otkrivanje kraja – dobro za uređaje s čistim uključivanjem/isključivanjem" }, "profile_match_threshold": { "higher": "Potvrđuje samo visoko pouzdana podudaranja; više ciklusa ostaje neoznačeno", "lower": "Podudara više ciklusa; neki mogu biti blago pogrešni" }, "running_dead_zone": { - "higher": "Zanemaruje kratke skokove snage tokom mirovanja — manje osetljiv na šum", - "lower": "Reaguje na kraće impulse snage — otkriva brzu prolaznu aktivnost" + "higher": "Zanemaruje kratke skokove snage tokom mirovanja – manje osetljiv na šum", + "lower": "Reaguje na kraće impulse snage – otkriva brzu prolaznu aktivnost" }, "start_threshold_w": { "higher": "Sprečava lažne startove od kratkih skokova snage", "lower": "Otkriva programe s niskom snagom; može reagovati na šum" }, "stop_threshold_w": { - "higher": "Završava ciklus brže — može zatvoriti tokom pauze", - "lower": "Duže čeka na završetak — manje prevremenih završetaka" + "higher": "Završava ciklus brže – može zatvoriti tokom pauze", + "lower": "Duže čeka na završetak – manje prevremenih završetaka" }, "watchdog_interval": { "higher": "Dozvoljava duže tihe faze; manje lažnih prisilnih zaustavljanja", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Sekundi iznad praga za potvrdu pokretanja", "start_threshold_w": "Minimum vati da bi se računalo kao pokrenuto", "stop_threshold_w": "Ispod ovoga mašina se računa kao isključena", - "dtw_bandwidth": "Koliko vremenskog izobličenja dozvoljava podudaranje oblika", - "corr_weight": "Težina oblika krive u odnosu na nivo snage pri podudaranju", - "duration_weight": "Koliko slaganje trajanja utiče na podudaranje", - "energy_weight": "Koliko slaganje potrošnje energije utiče na podudaranje", - "profile_match_min_duration_ratio": "Najkraće izvođenje (u odnosu na profil) koje još može da se podudari", - "profile_match_max_duration_ratio": "Najduže izvođenje (u odnosu na profil) koje još može da se podudari" + "dtw_bandwidth": "Faza 3: warp opseg Sakoe-Chiba (0 = DTW isključen; podrazumevano 0,2 = 20% dužine ciklusa)", + "corr_weight": "Faza 2: ravnoteža između oblika krive (korelacija) i nivoa snage (MAE); podrazumevano 0,45", + "duration_weight": "Faza 4: koliko jako slaganje trajanja utiče na konačni rezultat (podrazumevano 0,22)", + "energy_weight": "Faza 4: koliko jako slaganje energije utiče na konačni rezultat (podrazumevano 0,22)", + "profile_match_min_duration_ratio": "Faza 1: minimalna dužina ciklusa kao udeo trajanja profila; podrazumevano 0,1 (10%)", + "profile_match_max_duration_ratio": "Faza 1: maksimalna dužina ciklusa kao udeo trajanja profila; podrazumevano 1,5 (150%)", + "keep_min_score": "Faza 2: minimalni rezultat za ostanak u utrci; podrazumevano 0,1 prihvata slaba podudaranja, kasniji koraci biraju najbolje", + "dtw_blend": "Faza 3: 0 = samo osnovni rezultat, 1 = samo DTW rezultat, 0,5 = jednako mešanje (podrazumevano)", + "dtw_ensemble_w": "Faza 3 (kombinovani režim): težina skaliranog L1 naspram derivatnog DTW; podrazumevano 0,7 favorizuje nivo", + "dtw_ddtw_scale": "Faza 3: rastojanje polu-zasićenja DDTW; manje = osetljivije na oblik (podrazumevano 30)", + "dtw_refine_top_n": "Faza 3: kandidati koje DTW ponovo ocenjuje; povećajte na 7-9 ako ispravni profil zauzme 4.-5. mesto (podrazumevano 5)", + "duration_scale": "Faza 4: odnos logaritma gde se slaganje trajanja prepolovi; manje = stroža kazna (podrazumevano 0,175)", + "energy_scale": "Faza 4: odnos logaritma gde se slaganje energije prepolovi; manje = stroža kazna (podrazumevano 0,25)" }, "col": { "profile_tip": "Naziv podudarnog programa. Bez oznake znači da se na kraju ciklusa nijedan profil nije podudarao.", @@ -1766,6 +1820,68 @@ }, "pg_sweep": { "optimize": "Optimizacija: {param}" + }, + "pg_detail": { + "simulate": "Simuliraj ciklus" + }, + "split": { + "apply": "Deljenje ciklusa" + }, + "trim": { + "apply": "Obrezivanje ciklusa" + }, + "merge": { + "apply": "Spajanje ciklusa" + }, + "rebuild": { + "envelopes": "Ponovna izgradnja omotača" + } + }, + "setup": { + "hdr": { + "card": "Podešavanje uređaja", + "healthy_chip": "Podešavanje dovršeno" + }, + "phase0": { + "washer": "WashData već otkriva vaše cikluse. Snimite prvi ciklus kako biste omogućili nazive programa i procene vremena.", + "dishwasher": "WashData posmatra. Mašine za pranje sudova imaju složene cikluse – snimanje prvog ciklusa je izrazito preporučeno. Ako otkriveni ciklus traje predugo, koristite uređivač ciklusa za isecanje pre čuvanja kao profila.", + "generic": "WashData posmatra. Snimite ili označite otkriveni ciklus kako biste počeli da gradite profile." + }, + "phase1a": { + "labelled": "Dobar početak – vaš prvi program je sačuvan. Za najčistije podatke razmislite o snimanju sledećeg ciklusa pomoću vidžeta za snimanje." + }, + "phase1b": { + "recorded": "Vaša snimka sačuvana je kao {profile_name}. Sada snimite ili označite ostale uobičajene programe za izgradnju pokrivenosti." + }, + "phase1c": { + "verify": "Imate {count} programa od zajednice. Pokrenite ciklus kako biste proverili da li ih WashData ispravno prepoznaje – poklapanje će se poboljšavati kako vaš uređaj gradi sopstvenu istoriju." + }, + "phase2": { + "cluster": "WashData je video {count} ciklusa koji se ne poklapaju ni sa jednim sačuvanim programom – međusobno su slični. Da li želite da napravite novi profil za njih?", + "unmatched": "Vaš poslednji ciklus se nije poklopio ni sa jednim sačuvanim programom. Da li je to novi program?" + }, + "phase3": { + "suggestions": "WashData ima preporuke podešavanja zasnovane na istoriji vaših ciklusa – pregledajte ih da biste poboljšali tačnost otkrivanja.", + "groups": "Neki vaši profili izgledaju kao isti program na različitim temperaturama. Organizujte ih u grupu za bolje poklapanje.", + "phases": "Dodajte faze programa u {profile_name} za tačnije procene preostalog vremena." + }, + "phase4": { + "healthy": "Ovaj uređaj je u potpunosti podešen ({profile_count} profila)." + }, + "cta": { + "start_recording": "Pokreni snimanje", + "label_detected_cycle": "Već imam otkriven ciklus – umesto toga ga označi", + "browse_cycles": "Pregledaj cikluse za označavanje sledećeg", + "view_profiles": "Pogledaj svoje profile", + "create_from_cluster": "Napravi profil od ovih ciklusa", + "create_profile": "Napravi za njega profil", + "review_suggestions": "Preglej predloge", + "organise_profiles": "Organizuj profile", + "configure_phases": "Konfiguriši faze", + "skip_step": "Preskoči ovaj korak", + "skip_forever": "Ne prikazuj ponovo", + "hide_guidance": "Sakrij uputstvo", + "show_guidance": "Prikaži uputstvo" } } } diff --git a/custom_components/ha_washdata/translations/panel/sv.json b/custom_components/ha_washdata/translations/panel/sv.json index 11ef699..2626d1a 100644 --- a/custom_components/ha_washdata/translations/panel/sv.json +++ b/custom_components/ha_washdata/translations/panel/sv.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "{n} anomalier upptäckta (t.ex. dörren öppnades mitt i cykeln) — öppna för att se dem i diagrammet", + "artifact_tip": "{n} anomalier upptäckta (t.ex. dörren öppnades mitt i cykeln) – öppna för att se dem i diagrammet", "auto": "(automatiskt identifierat)", "built_in_tag": "inbyggd", "declining": "↘ sjunkande", "energy_low": "Lägre energi än vanligt", "energy_spike": "Högre energi än vanligt", "fair_fit": "acceptabel kvalitet", - "fair_fit_tip": "Måttlig matchkonsistens — vissa cykler tilldelade den här profilen har lägre konfidenspoäng. Märk fler cykler eller spela in profilen igen för att förbättra noggrannheten.", + "fair_fit_tip": "Måttlig matchkonsistens – vissa cykler tilldelade den här profilen har lägre konfidenspoäng. Märk fler cykler eller spela in profilen igen för att förbättra noggrannheten.", "feedback_requested": "Feedback begärd", "golden_cycle": "Inspelad referenscykel", "improving": "↗ förbättras", @@ -15,7 +15,7 @@ "needs_review": "Behöver granskning", "overrun": "Pågick längre än vanligt", "poor_fit": "dålig kvalitet", - "poor_fit_tip": "Inkonsekvent matchhistorik — överväg att bygga om den här profilen", + "poor_fit_tip": "Inkonsekvent matchhistorik – överväg att bygga om den här profilen", "restart_gap_tip": "{n} HA-omstartslucka: effektspåret har ett hål", "reviewed": "Granskad", "steady": "→ stabil", @@ -87,7 +87,7 @@ "on_cycle_finished": "När cykeln är klar", "on_cycle_started": "När cykeln startar", "pause_cycle": "Pausa", - "pause_cycle_tip": "Pausa den pågående cykeln — apparaten återupptar där den slutade", + "pause_cycle_tip": "Pausa den pågående cykeln – apparaten återupptar där den slutade", "pg_apply_device": "Tillämpa på enhet", "pg_autofill": "Autofyll", "play": "Spela", @@ -97,8 +97,8 @@ "process_tip": "Spara det inspelade spåret som en ny eller befintlig profil", "rebuild": "Bygg om kuvert", "rebuild_envelope": "Bygg om kuvert", - "rebuild_tip": "Beräkna om det förväntade effektkuvertet (min/max-band) för alla profiler från deras märkta cykler — kör efter märkning av nya cykler eller korrigering av gamla", - "rec_start_tip": "Börja spela in apparatens effektspår — starta strax innan du kör en cykel", + "rebuild_tip": "Beräkna om det förväntade effektkuvertet (min/max-band) för alla profiler från deras märkta cykler – kör efter märkning av nya cykler eller korrigering av gamla", + "rec_start_tip": "Börja spela in apparatens effektspår – starta strax innan du kör en cykel", "rec_stop": "Stoppa", "rec_stop_tip": "Stoppa inspelningen och behåll det registrerade spåret för granskning", "record": "Starta inspelning", @@ -231,7 +231,7 @@ "interval": "Bör vara minst 2x samplingsintervallet ({si} s)", "sampling": "Samplingsintervall bör vara högst hälften av vakthundsintervallet ({wi} s)" }, - "settings_banner": "{n} inställningskonflikt{s} — kontrollera de markerade avsnitten och åtgärda dem innan du sparar.", + "settings_banner": "{n} inställningskonflikt{s} – kontrollera de markerade avsnitten och åtgärda dem innan du sparar.", "settings_banner_btn": "Gå till första" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "Visa förväntat kurvöverlägg (matchad profil, orange)", "show_raw": "Visa växlare för råa uttag-värden i live-effektdiagram", "sparkline": "Trend för senaste cykellängder", - "stage2": "Steg 2 — kärnlikhet", - "stage3": "Steg 3 — DTW", - "stage4": "Steg 4 — överensstämmelse", + "stage2": "Steg 2 – kärnlikhet", + "stage3": "Steg 3 – DTW", + "stage4": "Steg 4 – överensstämmelse", "status": "Status", "tail_trim": "Sluttrimning (s)", "timer_auto_pause": "Automatisk paus", @@ -561,7 +561,12 @@ "flags": "Flaggor", "include_phase_map": "Inkludera fasekarta", "include_settings": "Inkludera identifierings- och matchningsinställningar", - "show_contributor": "Visa bidragsgivarnamn" + "show_contributor": "Visa bidragsgivarnamn", + "task_pg_detail": "Simulera cykel", + "task_split": "Delar upp cykel", + "task_trim": "Beskär cykel", + "task_merge": "Slår ihop cykler", + "task_rebuild": "Bygger om enveloppar" }, "log": { "all_levels": "Alla nivåer", @@ -645,19 +650,19 @@ "artifact_dip_detail": "Föll under det normala effektbandet i ~{n} s.", "artifact_footer": "Markerad i grafen ovan. Dessa är övergående artefakter (t.ex. dörren öppnas mitt i cykeln), inte nödvändigtvis problem.", "artifact_header": "{n} anomalier upptäcktes under denna cykel", - "artifact_pause_detail": "Effekten föll till nära noll i ~{n} s och återupptogs sedan — troligtvis öppnades dörren mitt i cykeln eller cykeln pausades.", + "artifact_pause_detail": "Effekten föll till nära noll i ~{n} s och återupptogs sedan – troligtvis öppnades dörren mitt i cykeln eller cykeln pausades.", "artifact_spike_detail": "Drog över det normala effektbandet i ~{n} s.", "auto_label_intro": "Tilldela profiler till omärkta cykler vars matchningskonfidens överstiger tröskeln.", - "automations_intro": "WashData utlöser {start} / {end}-händelser och tillhandahåller entiteter — aviseringar och åtgärder byggs bäst som vanliga Home Assistant-automatiseringar. Automatiseringar som använder den här enheten visas nedan.", + "automations_intro": "WashData utlöser {start} / {end}-händelser och tillhandahåller entiteter – aviseringar och åtgärder byggs bäst som vanliga Home Assistant-automatiseringar. Automatiseringar som använder den här enheten visas nedan.", "cleanup_intro": "Alla märkta cykler är överlagda. Kryssa i avvikare och ta bort dem för att rensa profilen.", "clear_debug_hint": "Ta bort lagrad felsökningsdata för att frigöra utrymme.", "collecting_data": "Samlar in data: {need} cykel{plural} kvar innan finjustering kan starta ({current}/{min}).", "compare_overlay_profiles": "Lägg över profiler (svag)", "compare_profiles_tip": "Lägg över andra profilkuvert på diagrammet ovan för att se vilket som passar bäst för denna cykel.", - "compare_selected_cycles": "Valda cykler (solid) — visa / dölj", + "compare_selected_cycles": "Valda cykler (solid) – visa / dölj", "consider_new_profile": "Överväg att skapa en ny profil.", "coverage_gap": "de senaste cyklerna har ingen matchande profil ({pct}% av de senaste 30).", - "coverage_gap_similar_cycles": "{count} liknande omärkta cykler hittades — skapa en profil för att börja matcha dem.", + "coverage_gap_similar_cycles": "{count} liknande omärkta cykler hittades – skapa en profil för att börja matcha dem.", "cycles_deleted": "{count} cykler borttagna", "enough_data": "Tillräckligt med data för att lära sig av ({current}/{min} cykler).", "export_description": "Exportera alla profiler och cykler till JSON, eller återställ från en tidigare export.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Modeller finjusterade till denna maskin.", "ml_loading": "laddar ML…", "ml_settings_intro": "Två oberoende switchar: en tillämpar modellerna medan en cykel körs, den andra låter WashData finjustera dem till din maskin över tid.", - "name_first_program": "Du har tillräckligt med cykler — namnge ditt första program för att börja matcha.", + "name_first_program": "Du har tillräckligt med cykler – namnge ditt första program för att börja matcha.", "near_duplicate_cluster": "nästan duplicerat profilkluster upptäcktes. Gruppering gör att matchning på ett tillförlitligt sätt kan välja mellan look-alikes (t.ex. samma program vid olika temperaturer/centrifugering).", "no_cycles_match": "Inga cykler matchar det aktuella filtret.", "no_cycles_profile": "Inga cykler för denna profil.", @@ -696,7 +701,7 @@ "no_devices": "Inga WashData-enheter konfigurerade än.", "no_envelope": "Inget kuvert ännu - bygg om efter märkning av cykler.", "no_envelope_overlay": "Inget kuvert tillgängligt att lägga över.", - "no_fine_tuned": "Inget finjusterat ännu — WashData använder sina inbyggda modeller.", + "no_fine_tuned": "Inget finjusterat ännu – WashData använder sina inbyggda modeller.", "no_logs": "Inga loggposter buffrade än.", "no_maintenance": "Inget underhåll registrerat ännu.", "no_match_yet": "Inget matchningsförsök ännu - detta fylls i under en aktiv cykel.", @@ -713,7 +718,7 @@ "notify_services_hint": "Använd {entity}-tjänst-ID:n (kommaavgränsade för flera). Mallvariabler: {vars}.", "old_actions_warning": "Konfigurerad med den gamla åtgärdsredigeraren (nu borttagen). De utlöses fortfarande vid cykelhändelser men kan inte längre redigeras här. Konvertera dem till en normal automatisering, eller ta bort dem.", "onboarding_progress": "{n} / 3 cykler observerade", - "onboarding_watching": "Använd apparaten som vanligt — WashData observerar. Efter 3 cykler börjar programmatchningen.", + "onboarding_watching": "Använd apparaten som vanligt – WashData observerar. Efter 3 cykler börjar programmatchningen.", "pending_feedback": "Väntande detektionsfeedback", "performance_trend": "Prestandatrend ({n} cykler)", "pg_analysis_empty": "Ladda en cykel för att se matchningsanalys.", @@ -763,7 +768,7 @@ "setting_changed": "Ändrad från {old} till {new} den {date}", "settings_basic_note": "Visar väsentliga inställningar. Byt till Avancerat för hela listan.", "shape_drift_advisory": "⚠ Form avviker", - "shape_drift_detail": "Effektmönstret för den här profilen har förändrats över tid — möjligt apparatslitage eller underhåll behövs (t.ex. avkalkning, filterrengöring).", + "shape_drift_detail": "Effektmönstret för den här profilen har förändrats över tid – möjligt apparatslitage eller underhåll behövs (t.ex. avkalkning, filterrengöring).", "show_all_settings": "Visa alla inställningar", "showing_suggestions": "Visar {count} inställning med förslag.", "split_intro": "Klicka på diagrammet för att lägga till eller ta bort en delningspunkt, eller autoidentifiera via tomma luckor. Varje resulterande segment kan få sin egen profil.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Återställning misslyckades: {error}", "toast_reverted": "{key} återställd", "toast_save_failed": "Sparning misslyckades: {error}", - "trend_duration_longer": "Varaktighet trendar längre ({pct}%/cykel) — senaste medelvärde {avg}", - "trend_duration_shorter": "Varaktighet trendar kortare ({pct}%/cykel) — senaste medelvärde {avg}", + "trend_duration_longer": "Varaktighet trendar längre ({pct}%/cykel) – senaste medelvärde {avg}", + "trend_duration_shorter": "Varaktighet trendar kortare ({pct}%/cykel) – senaste medelvärde {avg}", "trend_energy_down": "Energin går ned ({pct}%/cykel)", - "trend_energy_up": "Energitrend uppåt ({pct}%/cykel) — senaste snitt {avg}", + "trend_energy_up": "Energitrend uppåt ({pct}%/cykel) – senaste snitt {avg}", "trim_intro": "Dra de röda handtagen eller ange värden. Allt utanför fönstret tas bort.", "tuning_suggestions_available": "{count} inställningsförslag tillgängligt från observerade cykler. De visas bredvid de relevanta fälten.", "unsure_detected_prefix": "WashData är osäker på att den identifierade", @@ -857,7 +862,9 @@ "share_guidelines_title": "Innan du delar", "store_download_device_intro": "Ta över alla delade program och deras referenscykler till din apparat. Dina egna inspelade cykler och statistik påverkas inte.", "store_share_device_intro": "Ladda upp {brand} {model} med de referenscykler du väljer. Andra med samma apparat kan ta över dina program. Poster granskas innan de visas offentligt.", - "share_profile_no_cycles": "Inga referenscykler -- markera en cykel som ⭐ på fliken Cykler för att inkludera den här profilen" + "share_profile_no_cycles": "Inga referenscykler -- markera en cykel som ⭐ på fliken Cykler för att inkludera den här profilen", + "advisory_phase_inconsistent": "'{name}' verkar blanda olika program eller temperaturer - dess cykler värms upp under mycket olika lång tid. Om du delar upp den i separata profiler (t.ex. per temperatur) förbättras matchningen och tidsuppskattningarna.", + "advisory_phase_inconsistent_title": "⚠ Möjligen blandade program" }, "pg_desc": { "abrupt_drop_watts": "Plötsligt fall som tolkas som omedelbart slut", @@ -869,12 +876,19 @@ "start_duration_threshold": "Sekunder över tröskeln för att bekräfta start", "start_threshold_w": "Minsta antal watt för att räknas som startad", "stop_threshold_w": "Under detta räknas maskinen som avstängd", - "dtw_bandwidth": "Hur mycket tidsförvrängning formmatchningen tillåter", - "corr_weight": "Vikt på kurvform kontra effektnivå i matchningen", - "duration_weight": "Hur mycket överensstämmelse i körtid påverkar matchningen", - "energy_weight": "Hur mycket överensstämmelse i energiförbrukning påverkar matchningen", - "profile_match_min_duration_ratio": "Kortaste körning (jämfört med profilen) som fortfarande får matcha", - "profile_match_max_duration_ratio": "Längsta körning (jämfört med profilen) som fortfarande får matcha" + "dtw_bandwidth": "Steg 3: Sakoe-Chiba-förvrängningsband (0 = DTW av; standard 0,2 = 20% av cykellängd)", + "corr_weight": "Steg 2: balans mellan kurvform (korrelation) och effektnivå (MAE); standard 0,45", + "duration_weight": "Steg 4: hur starkt körtidsöverensstämmelse påverkar slutpoängen (standard 0,22)", + "energy_weight": "Steg 4: hur starkt energiöverensstämmelse påverkar slutpoängen (standard 0,22)", + "profile_match_min_duration_ratio": "Steg 1: lägsta cykellängd som andel av profilduration; standard 0,1 (10%)", + "profile_match_max_duration_ratio": "Steg 1: högsta cykellängd som andel av profilduration; standard 1,5 (150%)", + "keep_min_score": "Steg 2: lägsta poäng för att vara kvar i matchen; standard 0,1 tillåter svaga kandidater, senare steg väljer bäst", + "dtw_blend": "Steg 3: 0 = bara kärnpoäng, 1 = bara DTW-poäng, 0,5 = lika blandning (standard)", + "dtw_ensemble_w": "Steg 3 (ensemble-läge): vikt på skalad L1 kontra derivativ DTW; standard 0,7 gynnar nivåmedvetenhet", + "dtw_ddtw_scale": "Steg 3: DDTW-halvmättnadsavstånd; lägre = mer formkänslig (standard 30)", + "dtw_refine_top_n": "Steg 3: kandidater som DTW ompoängsätter; öka till 7-9 om rätt profil placerar sig 4-5:a (standard 5)", + "duration_scale": "Steg 4: log-kvot där varaktighetsöverensstämmelse halveras; lägre = striktare straff (standard 0,175)", + "energy_scale": "Steg 4: log-kvot där energiöverensstämmelse halveras; lägre = striktare straff (standard 0,25)" }, "phase_desc": { "anti_crease": "Enstaka korta tumlar efter färdigställandet för att förhindra skrynklor i tvätten.", @@ -957,6 +971,10 @@ "triggers": { "intro": "Valfria externa signaler: en slututlösare, en dörrsensor, en pausknapp och en lossningspåminnelse.", "label": "Utlösare och dörrar" + }, + "phase_eta": { + "label": "Återstående tid", + "intro": "Fasmedveten återstående tid, för maskiner vars cykellängd beror på temperatur eller centrifugering." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Fast pris per kWh som används för kostnadssiffror när ingen levande prisenhet anges ovan.", "label": "Statiskt energipris (per kWh)" }, + "energy_sensor": { + "doc": "Valfri kumulativ energiräknare (total_increasing i kWh/Wh, t.ex. uttagets egen livstidsmätare). När den anges hämtas varje cykels rapporterade energi från skillnaden på denna räknare mellan start och slut, vilket undviker den underräkning du får när en långsamt rapporterande effektsensor integreras. Faller tillbaka på det integrerade värdet om avläsningen saknas, dess enhet är okänd eller skillnaden inte är positiv. Lämna tomt för att fortsätta integrera effektsensorn.", + "label": "Energimätarentitet" + }, "expose_debug_entities": { "doc": "Publicera extra diagnostiska HA-enheter (matchningsförtroende, tvetydighet, tillståndsinterna). Av håller enhetslistan ren för normal användning.", "label": "Exponera felsökningsentiteter" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Visar tillskrivningen 'av ' på gemenskapsapparater och referenscykler." + }, + "enable_phase_matching": { + "label": "Fasmedveten återstående tid", + "doc": "Dela upp varje pågående cykel i faser (uppvärmning, tvätt, centrifugering) och budgetera den återstående tiden per fas, blandat med den klassiska uppskattningen - tidigt i cykeln lutar den sig mot fasbudgeten och mot slutet mot den klassiska uppskattningen. Detta anpassar nedräkningen efter hur länge din maskin faktiskt värms upp och körs, vilket märks tydligast under den första halvan av en cykel. Av = endast den klassiska uppskattningen. Endast visningen av återstående tid påverkas; programmatchning och cykeldetektering är oförändrade." + }, + "keep_min_score": { + "label": "Min matchningspoäng", + "doc": "Lägsta likhetspoäng som en kandidat behöver för att vara kvar i matchningen. Standard 0,1 är avsiktligt tillåtande – det tillåter även svaga kandidater och förlitar sig på senare steg för att hitta bästa match. Höj för att gallra bort osannolika profiler tidigare; sänk för att utöka den initiala kandidatpoolen." + }, + "dtw_blend": { + "label": "DTW-blandning", + "doc": "Hur mycket DTW-justeringspoängen ersätter kärnpoängen från Steg 2. 0 = använd bara Steg 2-poängen, 1 = använd bara DTW-poängen, 0,5 (standard) = lika blandning. Höj om program har liknande effektnivåer men olika timingmönster." + }, + "dtw_ensemble_w": { + "label": "DTW-ensemblemix", + "doc": "I ensemble-DTW-läge (standard) anger detta vikten på skalad L1 kontra derivativ DTW (DDTW). 1,0 = enbart skalad L1, 0 = enbart DDTW, 0,7 = standard. Den skalade varianten jämför effektnivåer; den derivativa reagerar på hur effekten förändras över tid. Ensemble blandar båda." + }, + "dtw_ddtw_scale": { + "label": "Derivativ DTW-skala", + "doc": "Halvmättnadsavstånd för derivativ DTW-poängsättning. Poängen halveras när DDTW-avståndet är lika med detta värde. Lägre = mer känslig för formskillnader. Standard 30 är kalibrerad för typiska apparaters effektprofiler. Påverkar bara ensemble- och ddtw-DTW-lägen." + }, + "dtw_refine_top_n": { + "label": "DTW-förfinandetal", + "doc": "Hur många toppkandidater från Steg 2 som ompoängsätts av DTW. DTW är mer beräkningskrävande så det tillämpas bara på de N bästa. Standard 5. Höj (till 7-9) om rätt profil ibland bara når 4:e eller 5:e plats efter Steg 2 – DTW kan rädda den. Lägre värde snabbar upp matchningen något." + }, + "duration_scale": { + "label": "Varaktighetsskärpa", + "doc": "Skärpan i Steg 4:s straff för varaktighetsöverensstämmelse. Det är log-kvoten vid vilken överensstämmelsepoängen halveras. Lägre = striktare: en varaktighetsavvikelse straffas mer. Standard 0,175 motsvarar ungefär 18% varaktighetstolerans vid halvvikt. Para ihop med Varaktighetssvikt." + }, + "energy_scale": { + "label": "Energiskärpa", + "doc": "Skärpan i Steg 4:s straff för energiöverensstämmelse. Lägre = striktare: en energiavvikelse straffas mer. Standard 0,25 är avsiktligt mer tillåtande än varaktighetsskärpan eftersom energiförbrukning varierar med last. Para ihop med Energivikt." } }, "setting_group": { @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimera: {param}" + }, + "pg_detail": { + "simulate": "Simulera cykel" + }, + "split": { + "apply": "Delar upp cykel" + }, + "trim": { + "apply": "Beskär cykel" + }, + "merge": { + "apply": "Slår ihop cykler" + }, + "rebuild": { + "envelopes": "Bygger om enveloppar" + }, + "cancelling": "Avbryter..." + }, + "setup": { + "hdr": { + "card": "Enhetsinställning", + "healthy_chip": "Konfiguration klar" + }, + "phase0": { + "washer": "WashData identifierar redan dina cykler. Spela in din första cykel för att aktivera programnamn och tidsuppskattningar.", + "dishwasher": "WashData observerar. Diskmaskiner har komplexa cykler – att spela in din första cykel rekommenderas starkt. Om en identifierad cykel körs för länge, använd cykeleditorn för att trimma den innan du sparar den som en profil.", + "generic": "WashData observerar. Spela in eller märk en identifierad cykel för att börja bygga profiler." + }, + "phase1a": { + "labelled": "Bra start – ditt första program är sparat. För renast möjliga data kan du spela in din nästa cykel med inspelningswidgeten." + }, + "phase1b": { + "recorded": "Din inspelning sparades som {profile_name}. Spela nu in eller märk dina andra vanliga program för att bygga upp täckning." + }, + "phase1c": { + "verify": "Du har {count} program från gemenskapen. Kör en cykel för att verifiera att WashData identifierar den rätt – matchningen förbättras ju mer historik din enhet bygger upp." + }, + "phase2": { + "cluster": "WashData har sett {count} cykler som inte matchar något sparat program – de liknar varandra. Vill du skapa en ny profil för dem?", + "unmatched": "Din senaste cykel matchade inget sparat program. Är det ett nytt program?" + }, + "phase3": { + "suggestions": "WashData har inställningsrekommendationer baserade på din cykelhistorik – granska dem för att förbättra detekteringens noggrannhet.", + "groups": "Några av dina profiler ser ut som samma program vid olika temperaturer. Organisera dem i en grupp för bättre matchning.", + "phases": "Lägg till programfaser i {profile_name} för mer exakta uppskattningar av återstående tid." + }, + "phase4": { + "healthy": "Den här enheten är helt konfigurerad ({profile_count} profiler)." + }, + "cta": { + "start_recording": "Starta inspelning", + "label_detected_cycle": "Jag har redan en identifierad cykel – märk den istället", + "browse_cycles": "Bläddra bland cykler för att märka en till", + "view_profiles": "Visa dina profiler", + "create_from_cluster": "Skapa profil från dessa cykler", + "create_profile": "Skapa en profil för det", + "review_suggestions": "Granska förslag", + "organise_profiles": "Organisera profiler", + "configure_phases": "Konfigurera faser", + "skip_step": "Hoppa över det här steget", + "skip_forever": "Visa inte igen", + "hide_guidance": "Dölj vägledning", + "show_guidance": "Visa vägledning" } } } diff --git a/custom_components/ha_washdata/translations/panel/tr.json b/custom_components/ha_washdata/translations/panel/tr.json index c4c9d64..a4afae8 100644 --- a/custom_components/ha_washdata/translations/panel/tr.json +++ b/custom_components/ha_washdata/translations/panel/tr.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "{n} anormallik algılandı (ör. döngünün ortasında kapının açılması) — bunları grafikte görmek için açın", + "artifact_tip": "{n} anormallik algılandı (ör. döngünün ortasında kapının açılması) – bunları grafikte görmek için açın", "auto": "(otomatik algılandı)", "built_in_tag": "yerleşik", "declining": "↘ düşüyor", "energy_low": "Normalden düşük enerji", "energy_spike": "Normalden yüksek enerji", "fair_fit": "kabul edilebilir kalite", - "fair_fit_tip": "Orta düzeyde eşleşme tutarlılığı — bu profile atanan bazı döngülerin güven puanları daha düşüktür. Doğruluğu artırmak için daha fazla döngü etiketleyin veya profili yeniden kaydedin.", + "fair_fit_tip": "Orta düzeyde eşleşme tutarlılığı – bu profile atanan bazı döngülerin güven puanları daha düşüktür. Doğruluğu artırmak için daha fazla döngü etiketleyin veya profili yeniden kaydedin.", "feedback_requested": "Geri bildirim istendi", "golden_cycle": "Kaydedilen referans döngüsü", "improving": "↗ iyileşiyor", @@ -15,7 +15,7 @@ "needs_review": "İncelenmesi gerekiyor", "overrun": "Normalden daha uzun sürdü", "poor_fit": "düşük kalite", - "poor_fit_tip": "Tutarsız eşleşme geçmişi — bu profili yeniden oluşturmayı düşünün", + "poor_fit_tip": "Tutarsız eşleşme geçmişi – bu profili yeniden oluşturmayı düşünün", "restart_gap_tip": "{n} HA yeniden başlatma boşluğu: güç izinde bir delik var", "reviewed": "İncelendi", "steady": "→ sabit", @@ -87,7 +87,7 @@ "on_cycle_finished": "Döngü bittiğinde", "on_cycle_started": "Döngü başladığında", "pause_cycle": "Duraklat", - "pause_cycle_tip": "Çalışan döngüyü duraklatın — cihaz kaldığı yerden devam edecek", + "pause_cycle_tip": "Çalışan döngüyü duraklatın – cihaz kaldığı yerden devam edecek", "pg_apply_device": "Cihaza uygula", "pg_autofill": "Otomatik doldur", "play": "Oynat", @@ -97,8 +97,8 @@ "process_tip": "Kaydedilen izi yeni veya mevcut bir profil olarak kaydedin", "rebuild": "Zarfları Yeniden Oluştur", "rebuild_envelope": "Zarfı Yeniden Oluştur", - "rebuild_tip": "Tüm profiller için etiketlenmiş döngülerinden beklenen güç zarfını (min/maks bandı) yeniden hesaplayın — yeni döngüler etiketledikten veya eskilerini düzelttikten sonra çalıştırın", - "rec_start_tip": "Cihazın güç izini kaydetmeye başlayın — döngü çalıştırmadan hemen önce başlatın", + "rebuild_tip": "Tüm profiller için etiketlenmiş döngülerinden beklenen güç zarfını (min/maks bandı) yeniden hesaplayın – yeni döngüler etiketledikten veya eskilerini düzelttikten sonra çalıştırın", + "rec_start_tip": "Cihazın güç izini kaydetmeye başlayın – döngü çalıştırmadan hemen önce başlatın", "rec_stop": "Durdur", "rec_stop_tip": "Kaydı durdurun ve yakalanan izi inceleme için saklayın", "record": "Kayda Başla", @@ -182,7 +182,7 @@ }, "attn_sub": "Kaydetmeden önce çakışmaları düzeltin", "attn_title": "{n} ayar çakışması{s}", - "settings_banner": "{n} ayar çakışması{s} — vurgulanan bölümleri kontrol edin ve kaydetmeden önce düzeltin.", + "settings_banner": "{n} ayar çakışması{s} – vurgulanan bölümleri kontrol edin ve kaydetmeden önce düzeltin.", "settings_banner_btn": "İlkine git", "confidence": { "auto": "Eşleşme Eşiğinde veya üzerinde olmalıdır ({match})", @@ -451,9 +451,9 @@ "show_expected": "Beklenen eğri bindirmesini göster (eşleşen profil, turuncu)", "show_raw": "Canlı güç grafiğinde ham soket değiştiriciyi göster", "sparkline": "Son döngü süresi eğilimi", - "stage2": "Aşama 2 — çekirdek benzerlik", - "stage3": "Aşama 3 — DTW", - "stage4": "Aşama 4 — uyum", + "stage2": "Aşama 2 – çekirdek benzerlik", + "stage3": "Aşama 3 – DTW", + "stage4": "Aşama 4 – uyum", "status": "Durum", "tail_trim": "Kuyruk Kırpma (s)", "timer_auto_pause": "Otomatik duraklatma", @@ -561,7 +561,12 @@ "flags": "İşaretler", "include_phase_map": "Faz haritasını dahil et", "include_settings": "Algılama ve eşleştirme ayarlarını dahil et", - "show_contributor": "Katkıda bulunanların adlarını göster" + "show_contributor": "Katkıda bulunanların adlarını göster", + "task_pg_detail": "Döngü simülasyonu", + "task_split": "Döngü bölünüyor", + "task_trim": "Döngü kırpılıyor", + "task_merge": "Döngüler birleştiriliyor", + "task_rebuild": "Zarflar yeniden oluşturuluyor" }, "log": { "all_levels": "Tüm seviyeler", @@ -645,7 +650,7 @@ "artifact_dip_detail": "Normal güç bandının altında yaklaşık ~{n} saniye sürdü.", "artifact_footer": "Yukarıdaki grafikte vurgulanmıştır. Bunlar geçici eserlerdir (örn. döngünün ortasında kapının açılması), mutlaka sorun teşkil etmez.", "artifact_header": "Bu döngü sırasında {n} anormallik algılandı", - "artifact_pause_detail": "Güç yaklaşık ~{n} saniye boyunca sıfıra yakın düştü, sonra devam etti — döngünün ortasında kapı açılmış veya döngü duraklatılmış olabilir.", + "artifact_pause_detail": "Güç yaklaşık ~{n} saniye boyunca sıfıra yakın düştü, sonra devam etti – döngünün ortasında kapı açılmış veya döngü duraklatılmış olabilir.", "artifact_spike_detail": "Normal güç bandının üzerinde yaklaşık ~{n} saniye sürdü.", "auto_label_intro": "Eşleşme güveni eşiği geçen etiketsiz döngülere profil atayın.", "automations_intro": "WashData, {start} / {end} olaylarını tetikler ve varlıkları gösterir; bu nedenle bildirimler ve eylemler en iyi şekilde normal Home Assistant otomasyonları olarak oluşturulur. Bu cihazı kullanan otomasyonlar aşağıda görünür.", @@ -654,10 +659,10 @@ "collecting_data": "Veri toplanıyor: ince ayar başlamadan önce {need} döngü{plural} daha gerekiyor ({current}/{min}).", "compare_overlay_profiles": "Profilleri üst üste bindirin (soluk)", "compare_profiles_tip": "Hangisinin bu döngüye en uygun olduğunu görmek için diğer profil zarflarını yukarıdaki tabloya yerleştirin.", - "compare_selected_cycles": "Seçili döngüler (katı) — göster / gizle", + "compare_selected_cycles": "Seçili döngüler (katı) – göster / gizle", "consider_new_profile": "Yeni bir profil oluşturmayı düşünün.", "coverage_gap": "son döngülerin eşleşen profili yok (son 30 döngünün %{pct}'i).", - "coverage_gap_similar_cycles": "{count} benzer etiketsiz döngü bulundu — bunları eşleştirmeye başlamak için bir profil oluşturun.", + "coverage_gap_similar_cycles": "{count} benzer etiketsiz döngü bulundu – bunları eşleştirmeye başlamak için bir profil oluşturun.", "cycles_deleted": "{count} döngü silindi", "enough_data": "Öğrenmek için yeterli veri ({current}/{min} döngü).", "export_description": "Tüm profilleri ve döngüleri JSON'a aktarın veya önceki bir dışa aktarma işleminden geri yükleyin.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Bu makineye ince ayarlı modeller.", "ml_loading": "ML yükleniyor…", "ml_settings_intro": "İki bağımsız anahtar: biri döngü çalışırken modelleri uygular, diğeri WashData'nın zamanla makinenize ince ayar yapmasına izin verir.", - "name_first_program": "Yeterli döngünüz var — eşleştirmeye başlamak için ilk programınıza ad verin.", + "name_first_program": "Yeterli döngünüz var – eşleştirmeye başlamak için ilk programınıza ad verin.", "near_duplicate_cluster": "neredeyse yinelenen profil kümesi algılandı. Gruplandırma, eşleştirmenin benzerler arasında güvenilir bir şekilde seçim yapmasına olanak tanır (örneğin, farklı sıcaklıkta/döndürmede aynı program).", "no_cycles_match": "Mevcut filtreyle eşleşen döngü yok.", "no_cycles_profile": "Bu profil için döngü yok.", @@ -696,7 +701,7 @@ "no_devices": "Henüz WashData cihazı yapılandırılmamış.", "no_envelope": "Henüz zarf yok - döngüleri etiketledikten sonra yeniden oluşturun.", "no_envelope_overlay": "Üst üste bindirmek için zarf mevcut değil.", - "no_fine_tuned": "Henüz ince ayar yapılmadı — WashData yerleşik modellerini kullanıyor.", + "no_fine_tuned": "Henüz ince ayar yapılmadı – WashData yerleşik modellerini kullanıyor.", "no_logs": "Henüz günlük kaydı arabelleğe alınmadı.", "no_maintenance": "Henüz bakım kaydı yok.", "no_match_yet": "Henüz eşleşme denemesi yok - bu, çalışan bir döngü sırasında doldurulur.", @@ -713,7 +718,7 @@ "notify_services_hint": "{entity} hizmet kimliklerini kullanın (birden fazlası için virgülle ayrın). Şablon değişkenleri: {vars}.", "old_actions_warning": "Eski eylem düzenleyiciyle yapılandırılmıştır (artık kaldırılmıştır). Döngü etkinliklerinde hala tetikleniyorlar ancak artık burada düzenlenemezler. Bunları normal bir otomasyona dönüştürün veya kaldırın.", "onboarding_progress": "{n} / 3 döngü gözlemlendi", - "onboarding_watching": "Cihazınızı normal şekilde kullanın — WashData izliyor. 3 döngüden sonra program eşleştirmesi başlar.", + "onboarding_watching": "Cihazınızı normal şekilde kullanın – WashData izliyor. 3 döngüden sonra program eşleştirmesi başlar.", "pending_feedback": "Bekleyen algılama geri bildirimi", "performance_trend": "Performans trendi ({n} döngü)", "pg_analysis_empty": "Eşleşme analizini görmek için bir döngü yükleyin.", @@ -763,7 +768,7 @@ "setting_changed": "{date} tarihinde {old} değerinden {new} değerine değiştirildi", "settings_basic_note": "Temel ayarlar gösteriliyor. Tam liste için Gelişmiş'e geçin.", "shape_drift_advisory": "⚠ Şekil kayıyor", - "shape_drift_detail": "Bu profil için güç paterni zamanla kaymıştır — olası cihaz aşınması veya bakım gerekebilir (örn. kireç çözme, filtre temizliği).", + "shape_drift_detail": "Bu profil için güç paterni zamanla kaymıştır – olası cihaz aşınması veya bakım gerekebilir (örn. kireç çözme, filtre temizliği).", "show_all_settings": "Tüm ayarları göster", "showing_suggestions": "Önerilerle birlikte {count} ayarı gösteriliyor.", "split_intro": "Bölme noktası eklemek veya kaldırmak için grafiğe tıklayın veya boşta kalma aralıklarına göre otomatik algılayın. Her elde edilen bölüm kendi profilini alabilir.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Geri alma başarısız: {error}", "toast_reverted": "{key} geri alındı", "toast_save_failed": "Kaydetme başarısız: {error}", - "trend_duration_longer": "Süre trendi daha uzun (%{pct}/döngü) — son ortalama {avg}", - "trend_duration_shorter": "Süre eğilimi daha kısa (%{pct}/döngü) — son ortalama {avg}", + "trend_duration_longer": "Süre trendi daha uzun (%{pct}/döngü) – son ortalama {avg}", + "trend_duration_shorter": "Süre eğilimi daha kısa (%{pct}/döngü) – son ortalama {avg}", "trend_energy_down": "Enerji düşüş eğiliminde (%{pct}/döngü)", - "trend_energy_up": "Enerji yükseliş eğiliminde (%{pct}/döngü) — son ortalama {avg}", + "trend_energy_up": "Enerji yükseliş eğiliminde (%{pct}/döngü) – son ortalama {avg}", "trim_intro": "Kırmızı tutamaçları sürükleyin veya değer girin. Pencerenin dışındaki her şey kaldırılır.", "tuning_suggestions_available": "Gözlemlenen döngülerden {count} ayarlama önerisi mevcut. İlgili alanların yanında görünürler.", "unsure_detected_prefix": "WashData algıladığından emin değil", @@ -857,7 +862,9 @@ "share_guidelines_title": "Paylaşmadan önce", "store_download_device_intro": "Her paylaşılan programı ve referans döngülerini cihazınıza benimseyin. Kendi kayıtlı döngüleriniz ve istatistikleriniz etkilenmez.", "store_share_device_intro": "Seçtiğiniz referans döngüleriyle {brand} {model} cihazını yükleyin. Aynı cihaza sahip diğer kullanıcılar programlarınızı benimseyebilir. Girişler kamuya açılmadan önce incelenir.", - "share_profile_no_cycles": "Referans döngüsü yok -- bu profili dahil etmek için Döngüler sekmesinde bir döngüyü ⭐ olarak işaretleyin" + "share_profile_no_cycles": "Referans döngüsü yok -- bu profili dahil etmek için Döngüler sekmesinde bir döngüyü ⭐ olarak işaretleyin", + "advisory_phase_inconsistent": "'{name}' farklı programları veya sıcaklıkları karıştırıyor gibi görünüyor - çevrimleri çok farklı sürelerde ısıtma yapıyor. Bunu ayrı profillere bölmek (örneğin her sıcaklık için) eşleştirmeyi ve süre tahminlerini iyileştirir.", + "advisory_phase_inconsistent_title": "⚠ Karışık olabilecek programlar" }, "pg_desc": { "abrupt_drop_watts": "Ani düşüş anında bitiş sayılır", @@ -869,12 +876,19 @@ "start_duration_threshold": "Başlangıcı onaylamak için eşik üstünde saniye", "start_threshold_w": "Başladı sayılması için minimum watt", "stop_threshold_w": "Bunun altında makine kapalı sayılır", - "dtw_bandwidth": "Şekil eşleştirmesinin ne kadar zaman bükmeye izin verdiği", - "corr_weight": "Eşleştirmede eğri şekline karşı güç düzeyinin ağırlığı", - "duration_weight": "Süre uyumunun eşleştirmeyi ne kadar etkilediği", - "energy_weight": "Enerji uyumunun eşleştirmeyi ne kadar etkilediği", - "profile_match_min_duration_ratio": "Eşleşmeye hâlâ izin verilen en kısa çevrim (profile göre)", - "profile_match_max_duration_ratio": "Eşleşmeye hâlâ izin verilen en uzun çevrim (profile göre)" + "dtw_bandwidth": "Aşama 3: Sakoe-Chiba büküm bandı (0 = DTW kapalı; varsayılan 0.2 = döngü uzunluğunun %20'si)", + "corr_weight": "Aşama 2: eğri şekli (korelasyon) ile güç düzeyi (MAE) arasındaki denge; varsayılan 0.45", + "duration_weight": "Aşama 4: çalışma süresi uyumunun nihai puanı ne kadar etkilediği (varsayılan 0.22)", + "energy_weight": "Aşama 4: enerji uyumunun nihai puanı ne kadar etkilediği (varsayılan 0.22)", + "profile_match_min_duration_ratio": "Aşama 1: profil süresinin bir kesri olarak minimum döngü uzunluğu; varsayılan 0.1 (%10)", + "profile_match_max_duration_ratio": "Aşama 1: profil süresinin bir kesri olarak maksimum döngü uzunluğu; varsayılan 1.5 (%150)", + "keep_min_score": "Aşama 2: yarışta kalmak için taban puan; varsayılan 0.1 zayıf eşleşmelere izin verir, sonraki aşamalar en iyisini seçer", + "dtw_blend": "Aşama 3: 0 = yalnızca çekirdek puan, 1 = yalnızca DTW puanı, 0.5 = eşit karışım (varsayılan)", + "dtw_ensemble_w": "Aşama 3 (topluluk modu): ölçeklendirilmiş-L1 ile türev DTW arasındaki ağırlık; varsayılan 0.7 düzey farkındalığını tercih eder", + "dtw_ddtw_scale": "Aşama 3: DDTW yarı doygunluk mesafesi; küçük = şekle daha duyarlı (varsayılan 30)", + "dtw_refine_top_n": "Aşama 3: DTW'nin yeniden puanladığı aday sayısı; doğru profil 4-5. sıraya düşüyorsa 7-9'a yükseltin (varsayılan 5)", + "duration_scale": "Aşama 4: süre uyumunun yarılandığı logaritmik oran; küçük = daha sert ceza (varsayılan 0.175)", + "energy_scale": "Aşama 4: enerji uyumunun yarılandığı logaritmik oran; küçük = daha sert ceza (varsayılan 0.25)" }, "phase_desc": { "anti_crease": "Kırışıklıkları azaltmak için tamamlandıktan sonra ara sıra kısa yuvarlanmalar.", @@ -957,6 +971,10 @@ "triggers": { "intro": "İsteğe bağlı harici sinyaller: bitiş tetikleyicisi, kapı sensörü, duraklatma anahtarı ve boşaltma hatırlatıcısı.", "label": "Tetikleyiciler & Kapılar" + }, + "phase_eta": { + "label": "Kalan Süre", + "intro": "Çevrim uzunluğu sıcaklığa veya sıkmaya bağlı olan makineler için aşama farkındalıklı kalan süre." } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "Yukarıda hiçbir canlı fiyat varlığı belirlenmediğinde maliyet rakamları için kullanılan kWh başına sabit fiyat.", "label": "Sabit Enerji Fiyatı (kWh başına)" }, + "energy_sensor": { + "doc": "İsteğe bağlı kümülatif enerji sayacı (total_increasing kWh/Wh, örn. prizin kendi ömür boyu sayacı). Ayarlandığında, her döngünün raporlanan enerjisi bu sayacın baştan sona olan farkından alınır; bu da yavaş raporlayan bir güç sensörünün integralini almaktan kaynaklanan eksik sayımı önler. Okuma eksikse, birimi bilinmiyorsa veya fark pozitif değilse integral alınmış değere geri döner. Güç sensörünün integralini almaya devam etmek için boş bırakın.", + "label": "Enerji Sayacı Varlığı" + }, "expose_debug_entities": { "doc": "Ekstra teşhis HA varlıkları yayınlayın (eşleşme güveni, belirsizlik, dahili durum bilgileri). Kapalı, varlık listesini normal kullanım için temiz tutar.", "label": "Hata Ayıklama Varlıklarını Göster" @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "Topluluk cihazlarında ve referans döngülerinde \"- \" özelliğini göster." + }, + "enable_phase_matching": { + "label": "Aşama farkındalıklı kalan süre", + "doc": "Çalışan her çevrimi aşamalara (ısıtma, yıkama, sıkma) böler ve kalan süreyi aşama başına ayırarak klasik tahminle harmanlar - çevrimin başında aşama bütçesine, sona yaklaşırken klasik tahmine ağırlık verir. Bu, geri sayımı makinenizin gerçekte ne kadar ısıttığına ve çalıştığına göre kişiselleştirir; en çok çevrimin ilk yarısında fark edilir. Kapalı = yalnızca klasik tahmin. Yalnızca kalan süre göstergesi etkilenir; program eşleştirme ve çevrim algılama değişmez." + }, + "keep_min_score": { + "label": "Min. Eşleşme Puanı", + "doc": "Bir adayın eşleşme yarışında kalabilmesi için gereken minimum benzerlik puanı. Varsayılan 0.1 kasıtlı olarak gevşektir - zayıf adayları bile kabul eder ve en iyi eşleşmeyi sonraki aşamalara bırakır. Olası olmayan profilleri daha erken elemek için artırın; başlangıç aday havuzunu genişletmek için azaltın." + }, + "dtw_blend": { + "label": "Büküm Karışımı", + "doc": "Zaman bükme (DTW) hizalama puanının Aşama 2 çekirdek puanını ne ölçüde değiştirdiği. 0 = yalnızca Aşama 2 puanı, 1 = yalnızca DTW puanı, 0.5 (varsayılan) = eşit karışım. Programların güç düzeyleri benzer ancak zamanlama kalıpları farklıysa büküm hizalamasına daha fazla güvenmek için artırın." + }, + "dtw_ensemble_w": { + "label": "Büküm Topluluk Karışımı", + "doc": "Topluluk DTW modunda (varsayılan), ölçeklendirilmiş L1 ile türev DTW (DDTW) arasındaki ağırlık. 1.0 = tamamen ölçeklendirilmiş-L1, 0 = tamamen DDTW, 0.7 = varsayılan. Ölçeklendirilmiş-L1 değişkeni güç düzeylerini karşılaştırır; türev değişkeni gücün zaman içinde nasıl değiştiğine tepki verir. Topluluk her ikisini de birleştirir." + }, + "dtw_ddtw_scale": { + "label": "Türev Büküm Ölçeği", + "doc": "Türev DTW puanlaması için yarı doygunluk mesafesi. DDTW mesafesi bu değere eşit olduğunda puan yarıya düşer. Küçük = şekil farklılıklarına daha duyarlı. Varsayılan 30, tipik ev aletleri güç izleri için kalibre edilmiştir. Yalnızca topluluk ve ddtw DTW modlarını etkiler." + }, + "dtw_refine_top_n": { + "label": "Büküm Yeniden Skor Sayısı", + "doc": "Kaç üst Aşama 2 adayı DTW tarafından yeniden puanlandırılır. DTW daha pahalıdır, bu nedenle yalnızca en iyi N adaya uygulanır. Varsayılan 5. Doğru profil bazen Aşama 2'den sonra yalnızca 4. veya 5. sıraya geliyorsa artırın (7-9'a) - DTW onu kurtarabilir. Azaltmak eşleşmeyi biraz hızlandırır." + }, + "duration_scale": { + "label": "Süre Keskinliği", + "doc": "Aşama 4 süre uyumu cezasının keskinliği. Bu, uyum puanının yarıya düştüğü logaritmik orandır. Küçük = daha sert: bir süre uyumsuzluğu daha fazla zarara yol açar. Varsayılan 0.175, yarı ağırlıkta yaklaşık %18 süre toleransına karşılık gelir. Süre Ağırlığı ile birlikte kullanın." + }, + "energy_scale": { + "label": "Enerji Keskinliği", + "doc": "Aşama 4 enerji uyumu cezasının keskinliği. Küçük = daha sert: bir enerji uyumsuzluğu daha fazla zarara yol açar. Varsayılan 0.25, enerji yükle değiştiğinden Süre Keskinliğinden kasıtlı olarak daha gevşektir. Enerji Ağırlığı ile birlikte kullanın." } }, "setting_group": { @@ -1570,24 +1624,24 @@ "lower": "Daha fazla döngüyü otomatik etiketler; bazıları yanlış tanımlanabilir" }, "duration_tolerance": { - "higher": "Daha geniş süre aralığı kabul eder — daha esnek eşleştirme", - "lower": "Daha yakın süre eşleşmesi gerektirir — daha katı ama alışılmadık döngüleri reddedebilir" + "higher": "Daha geniş süre aralığı kabul eder – daha esnek eşleştirme", + "lower": "Daha yakın süre eşleşmesi gerektirir – daha katı ama alışılmadık döngüleri reddedebilir" }, "end_energy_threshold": { "higher": "Yalnızca önemli enerji kullanan döngüleri kapatır", "lower": "Daha kısa veya düşük enerjili döngüleri de kapatır" }, "end_repeat_count": { - "higher": "Bitiş sinyalinin daha fazla tekrarlanmasını gerektirir — daha güvenilir, biraz daha yavaş", - "lower": "Daha az tekrardan sonra biter — daha hızlı algılama, daha yüksek yanlış bitiş riski" + "higher": "Bitiş sinyalinin daha fazla tekrarlanmasını gerektirir – daha güvenilir, biraz daha yavaş", + "lower": "Daha az tekrardan sonra biter – daha hızlı algılama, daha yüksek yanlış bitiş riski" }, "learning_confidence": { - "higher": "Yalnızca çok güvenli eşleşmelerden öğrenir — daha yavaş ama daha güvenilir", + "higher": "Yalnızca çok güvenli eşleşmelerden öğrenir – daha yavaş ama daha güvenilir", "lower": "Daha fazla döngüden öğrenir; model daha gürültülü olabilir" }, "min_off_gap": { "higher": "Yeni döngü başlamadan önce daha uzun boşta kalma süresi gerekir", - "lower": "Kısa boşta kalma yeni döngü başlatır — arka arkaya çalışmalar bölünebilir" + "lower": "Kısa boşta kalma yeni döngü başlatır – arka arkaya çalışmalar bölünebilir" }, "min_power": { "higher": "Boşta güç çekimine daha az duyarlı; çok sessiz programları kaçırabilir", @@ -1598,24 +1652,24 @@ "lower": "Takılı döngüyü daha erken zorunlu durdurur" }, "off_delay": { - "higher": "Cihaz duraklamaları için daha fazla süre — uzun ıslatma veya kurutma aşamalarını yönetir", - "lower": "Daha hızlı bitiş algılama — net açık/kapalı cihazlar için iyi çalışır" + "higher": "Cihaz duraklamaları için daha fazla süre – uzun ıslatma veya kurutma aşamalarını yönetir", + "lower": "Daha hızlı bitiş algılama – net açık/kapalı cihazlar için iyi çalışır" }, "profile_match_threshold": { "higher": "Yalnızca yüksek güvenli eşleşmeleri onaylar; daha fazla döngü etiketsiz kalır", "lower": "Daha fazla döngüyle eşleşir; bazıları hafif hatalı olabilir" }, "running_dead_zone": { - "higher": "Boşta kısa güç artışlarını yoksayar — gürültüye daha az duyarlı", - "lower": "Daha kısa güç patlamalarına yanıt verir — hızlı geçici etkinliği yakalar" + "higher": "Boşta kısa güç artışlarını yoksayar – gürültüye daha az duyarlı", + "lower": "Daha kısa güç patlamalarına yanıt verir – hızlı geçici etkinliği yakalar" }, "start_threshold_w": { "higher": "Kısa güç artışlarından kaynaklanan yanlış başlatmaları önler", "lower": "Düşük güçlü programları yakalar; gürültüde tetiklenebilir" }, "stop_threshold_w": { - "higher": "Döngüyü daha hızlı bitirir — duraklamada kapanabilir", - "lower": "Bitişi daha uzun bekler — daha az erken tamamlanma" + "higher": "Döngüyü daha hızlı bitirir – duraklamada kapanabilir", + "lower": "Bitişi daha uzun bekler – daha az erken tamamlanma" }, "watchdog_interval": { "higher": "Daha uzun sessiz aşamalara izin verilir; daha az yanlış zorunlu durdurma", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Optimizasyon: {param}" + }, + "pg_detail": { + "simulate": "Döngü simülasyonu" + }, + "split": { + "apply": "Döngü bölünüyor" + }, + "trim": { + "apply": "Döngü kırpılıyor" + }, + "merge": { + "apply": "Döngüler birleştiriliyor" + }, + "rebuild": { + "envelopes": "Zarflar yeniden oluşturuluyor" + }, + "cancelling": "İptal ediliyor..." + }, + "setup": { + "hdr": { + "card": "Cihaz Kurulumu", + "healthy_chip": "Kurulum tamamlandı" + }, + "phase0": { + "washer": "WashData döngülerinizi zaten algılıyor. Program adlarını ve süre tahminlerini etkinleştirmek için ilk döngünüzü kaydedin.", + "dishwasher": "WashData izliyor. Bulaşık makinelerinin döngüleri karmaşıktır – ilk döngünüzü kaydetmeniz kesinlikle önerilir. Algılanan bir döngü çok uzun sürerse, profil olarak kaydetmeden önce döngü düzenleyicisini kullanarak kırpın.", + "generic": "WashData izliyor. Profil oluşturmaya başlamak için algılanan bir döngüyü kaydedin veya etiketleyin." + }, + "phase1a": { + "labelled": "İyi bir başlangıç – ilk programınız kaydedildi. En temiz veri için bir sonraki döngünüzü kaydedici widget'ı ile kaydetmeyi düşünün." + }, + "phase1b": { + "recorded": "Kaydınız {profile_name} olarak kaydedildi. Kapsama alanı oluşturmak için diğer yaygın programlarınızı kaydedin veya etiketleyin." + }, + "phase1c": { + "verify": "Topluluktan {count} programınız var. WashData'nın doğru tanıyıp tanımadığını doğrulamak için bir döngü çalıştırın – cihazınız kendi geçmişini oluşturdukça eşleştirme iyileşecektir." + }, + "phase2": { + "cluster": "WashData, kayıtlı hiçbir programla eşleşmeyen {count} döngü gördü – bunlar birbirlerine benziyor. Bunlar için yeni bir profil oluşturmak ister misiniz?", + "unmatched": "Son döngünüz kayıtlı hiçbir programla eşleşmedi. Yeni bir program mı?" + }, + "phase3": { + "suggestions": "WashData, döngü geçmişinize göre ayar önerileri sunuyor – algılama doğruluğunu artırmak için bunları inceleyin.", + "groups": "Bazı profilleriniz farklı sıcaklıklardaki aynı programa benziyor. Daha iyi eşleştirme için bunları bir grupta düzenleyin.", + "phases": "Daha doğru kalan süre tahminleri için {profile_name} profiline program aşamaları ekleyin." + }, + "phase4": { + "healthy": "Bu cihaz tamamen kuruldu ({profile_count} profil)." + }, + "cta": { + "start_recording": "Kaydı Başlat", + "label_detected_cycle": "Zaten algılanmış bir döngüm var – bunun yerine etiketle", + "browse_cycles": "Başka birini etiketlemek için döngülere göz at", + "view_profiles": "Profillerinizi görüntüleyin", + "create_from_cluster": "Bu döngülerden profil oluştur", + "create_profile": "Bunun için bir profil oluştur", + "review_suggestions": "Önerileri incele", + "organise_profiles": "Profilleri düzenle", + "configure_phases": "Aşamaları yapılandır", + "skip_step": "Bu adımı atla", + "skip_forever": "Bir daha gösterme", + "hide_guidance": "Rehberi gizle", + "show_guidance": "Rehberi göster" } } } diff --git a/custom_components/ha_washdata/translations/panel/uk.json b/custom_components/ha_washdata/translations/panel/uk.json index 1bf059f..eb8891a 100644 --- a/custom_components/ha_washdata/translations/panel/uk.json +++ b/custom_components/ha_washdata/translations/panel/uk.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "Виявлено {n} аномалій (наприклад, двері відкриті посеред циклу) — відкрийте, щоб побачити їх на графіку", + "artifact_tip": "Виявлено {n} аномалій (наприклад, двері відкриті посеред циклу) – відкрийте, щоб побачити їх на графіку", "auto": "(виявлено автоматично)", "built_in_tag": "вбудований", "declining": "↘ знижується", "energy_low": "Енергії менше, ніж зазвичай", "energy_spike": "Енергії більше, ніж зазвичай", "fair_fit": "прийнятна якість", - "fair_fit_tip": "Помірна постійність збігів — деякі цикли, призначені цьому профілю, мають нижчі оцінки надійності. Позначте більше циклів або перезапишіть профіль, щоб підвищити точність.", + "fair_fit_tip": "Помірна постійність збігів – деякі цикли, призначені цьому профілю, мають нижчі оцінки надійності. Позначте більше циклів або перезапишіть профіль, щоб підвищити точність.", "feedback_requested": "Запит на відгук", "golden_cycle": "Записаний контрольний цикл", "improving": "↗ покращується", @@ -15,7 +15,7 @@ "needs_review": "Потребує перегляду", "overrun": "Тривав довше, ніж зазвичай", "poor_fit": "погана якість", - "poor_fit_tip": "Непослідовна історія збігів — розгляньте можливість відновлення цього профілю", + "poor_fit_tip": "Непослідовна історія збігів – розгляньте можливість відновлення цього профілю", "restart_gap_tip": "{n} пропуск при перезапуску HA: у графіку потужності є розрив", "reviewed": "Переглянуто", "steady": "→ стабільно", @@ -57,7 +57,7 @@ "create_profile": "+ Створити профіль", "delete": "Видалити", "delete_group": "Видалити групу", - "delete_group_tip": "Видалити лише цю групу — профілі учасників збережуться", + "delete_group_tip": "Видалити лише цю групу – профілі учасників збережуться", "delete_profile": "Видалити профіль", "discard": "Відхилити", "discard_tip": "Відхилити запис без збереження", @@ -87,7 +87,7 @@ "on_cycle_finished": "Коли цикл завершено", "on_cycle_started": "Коли цикл розпочато", "pause_cycle": "Пауза", - "pause_cycle_tip": "Призупинити поточний цикл — прилад продовжить роботу з того місця, де зупинився", + "pause_cycle_tip": "Призупинити поточний цикл – прилад продовжить роботу з того місця, де зупинився", "pg_apply_device": "Застосувати до пристрою", "pg_autofill": "Автозаповнення", "play": "Відтворити", @@ -97,8 +97,8 @@ "process_tip": "Зберегти запис як новий або наявний профіль", "rebuild": "Відновити конверти", "rebuild_envelope": "Відновити конверт", - "rebuild_tip": "Перерахувати очікуваний конверт потужності (смуга мін./макс.) для всіх профілів з позначених циклів — запускати після позначення нових або виправлення старих циклів", - "rec_start_tip": "Розпочати запис потужності приладу — запустіть безпосередньо перед початком циклу", + "rebuild_tip": "Перерахувати очікуваний конверт потужності (смуга мін./макс.) для всіх профілів з позначених циклів – запускати після позначення нових або виправлення старих циклів", + "rec_start_tip": "Розпочати запис потужності приладу – запустіть безпосередньо перед початком циклу", "rec_stop": "Зупинити", "rec_stop_tip": "Зупинити запис і зберегти захоплені дані для перегляду", "record": "Почати запис", @@ -182,7 +182,7 @@ }, "attn_sub": "Виправте конфлікти перед збереженням", "attn_title": "{n} конфлікт{s} налаштувань", - "settings_banner": "{n} конфлікт{s} налаштувань — перевірте виділені розділи й виправте перед збереженням.", + "settings_banner": "{n} конфлікт{s} налаштувань – перевірте виділені розділи й виправте перед збереженням.", "settings_banner_btn": "До першого", "confidence": { "auto": "Має бути не нижчим за Поріг Зіставлення ({match})", @@ -451,9 +451,9 @@ "show_expected": "Показати накладення очікуваної кривої (відповідний профіль, помаранчевий)", "show_raw": "Показати перемикач необроблених даних розетки на графіку потужності в реальному часі", "sparkline": "Тренд тривалості останніх циклів", - "stage2": "Етап 2 — основна подібність", - "stage3": "Етап 3 — DTW", - "stage4": "Етап 4 — відповідність", + "stage2": "Етап 2 – основна подібність", + "stage3": "Етап 3 – DTW", + "stage4": "Етап 4 – відповідність", "status": "Стан", "tail_trim": "Обрізання кінця (с)", "timer_auto_pause": "Автопауза", @@ -561,7 +561,12 @@ "flags": "Мітки", "include_phase_map": "Включити карту фаз", "include_settings": "Включити налаштування", - "show_contributor": "Показати автора" + "show_contributor": "Показати автора", + "task_pg_detail": "Змоделювати цикл", + "task_split": "Розділення циклу", + "task_trim": "Обрізання циклу", + "task_merge": "Об'єднання циклів", + "task_rebuild": "Перерахунок обвідних" }, "log": { "all_levels": "Усі рівні", @@ -645,19 +650,19 @@ "artifact_dip_detail": "Опустився нижче звичайної смуги потужності приблизно на ~{n} с.", "artifact_footer": "Виділено на графіку вище. Це тимчасові артефакти (наприклад, двері відчинені посередині циклу), не обов'язково проблеми.", "artifact_header": "Протягом цього циклу виявлено {n} аномалій", - "artifact_pause_detail": "Потужність впала майже до нуля приблизно на ~{n} с, а потім відновилася — імовірно, двері відчинили під час циклу або цикл призупинили.", + "artifact_pause_detail": "Потужність впала майже до нуля приблизно на ~{n} с, а потім відновилася – імовірно, двері відчинили під час циклу або цикл призупинили.", "artifact_spike_detail": "Вийшов за верхню межу звичайної смуги потужності приблизно на ~{n} с.", "auto_label_intro": "Призначте профілі непозначеним циклам, чия впевненість збігу перевищує поріг.", "automations_intro": "WashData запускає події {start} / {end} та надає об'єкти, тому сповіщення й дії найкраще будувати як звичайні автоматизації Home Assistant. Автоматизації, що використовують цей пристрій, відображаються нижче.", "cleanup_intro": "Усі позначені цикли накладено. Позначте аномалії і видаліть їх для очищення профілю.", "clear_debug_hint": "Видаліть збережені дані налагодження, щоб звільнити місце.", - "collecting_data": "Збір даних — потрібно ще {need} цикл{plural} до початку точного налаштування ({current}/{min}).", + "collecting_data": "Збір даних – потрібно ще {need} цикл{plural} до початку точного налаштування ({current}/{min}).", "compare_overlay_profiles": "Накласти профілі (блідо)", "compare_profiles_tip": "Накладіть інші конверти профілів на таблицю вище, щоб побачити, який з них найкраще підходить для цього циклу.", - "compare_selected_cycles": "Вибрані цикли (суцільно) — показати / приховати", + "compare_selected_cycles": "Вибрані цикли (суцільно) – показати / приховати", "consider_new_profile": "Розгляньте можливість створення нового профілю.", "coverage_gap": "останні цикли не мають відповідного профілю ({pct}% з останніх 30).", - "coverage_gap_similar_cycles": "Знайдено {count} схожих непозначених циклів — створіть профіль, щоб почати їх зіставлення.", + "coverage_gap_similar_cycles": "Знайдено {count} схожих непозначених циклів – створіть профіль, щоб почати їх зіставлення.", "cycles_deleted": "Видалено циклів: {count}", "enough_data": "Достатньо даних для навчання ({current}/{min} циклів).", "export_description": "Експортуйте всі профілі та цикли в JSON або відновіть попередній експорт.", @@ -686,7 +691,7 @@ "ml_learned_intro": "Моделі, налаштовані під цей пристрій.", "ml_loading": "завантаження ML…", "ml_settings_intro": "Два незалежних перемикачі: один застосовує моделі під час циклу, інший дозволяє WashData з часом налаштовуватися під ваш пристрій.", - "name_first_program": "У вас достатньо циклів — назвіть першу програму, щоб почати зіставлення.", + "name_first_program": "У вас достатньо циклів – назвіть першу програму, щоб почати зіставлення.", "near_duplicate_cluster": "виявлено майже повторюваний кластер профілів. Групування дає змогу надійно вибирати між подібними програмами (наприклад, однакова програма з різною температурою/віджиманням).", "no_cycles_match": "Жоден цикл не відповідає поточному фільтру.", "no_cycles_profile": "Немає циклів для цього профілю.", @@ -694,12 +699,12 @@ "no_cycles_yet": "Жодного циклу ще не записано.", "no_device_selected": "Пристрій не вибрано.", "no_devices": "Жодного пристрою WashData ще не налаштовано.", - "no_envelope": "Конверта ще немає — відновіть після позначення циклів.", + "no_envelope": "Конверта ще немає – відновіть після позначення циклів.", "no_envelope_overlay": "Немає доступного конверта для накладання.", - "no_fine_tuned": "Ще нічого не налаштовано — WashData використовує вбудовані моделі.", + "no_fine_tuned": "Ще нічого не налаштовано – WashData використовує вбудовані моделі.", "no_logs": "Жодних записів журналу ще не буферизовано.", "no_maintenance": "Обслуговування ще не записано.", - "no_match_yet": "Ще немає спроби збігу — це заповнюється під час запущеного циклу.", + "no_match_yet": "Ще немає спроби збігу – це заповнюється під час запущеного циклу.", "no_other_users": "Інших користувачів Home Assistant не знайдено.", "no_phases": "Фаз не визначено.", "no_phases_assigned": "Фаз не призначено.", @@ -713,7 +718,7 @@ "notify_services_hint": "Використовуйте ID сервісів {entity} (через кому для кількох). Змінні шаблону: {vars}.", "old_actions_warning": "Налаштовано за допомогою старого редактора дій (тепер вилучено). Вони все ще запускаються на події циклу, але їх більше не можна редагувати тут. Перетворіть їх на звичайну автоматизацію або видаліть.", "onboarding_progress": "{n} / 3 циклів спостережено", - "onboarding_watching": "Користуйтеся приладом як завжди — WashData спостерігає. Після 3 циклів почнеться зіставлення програм.", + "onboarding_watching": "Користуйтеся приладом як завжди – WashData спостерігає. Після 3 циклів почнеться зіставлення програм.", "pending_feedback": "Очікує відгук про виявлення", "performance_trend": "Тенденція продуктивності ({n} циклів)", "pg_analysis_empty": "Завантажте цикл, щоб побачити аналіз збігу.", @@ -748,13 +753,13 @@ "restart_gap_footer": "Виділено на графіку. Дані потужності відсутні для цих інтервалів; зіставлення використовувало лише реальні показання.", "restart_gap_header": "{n} пропуск при перезапуску HA під час цього циклу", "restart_gap_item": "{dur} пропуск", - "review_confirm_help": "Перевірте, чи правильно було виявлено цей цикл. Ваші відгуки навчають модель на вашому пристрої — що більше циклів ви підтвердите, то кращим буде відповідність та оцінка стану. Достатньо швидкого Добре/Погано.", + "review_confirm_help": "Перевірте, чи правильно було виявлено цей цикл. Ваші відгуки навчають модель на вашому пристрої – що більше циклів ви підтвердите, то кращим буде відповідність та оцінка стану. Достатньо швидкого Добре/Погано.", "review_in_settings": "Переглянути в налаштуваннях", "review_notes_placeholder": "Примітки (необов'язково)", "review_notes_tip": "Довільні текстові нотатки для власної довідки. Не використовується під час підбору чи навчання.", - "review_profile_tip": "Програма, якою позначено цей цикл. Якщо автоматично визначена програма була неправильною, виправте це тут — маркування навчає відповідності для майбутніх циклів.", + "review_profile_tip": "Програма, якою позначено цей цикл. Якщо автоматично визначена програма була неправильною, виправте це тут – маркування навчає відповідності для майбутніх циклів.", "review_quality_tip": "Наскільки чистий цей цикл. Добре = хрестоматійний приклад цієї програми; Погано = виявлено, але шумно або нетипово; Непридатний = неправильно виявлений (об'єднаний, усічений або фальшивий). Керує оцінкою стану та циклами, дозволеними для навчання моделі.", - "review_recorded_tip": "Позначте це як підібраний вручну еталонний цикл для своєї програми — така ж роль, як цикл, записаний вручну. Довідкові цикли завжди зберігаються, заповнюють відповідний шаблон і ніколи не відкидаються під час очищення. (Це «золотий»/записаний прапор; обидва — одне й те саме.)", + "review_recorded_tip": "Позначте це як підібраний вручну еталонний цикл для своєї програми – така ж роль, як цикл, записаний вручну. Довідкові цикли завжди зберігаються, заповнюють відповідний шаблон і ніколи не відкидаються під час очищення. (Це «золотий»/записаний прапор; обидва – одне й те саме.)", "review_tags_tip": "Необов'язкові прапорці, що описують, що пішло не так у цьому циклі, щоб навчання та очищення могли це врахувати.", "review_to_cycles": "Відкрити чергу огляду циклів", "saving_triggers_reload": "Збереження запускає перезавантаження інтеграції. Об'єкти HA можуть короткочасно відображатися як недоступні.", @@ -763,7 +768,7 @@ "setting_changed": "Змінено з {old} на {new} {date}", "settings_basic_note": "Показано основні налаштування. Перейдіть до Розширених, щоб побачити весь список.", "shape_drift_advisory": "⚠ Форма змінюється", - "shape_drift_detail": "Профіль потужності для цього профілю змінився з часом — можливе зношення приладу або потрібне обслуговування (наприклад, видалення накипу, очищення фільтра).", + "shape_drift_detail": "Профіль потужності для цього профілю змінився з часом – можливе зношення приладу або потрібне обслуговування (наприклад, видалення накипу, очищення фільтра).", "show_all_settings": "Показати всі налаштування", "showing_suggestions": "Показано {count} налаштувань із пропозиціями.", "split_intro": "Натисніть на графік, щоб додати або видалити точку поділу, або автовизначення за паузами. Кожен отриманий сегмент може отримати власний профіль.", @@ -789,10 +794,10 @@ "toast_revert_failed": "Відкат не виконано: {error}", "toast_reverted": "Повернено: {key}", "toast_save_failed": "Збереження не виконано: {error}", - "trend_duration_longer": "Тривалість збільшується ({pct}%/цикл) — останній середній показник {avg}", - "trend_duration_shorter": "Тривалість скорочується ({pct}%/цикл) — останній середній показник {avg}", + "trend_duration_longer": "Тривалість збільшується ({pct}%/цикл) – останній середній показник {avg}", + "trend_duration_shorter": "Тривалість скорочується ({pct}%/цикл) – останній середній показник {avg}", "trend_energy_down": "Енергія зменшується ({pct}%/цикл)", - "trend_energy_up": "Енергія зростає ({pct}%/цикл) — останній середній показник {avg}", + "trend_energy_up": "Енергія зростає ({pct}%/цикл) – останній середній показник {avg}", "trim_intro": "Перетягніть червоні маркери або введіть значення. Все за межами вікна видаляється.", "tuning_suggestions_available": "Доступна {count} пропозиція налаштування на основі спостережених циклів. Вони з'являються поряд із відповідними полями.", "unsure_detected_prefix": "WashData не впевнений, що виявив", @@ -857,7 +862,9 @@ "share_guidelines_title": "Перед публікацією", "store_download_device_intro": "Завантажити конфігурацію пристрою зі спільноти та застосувати її до нового або наявного пристрою", "store_share_device_intro": "Поділитися програмами вашого пристрою (профілі + еталонні цикли) зі спільнотою. Налаштування необов'язкові.", - "share_profile_no_cycles": "Профіль '{p}' не має ⭐ еталонних циклів -- його буде пропущено, якщо у вас немає {n}+ підтверджених запусків" + "share_profile_no_cycles": "Профіль '{p}' не має ⭐ еталонних циклів -- його буде пропущено, якщо у вас немає {n}+ підтверджених запусків", + "advisory_phase_inconsistent": "Схоже, що '{name}' поєднує різні програми або температури - його цикли нагріваються протягом дуже різного часу. Розділення на окремі профілі (напр. за температурою) покращить зіставлення та оцінки часу.", + "advisory_phase_inconsistent_title": "⚠ Можливо, змішані програми" }, "phase_desc": { "anti_crease": "Час від часу короткі оберти після завершення для зменшення зморшок.", @@ -940,6 +947,10 @@ "triggers": { "intro": "Додаткові зовнішні сигнали: тригер завершення, датчик дверей, перемикач паузи та нагадування про вивантаження.", "label": "Тригери & Двері" + }, + "phase_eta": { + "label": "Залишок часу", + "intro": "Оцінка залишку часу з урахуванням фаз, для машин, тривалість циклу яких залежить від температури або віджиму." } }, "setting": { @@ -1023,6 +1034,10 @@ "doc": "Фіксована ціна за кВт-год, яка використовується для показників витрат, якщо вище не встановлено реальну ціну.", "label": "Фіксована ціна на енергію (за kWh)" }, + "energy_sensor": { + "doc": "Необов’язковий накопичувальний лічильник енергії (total_increasing kWh/Wh, наприклад, власний лічильник загального споживання розетки). Якщо задано, енергія, що відображається для кожного циклу, береться з різниці показань цього лічильника між початком і кінцем, що дозволяє уникнути заниження, яке виникає при інтегруванні датчика потужності, що повільно оновлюється. Якщо показання відсутнє, його одиниця невідома або різниця не додатна, використовується інтегроване значення. Залиште порожнім, щоб продовжувати інтегрувати датчик потужності.", + "label": "Сутність лічильника енергії" + }, "expose_debug_entities": { "doc": "Опублікуйте додаткові діагностичні об’єкти високої доступності (достовірність відповідності, неоднозначність, внутрішні стани). Якщо вимкнено, список об’єктів залишається чистим для звичайного використання.", "label": "Показати відлагоджувальні сутності" @@ -1305,6 +1320,38 @@ }, "show_contributor": { "doc": "Показати ім'я автора на профілях, завантажених з магазину спільноти" + }, + "enable_phase_matching": { + "label": "Залишок часу з урахуванням фаз", + "doc": "Розбиває кожен активний цикл на фази (нагрівання, прання, віджим) і розподіляє залишок часу за фазами, поєднуючи це з класичною оцінкою - спираючись на розподіл за фазами на початку циклу та на класичну оцінку ближче до кінця. Це персоналізує відлік під те, скільки насправді нагрівається та працює ваша машина, що найпомітніше в першій половині циклу. Вимкнено = лише класична оцінка. Впливає лише на відображення залишку часу; зіставлення програм і виявлення циклу залишаються незмінними." + }, + "keep_min_score": { + "label": "Мін. оцінка збігу", + "doc": "Мінімальна оцінка подібності, необхідна кандидату для участі у змаганні зі збігів. Значення за замовчуванням 0,1 є навмисно м'яким - допускає навіть слабких кандидатів і покладається на пізніші етапи для знаходження найкращого збігу. Збільшіть для ранішого відхилення малоймовірних профілів; зменшіть для розширення початкового пулу кандидатів." + }, + "dtw_blend": { + "label": "Змішування деформації", + "doc": "Наскільки оцінка вирівнювання DTW замінює базову оцінку Етапу 2. 0 = використовуй лише оцінку Етапу 2, 1 = використовуй лише оцінку DTW, 0,5 (за замовчуванням) = рівне змішування. Збільшіть для більшого покладання на вирівнювання деформацією, коли програми мають схожі рівні потужності, але різні часові шаблони." + }, + "dtw_ensemble_w": { + "label": "Мікс комбінації DTW", + "doc": "В режимі ансамблю DTW (за замовчуванням), вага масштабованого L1 проти похідного DTW (DDTW). 1,0 = лише масштабований L1, 0 = лише DDTW, 0,7 = за замовчуванням. Масштабований варіант L1 порівнює рівні потужності; похідний варіант реагує на зміни потужності в часі. Ансамбль поєднує обидва." + }, + "dtw_ddtw_scale": { + "label": "Масштаб похідної деформації", + "doc": "Відстань напівнасичення для оцінювання похідним DTW. Оцінка зменшується вдвічі, коли відстань DDTW рівна цьому значенню. Менша = більш чутлива до відмінностей форми. Значення за замовчуванням 30 відкалібровано для типових слідів потужності побутових приладів. Впливає лише на режими ensemble та ddtw DTW." + }, + "dtw_refine_top_n": { + "label": "Кількість уточнених кандидатів", + "doc": "Скільки провідних кандидатів Етапу 2 DTW переоцінює. DTW є дорожчим, тому застосовується лише до N найкращих кандидатів. За замовчуванням 5. Збільшіть (до 7-9), якщо правильний профіль іноді досягає лише 4-го або 5-го місця після Етапу 2 - DTW може його врятувати. Зменшення трохи прискорює зіставлення." + }, + "duration_scale": { + "label": "Гострота тривалості", + "doc": "Гострота штрафу за невідповідність тривалості на Етапі 4. Це лог-відношення, при якому оцінка збігу зменшується вдвічі. Менше = суворіше: невідповідність тривалості більше знижує оцінку. Значення за замовчуванням 0,175 відповідає допуску тривалості близько 18% при половинній вазі. Використовуйте разом із Вагою тривалості." + }, + "energy_scale": { + "label": "Гострота енергії", + "doc": "Гострота штрафу за невідповідність енергії на Етапі 4. Менше = суворіше: невідповідність енергії більше знижує оцінку. Значення за замовчуванням 0,25 є навмисно більш м'яким, ніж Гострота тривалості, оскільки енергія варіюється з навантаженням. Використовуйте разом із Вагою енергії." } }, "setting_group": { @@ -1553,24 +1600,24 @@ "lower": "Автоматично позначає більше циклів; деякі можуть бути визначені неправильно" }, "duration_tolerance": { - "higher": "Приймає ширший діапазон тривалості — гнучкіше зіставлення", - "lower": "Потребує точнішого збігу тривалості — суворіше, але може відхилити незвичні цикли" + "higher": "Приймає ширший діапазон тривалості – гнучкіше зіставлення", + "lower": "Потребує точнішого збігу тривалості – суворіше, але може відхилити незвичні цикли" }, "end_energy_threshold": { "higher": "Закриває лише цикли зі значним споживанням енергії", "lower": "Закриває також коротші або маломощні цикли" }, "end_repeat_count": { - "higher": "Потребує більшої кількості повторень сигналу завершення — надійніше, дещо повільніше", - "lower": "Завершує після меншої кількості повторень — швидше, більший ризик хибного завершення" + "higher": "Потребує більшої кількості повторень сигналу завершення – надійніше, дещо повільніше", + "lower": "Завершує після меншої кількості повторень – швидше, більший ризик хибного завершення" }, "learning_confidence": { - "higher": "Навчається лише на дуже впевнених збігах — повільніше, але надійніше", + "higher": "Навчається лише на дуже впевнених збігах – повільніше, але надійніше", "lower": "Навчається на більшій кількості циклів; модель може бути більш зашумленою" }, "min_off_gap": { "higher": "Перед новим циклом потрібен тривалий простій", - "lower": "Короткий простій запускає новий цикл — послідовні запуски можуть розділитися" + "lower": "Короткий простій запускає новий цикл – послідовні запуски можуть розділитися" }, "min_power": { "higher": "Менш чутливий до фонового споживання; може пропустити тихі програми", @@ -1581,24 +1628,24 @@ "lower": "Швидше примусово зупиняє зависший цикл" }, "off_delay": { - "higher": "Більше часу для пауз пристрою — обробляє тривале замочування або сушку", - "lower": "Швидше визначення завершення — добре для пристроїв із чітким вкл./викл." + "higher": "Більше часу для пауз пристрою – обробляє тривале замочування або сушку", + "lower": "Швидше визначення завершення – добре для пристроїв із чітким вкл./викл." }, "profile_match_threshold": { "higher": "Підтверджує лише збіги з високою впевненістю; більше циклів залишаються без мітки", "lower": "Зіставляє більше циклів; деякі можуть бути дещо неправильними" }, "running_dead_zone": { - "higher": "Ігнорує короткочасні стрибки потужності в режимі простою — менш чутливий до шуму", - "lower": "Реагує на коротші сплески потужності — вловлює швидку перехідну активність" + "higher": "Ігнорує короткочасні стрибки потужності в режимі простою – менш чутливий до шуму", + "lower": "Реагує на коротші сплески потужності – вловлює швидку перехідну активність" }, "start_threshold_w": { "higher": "Запобігає хибним запускам від короткочасних стрибків потужності", "lower": "Вловлює маломощні програми; може спрацювати на шум" }, "stop_threshold_w": { - "higher": "Завершує цикл швидше — може закрити під час паузи", - "lower": "Довше очікує завершення — менше передчасних завершень" + "higher": "Завершує цикл швидше – може закрити під час паузи", + "lower": "Довше очікує завершення – менше передчасних завершень" }, "watchdog_interval": { "higher": "Дозволяє триваліші тихі фази; менше хибних примусових зупинок", @@ -1676,12 +1723,19 @@ "start_duration_threshold": "Секунди вище порогу для підтвердження старту", "start_threshold_w": "Мінімум ват, щоб вважати запущеним", "stop_threshold_w": "Нижче цього машина вважається вимкненою", - "dtw_bandwidth": "Наскільки сильне розтягнення в часі дозволяє зіставлення за формою", - "corr_weight": "Вага форми кривої відносно рівня потужності при зіставленні", - "duration_weight": "Наскільки збіг за тривалістю впливає на зіставлення", - "energy_weight": "Наскільки збіг за енергією впливає на зіставлення", - "profile_match_min_duration_ratio": "Найкоротший запуск (щодо профілю), який ще може збігтися", - "profile_match_max_duration_ratio": "Найдовший запуск (щодо профілю), який ще може збігтися" + "dtw_bandwidth": "Етап 3: смуга деформації Sakoe-Chiba (0 = DTW вимкнено; за замовчуванням 0,2 = 20% довжини циклу)", + "corr_weight": "Етап 2: баланс між формою кривої (кореляція) та рівнем потужності (MAE); за замовчуванням 0,45", + "duration_weight": "Етап 4: наскільки сильно збіг за тривалістю впливає на кінцеву оцінку (за замовчуванням 0,22)", + "energy_weight": "Етап 4: наскільки сильно збіг за енергією впливає на кінцеву оцінку (за замовчуванням 0,22)", + "profile_match_min_duration_ratio": "Етап 1: мінімальна довжина циклу як частка тривалості профілю; за замовчуванням 0,1 (10%)", + "profile_match_max_duration_ratio": "Етап 1: максимальна довжина циклу як частка тривалості профілю; за замовчуванням 1,5 (150%)", + "keep_min_score": "Етап 2: мінімальна оцінка для участі у змаганні; за замовчуванням 0,1 допускає слабкі збіги, пізніші етапи обирають найкращий", + "dtw_blend": "Етап 3: 0 = лише базова оцінка, 1 = лише оцінка DTW, 0,5 = рівне змішування (за замовчуванням)", + "dtw_ensemble_w": "Етап 3 (режим ансамблю): вага масштабованого L1 проти похідного DTW; за замовчуванням 0,7 надає перевагу рівню", + "dtw_ddtw_scale": "Етап 3: відстань напівнасичення DDTW; менша = більш чутлива до форми (за замовчуванням 30)", + "dtw_refine_top_n": "Етап 3: кандидати, які DTW переоцінює; збільшіть до 7-9, якщо правильний профіль займає 4-5 місце (за замовчуванням 5)", + "duration_scale": "Етап 4: лог-відношення, при якому збіг за тривалістю зменшується вдвічі; менше = суворіший штраф (за замовчуванням 0,175)", + "energy_scale": "Етап 4: лог-відношення, при якому збіг за енергією зменшується вдвічі; менше = суворіший штраф (за замовчуванням 0,25)" }, "col": { "profile_tip": "Назва зіставленої програми. «Без мітки» означає, що наприкінці циклу не збігся жоден профіль.", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "Оптимізація: {param}" + }, + "pg_detail": { + "simulate": "Змоделювати цикл" + }, + "split": { + "apply": "Розділення циклу" + }, + "trim": { + "apply": "Обрізання циклу" + }, + "merge": { + "apply": "Об'єднання циклів" + }, + "rebuild": { + "envelopes": "Перерахунок обвідних" + }, + "cancelling": "Скасування..." + }, + "setup": { + "hdr": { + "card": "Налаштування пристрою", + "healthy_chip": "Налаштування завершено" + }, + "phase0": { + "washer": "WashData вже виявляє ваші цикли. Запишіть перший цикл, щоб увімкнути назви програм і оцінки часу.", + "dishwasher": "WashData спостерігає. Посудомийні машини мають складні цикли – запис першого циклу наполегливо рекомендується. Якщо виявлений цикл триває занадто довго, обріжте його в редакторі циклів перед збереженням як профіль.", + "generic": "WashData спостерігає. Запишіть або позначте виявлений цикл, щоб почати створювати профілі." + }, + "phase1a": { + "labelled": "Гарний початок – ваша перша програма збережена. Для найчистіших даних розгляньте запис наступного циклу за допомогою віджета запису." + }, + "phase1b": { + "recorded": "Ваш запис збережено як {profile_name}. Тепер запишіть або позначте інші поширені програми для розширення охоплення." + }, + "phase1c": { + "verify": "У вас є {count} програм від спільноти. Запустіть цикл, щоб переконатися, що WashData правильно їх розпізнає – зіставлення покращуватиметься в міру того, як пристрій накопичує власну історію." + }, + "phase2": { + "cluster": "WashData виявив {count} циклів, які не відповідають жодній збереженій програмі – вони схожі один на одного. Хочете створити для них новий профіль?", + "unmatched": "Ваш останній цикл не збігся з жодною збереженою програмою. Це нова програма?" + }, + "phase3": { + "suggestions": "WashData має рекомендації щодо налаштувань на основі вашої історії циклів – перегляньте їх для підвищення точності виявлення.", + "groups": "Деякі ваші профілі схожі на одну й ту саму програму при різних температурах. Об'єднайте їх у групу для покращення зіставлення.", + "phases": "Додайте фази програми до {profile_name} для точніших оцінок часу, що залишився." + }, + "phase4": { + "healthy": "Цей пристрій повністю налаштовано ({profile_count} профілів)." + }, + "cta": { + "start_recording": "Почати запис", + "label_detected_cycle": "У мене вже є виявлений цикл – натомість позначити його", + "browse_cycles": "Переглянути цикли для позначення наступного", + "view_profiles": "Переглянути свої профілі", + "create_from_cluster": "Створити профіль з цих циклів", + "create_profile": "Створити для нього профіль", + "review_suggestions": "Переглянути пропозиції", + "organise_profiles": "Упорядкувати профілі", + "configure_phases": "Налаштувати фази", + "skip_step": "Пропустити цей крок", + "skip_forever": "Більше не показувати", + "hide_guidance": "Приховати підказки", + "show_guidance": "Показати підказки" } } } diff --git a/custom_components/ha_washdata/translations/panel/zh-Hans.json b/custom_components/ha_washdata/translations/panel/zh-Hans.json index b2ca4f2..5226e28 100644 --- a/custom_components/ha_washdata/translations/panel/zh-Hans.json +++ b/custom_components/ha_washdata/translations/panel/zh-Hans.json @@ -1,13 +1,13 @@ { "badge": { - "artifact_tip": "检测到 {n} 个异常(例如,门在周期中打开)— 打开以在图表上查看", + "artifact_tip": "检测到 {n} 个异常(例如,门在周期中打开)– 打开以在图表上查看", "auto": "(自动检测)", "built_in_tag": "内置", "declining": "↘ 下降中", "energy_low": "能耗低于正常水平", "energy_spike": "能耗高于正常水平", "fair_fit": "质量尚可", - "fair_fit_tip": "匹配一致性适中 — 分配给此配置文件的部分周期置信度较低。标记更多周期或重新录制配置文件以提高准确性。", + "fair_fit_tip": "匹配一致性适中 – 分配给此配置文件的部分周期置信度较低。标记更多周期或重新录制配置文件以提高准确性。", "feedback_requested": "已请求反馈", "golden_cycle": "记录参考周期", "improving": "↗ 提升中", @@ -15,7 +15,7 @@ "needs_review": "需要审查", "overrun": "运行时间超出正常", "poor_fit": "质量差", - "poor_fit_tip": "匹配历史不一致 — 考虑重建此配置文件", + "poor_fit_tip": "匹配历史不一致 – 考虑重建此配置文件", "restart_gap_tip": "{n} HA 重启间隙 - 电源跟踪有漏洞", "reviewed": "已审核", "steady": "→ 稳定", @@ -57,7 +57,7 @@ "create_profile": "+ 创建配置文件", "delete": "删除", "delete_group": "删除组", - "delete_group_tip": "仅删除此组 — 保留成员配置文件", + "delete_group_tip": "仅删除此组 – 保留成员配置文件", "delete_profile": "删除配置文件", "discard": "丢弃", "discard_tip": "丢弃录制的电力记录,不保存", @@ -87,7 +87,7 @@ "on_cycle_finished": "周期完成时", "on_cycle_started": "周期开始时", "pause_cycle": "暂停", - "pause_cycle_tip": "暂停正在运行的周期 — 设备将从中断处恢复", + "pause_cycle_tip": "暂停正在运行的周期 – 设备将从中断处恢复", "pg_apply_device": "应用到设备", "pg_autofill": "自动填充", "play": "播放", @@ -97,8 +97,8 @@ "process_tip": "将录制的电力记录保存为新的或现有的配置文件", "rebuild": "重建包络", "rebuild_envelope": "重建包络", - "rebuild_tip": "从已标记周期重新计算所有配置文件的预期功率包络(最小/最大范围)— 在标记新周期或修正旧周期后运行", - "rec_start_tip": "开始录制设备的电力轨迹 — 在运行周期之前立即开始", + "rebuild_tip": "从已标记周期重新计算所有配置文件的预期功率包络(最小/最大范围)– 在标记新周期或修正旧周期后运行", + "rec_start_tip": "开始录制设备的电力轨迹 – 在运行周期之前立即开始", "rec_stop": "停止", "rec_stop_tip": "停止录制并保留已捕获的电力记录以供审核", "record": "开始录制", @@ -231,7 +231,7 @@ "interval": "应至少为采样间隔 ({si} 秒) 的 2 倍", "sampling": "采样间隔应最多为看门狗间隔 ({wi} 秒) 的一半" }, - "settings_banner": "{n} 个设置冲突{s} — 请检查高亮显示的部分,并在保存前修复。", + "settings_banner": "{n} 个设置冲突{s} – 请检查高亮显示的部分,并在保存前修复。", "settings_banner_btn": "前往第一处" }, "hdr": { @@ -451,9 +451,9 @@ "show_expected": "显示预期曲线叠加(匹配配置文件,橙色)", "show_raw": "在实时功率图中显示原始插座切换开关", "sparkline": "近期周期时长趋势", - "stage2": "阶段 2 — 核心相似度", - "stage3": "阶段 3 — DTW", - "stage4": "阶段 4 — 一致度", + "stage2": "阶段 2 – 核心相似度", + "stage3": "阶段 3 – DTW", + "stage4": "阶段 4 – 一致度", "status": "状态", "tail_trim": "尾部裁剪(秒)", "timer_auto_pause": "自动暂停", @@ -561,7 +561,12 @@ "flags": "标志", "include_phase_map": "包含阶段图", "include_settings": "包含检测与匹配设置", - "show_contributor": "显示贡献者名称" + "show_contributor": "显示贡献者名称", + "task_pg_detail": "模拟周期", + "task_split": "正在拆分周期", + "task_trim": "正在裁剪周期", + "task_merge": "正在合并周期", + "task_rebuild": "正在重建包络" }, "log": { "all_levels": "所有级别", @@ -645,7 +650,7 @@ "artifact_dip_detail": "跌破通常功率范围约 {n} 秒。", "artifact_footer": "在上图中高亮显示。这些是瞬态异常(例如门在周期中打开),不一定是问题。", "artifact_header": "此周期中检测到 {n} 个异常", - "artifact_pause_detail": "功率降至接近零约 {n} 秒后恢复 — 可能是周期中途开门或周期被暂停。", + "artifact_pause_detail": "功率降至接近零约 {n} 秒后恢复 – 可能是周期中途开门或周期被暂停。", "artifact_spike_detail": "超出通常功率范围约 {n} 秒。", "auto_label_intro": "为置信度达到阈值的未标记周期分配配置文件。", "automations_intro": "WashData 触发 {start} / {end} 事件并公开实体,因此通知和操作最好构建为常规 Home Assistant 自动化。使用此设备的自动化显示在下方。", @@ -654,10 +659,10 @@ "collecting_data": "正在收集数据,还需要 {need} 个周期{plural}才能开始微调({current}/{min})。", "compare_overlay_profiles": "叠加配置文件(浅色)", "compare_profiles_tip": "将其他配置文件包络叠加在上方图表上,看哪个最适合此周期。", - "compare_selected_cycles": "选中的周期(实线)— 显示/隐藏", + "compare_selected_cycles": "选中的周期(实线)– 显示/隐藏", "consider_new_profile": "考虑创建新配置文件。", "coverage_gap": "近期周期没有匹配的配置文件(最近 30 次的 {pct}%)。", - "coverage_gap_similar_cycles": "发现 {count} 个相似未标记周期 — 创建配置文件以开始匹配它们。", + "coverage_gap_similar_cycles": "发现 {count} 个相似未标记周期 – 创建配置文件以开始匹配它们。", "cycles_deleted": "已删除 {count} 个周期", "enough_data": "有足够数据可供学习({current}/{min} 个循环)。", "export_description": "将所有配置文件和周期导出为 JSON,或从之前的导出中恢复。", @@ -686,7 +691,7 @@ "ml_learned_intro": "针对此机器微调的模型。", "ml_loading": "加载 ML 中…", "ml_settings_intro": "两个独立开关:一个在周期运行时应用模型,另一个让 WashData 随时间针对您的机器进行微调。", - "name_first_program": "您已有足够的周期 — 为您的第一个程序命名即可开始匹配。", + "name_first_program": "您已有足够的周期 – 为您的第一个程序命名即可开始匹配。", "near_duplicate_cluster": "检测到近似重复的配置文件群。分组可让匹配在相似项目间可靠地选择(例如同一程序在不同温度/转速下)。", "no_cycles_match": "没有与当前筛选条件匹配的周期。", "no_cycles_profile": "此配置文件没有周期。", @@ -694,12 +699,12 @@ "no_cycles_yet": "尚未记录任何周期。", "no_device_selected": "未选择设备。", "no_devices": "尚未配置 WashData 设备。", - "no_envelope": "还没有包络 — 标记周期后重建。", + "no_envelope": "还没有包络 – 标记周期后重建。", "no_envelope_overlay": "没有可叠加的包络。", - "no_fine_tuned": "尚未微调 — WashData 正在使用内置模型。", + "no_fine_tuned": "尚未微调 – WashData 正在使用内置模型。", "no_logs": "尚未缓冲任何日志记录。", "no_maintenance": "尚未记录任何维护。", - "no_match_yet": "尚未尝试匹配 — 此项在运行周期时填充。", + "no_match_yet": "尚未尝试匹配 – 此项在运行周期时填充。", "no_other_users": "未找到其他 Home Assistant 用户。", "no_phases": "未定义阶段。", "no_phases_assigned": "未分配阶段。", @@ -713,7 +718,7 @@ "notify_services_hint": "使用 {entity} 服务 ID(多个请用英文逗号分隔)。模板变量:{vars}。", "old_actions_warning": "使用旧版操作编辑器配置(现已移除)。它们仍会在周期事件上触发,但无法在此处编辑。将它们转换为普通自动化,或移除它们。", "onboarding_progress": "已观察 {n} / 3 个周期", - "onboarding_watching": "照常使用您的设备 — WashData 正在观察。3 个周期后将开始程序匹配。", + "onboarding_watching": "照常使用您的设备 – WashData 正在观察。3 个周期后将开始程序匹配。", "pending_feedback": "待处理的检测反馈", "performance_trend": "性能趋势({n} 个周期)", "pg_analysis_empty": "加载一个周期以查看匹配分析。", @@ -745,16 +750,16 @@ "recent_logs": "最近的 ha_washdata 记录", "recording_in_progress": "录制进行中", "reminders_intro": "在上次保养后经过这么多个周期时在面板中显示提醒。留空或填 0 可关闭提醒。", - "restart_gap_footer": "在图表上突出显示。这些间隔的功率数据缺失——仅使用真实读数进行匹配。", + "restart_gap_footer": "在图表上突出显示。这些间隔的功率数据缺失––仅使用真实读数进行匹配。", "restart_gap_header": "{n} 此周期内的 HA 重新启动间隔", "restart_gap_item": "{dur}间隙", - "review_confirm_help": "确认此周期是否被正确检测。您的审查可在您的机器上训练模型 — 确认的周期越多,匹配和健康评分越好。快速的好/差就足够了。", + "review_confirm_help": "确认此周期是否被正确检测。您的审查可在您的机器上训练模型 – 确认的周期越多,匹配和健康评分越好。快速的好/差就足够了。", "review_in_settings": "在设置中审查", "review_notes_placeholder": "注释(可选)", "review_notes_tip": "供个人参考的自由文本注释。不用于匹配或训练。", - "review_profile_tip": "此周期标记的程序。如果自动检测的程序有误,请在此处修正 — 标记可帮助未来周期的匹配学习。", + "review_profile_tip": "此周期标记的程序。如果自动检测的程序有误,请在此处修正 – 标记可帮助未来周期的匹配学习。", "review_quality_tip": "此周期的质量。好 = 该程序的标准示例;差 = 已检测但有噪声或不典型;不可用 = 误检测(合并、截断或虚假)。影响健康评分及允许训练模型的周期。", - "review_recorded_tip": "将此标记为其程序的精选参考周期 — 与手动录制周期的作用相同。参考周期始终保留,为匹配模板提供种子,且不会被清理删除。(这是“黄金”/录制标志;两者是同一件事。)", + "review_recorded_tip": "将此标记为其程序的精选参考周期 – 与手动录制周期的作用相同。参考周期始终保留,为匹配模板提供种子,且不会被清理删除。(这是“黄金”/录制标志;两者是同一件事。)", "review_tags_tip": "描述此周期出现问题的可选标志,以便训练和清理可以考虑这些问题。", "review_to_cycles": "打开周期审查队列", "saving_triggers_reload": "保存会触发集成重载。HA 实体可能短暂显示为不可用。", @@ -763,7 +768,7 @@ "setting_changed": "于 {date} 从 {old} 更改为 {new}", "settings_basic_note": "正在显示基本设置。切换到高级可查看完整列表。", "shape_drift_advisory": "⚠ 形状偏移", - "shape_drift_detail": "此配置文件的功率模式随时间发生了变化 — 可能是设备磨损或需要维护(例如除垢、清洁滤网)。", + "shape_drift_detail": "此配置文件的功率模式随时间发生了变化 – 可能是设备磨损或需要维护(例如除垢、清洁滤网)。", "show_all_settings": "显示所有设置", "showing_suggestions": "显示 {count} 个有建议的设置。", "split_intro": "点击图表添加或删除分割点,或通过空闲间隔自动检测。每个结果片段可获得自己的配置文件。", @@ -789,10 +794,10 @@ "toast_revert_failed": "还原失败:{error}", "toast_reverted": "已还原 {key}", "toast_save_failed": "保存失败:{error}", - "trend_duration_longer": "时长趋势增长({pct}%/周期)— 近期平均 {avg}", - "trend_duration_shorter": "时长趋势缩短({pct}%/周期)— 近期平均 {avg}", + "trend_duration_longer": "时长趋势增长({pct}%/周期)– 近期平均 {avg}", + "trend_duration_shorter": "时长趋势缩短({pct}%/周期)– 近期平均 {avg}", "trend_energy_down": "能耗趋势下降({pct}%/周期)", - "trend_energy_up": "能耗趋势上升({pct}%/周期)— 近期平均 {avg}", + "trend_energy_up": "能耗趋势上升({pct}%/周期)– 近期平均 {avg}", "trim_intro": "拖动红色控制柄,或输入值。窗口外的所有内容将被移除。", "tuning_suggestions_available": "来自观察周期的 {count} 个调整建议可用。它们显示在相关字段旁边。", "unsure_detected_prefix": "WashData 不确定是否检测到", @@ -857,7 +862,9 @@ "share_guidelines_title": "分享前须知", "store_download_device_intro": "将所有共享程序及其参考周期采用到您的设备。您自己录制的周期和统计数据不受影响。", "store_share_device_intro": "上传 {brand} {model} 及您选择的参考周期。拥有相同设备的其他用户可以采用您的程序。条目在公开前经过审核。", - "share_profile_no_cycles": "无参考周期 -- 请在\"周期\"选项卡中将周期标记为 ⭐ 以包含此配置文件" + "share_profile_no_cycles": "无参考周期 -- 请在\"周期\"选项卡中将周期标记为 ⭐ 以包含此配置文件", + "advisory_phase_inconsistent": "'{name}' 似乎混合了不同的程序或温度,各周期的加热时长差异很大。将其拆分为独立的配置文件(例如按温度区分)可以改善匹配和时间估算。", + "advisory_phase_inconsistent_title": "⚠ 可能混合了不同程序" }, "pg_desc": { "abrupt_drop_watts": "骤降视为立即结束", @@ -869,12 +876,19 @@ "start_duration_threshold": "确认启动所需的超阈值秒数", "start_threshold_w": "计为启动的最小瓦数", "stop_threshold_w": "低于此值机器计为关闭", - "dtw_bandwidth": "形状匹配允许的时间扭曲程度", - "corr_weight": "匹配中曲线形状与功率水平的权重", - "duration_weight": "运行时长的吻合程度对匹配的影响大小", - "energy_weight": "能耗吻合程度对匹配的影响大小", - "profile_match_min_duration_ratio": "仍可用于匹配的最短运行时长(相对于程序档案)", - "profile_match_max_duration_ratio": "仍可用于匹配的最长运行时长(相对于程序档案)" + "dtw_bandwidth": "Stage 3: Sakoe-Chiba 规整带宽 (0 = DTW 关闭; 默认 0.2 = 周期长度的 20%)", + "corr_weight": "Stage 2: 曲线形状(相关性)与功率水平(MAE)之间的平衡; 默认 0.45", + "duration_weight": "Stage 4: 运行时长一致性对最终得分的影响程度(默认 0.22)", + "energy_weight": "Stage 4: 能耗一致性对最终得分的影响程度(默认 0.22)", + "profile_match_min_duration_ratio": "Stage 1: 最短允许运行时长占档案时长的比例; 默认 0.1 (10%)", + "profile_match_max_duration_ratio": "Stage 1: 最长允许运行时长占档案时长的比例; 默认 1.5 (150%)", + "keep_min_score": "Stage 2: 保留在竞争中的最低得分; 默认 0.1 接受弱候选,后续阶段选出最佳", + "dtw_blend": "Stage 3: 0 = 仅核心得分,1 = 仅 DTW 得分,0.5 = 等权混合(默认)", + "dtw_ensemble_w": "Stage 3 (集成模式): 缩放 L1 对导数 DTW 的权重; 默认 0.7 偏重水平感知", + "dtw_ddtw_scale": "Stage 3: DDTW 半饱和距离; 越小对形状越敏感(默认 30)", + "dtw_refine_top_n": "Stage 3: DTW 重新评分的候选数量; 若正确档案排在第 4-5 位则调高至 7-9(默认 5)", + "duration_scale": "Stage 4: 时长一致性得分减半时的对数比率; 越小惩罚越严格(默认 0.175)", + "energy_scale": "Stage 4: 能耗一致性得分减半时的对数比率; 越小惩罚越严格(默认 0.25)" }, "phase_desc": { "anti_crease": "完成后偶尔进行短暂的翻滚以减少皱纹。", @@ -957,6 +971,10 @@ "triggers": { "intro": "可选的外部信号:结束触发器、门传感器、暂停开关和卸载提醒。", "label": "触发器与门" + }, + "phase_eta": { + "label": "剩余时间", + "intro": "阶段感知的剩余时间,适用于周期长度取决于温度或脱水的机器。" } }, "setting": { @@ -1040,6 +1058,10 @@ "doc": "当上面未设置实时价格实体时,每千瓦时的固定价格用于成本数据。", "label": "固定电价(每 kWh)" }, + "energy_sensor": { + "doc": "可选的累积电能计数器(total_increasing 的 kWh/Wh,例如插座自带的累计电能表)。设置后,每个周期报告的能耗将取自该计数器从开始到结束的差值(delta),从而避免因积分上报缓慢的功率传感器而导致的少计。若读数缺失、单位未知或差值不为正,则回退到积分值。留空则继续对功率传感器进行积分。", + "label": "电能表实体" + }, "expose_debug_entities": { "doc": "发布额外的诊断 HA 实体(匹配置信度、模糊性、状态内部)。关闭可保持实体列表干净以供正常使用。", "label": "暴露调试实体" @@ -1073,7 +1095,7 @@ "label": "最小功率" }, "ml_training_enabled": { - "doc": "定期隔夜研究您审查的循环,并针对该特定机器微调模型。只有当改变在持续的周期中真正取得更好的成绩时,才会保留改变,因此这只能有所帮助或保持不变——永远不会倒退。", + "doc": "定期隔夜研究您审查的循环,并针对该特定机器微调模型。只有当改变在持续的周期中真正取得更好的成绩时,才会保留改变,因此这只能有所帮助或保持不变––永远不会倒退。", "label": "从此机器学习" }, "ml_training_hour": { @@ -1322,6 +1344,38 @@ }, "show_contributor": { "doc": "在社区家电和参考周期上显示\"by \"的贡献者署名。" + }, + "enable_phase_matching": { + "label": "阶段感知的剩余时间", + "doc": "将每个运行中的周期分解为多个阶段(加热、洗涤、脱水),按阶段分配剩余时间,并与经典估算相融合:周期早期更依赖阶段分配,接近结束时更依赖经典估算。这会根据您的机器实际加热和运行的时长来个性化倒计时,在周期的前半段效果最明显。关闭 = 仅使用经典估算。仅影响剩余时间显示;程序匹配和周期检测保持不变。" + }, + "keep_min_score": { + "label": "最低匹配得分", + "doc": "候选档案留在匹配竞争中所需的最低相似度得分。默认 0.1 有意设置得较宽松,允许弱候选也进入,由后续阶段找出最佳匹配。提高此值可更早淘汰不合适的档案;降低此值可扩大初始候选池。" + }, + "dtw_blend": { + "label": "规整混合", + "doc": "时间规整(DTW)对齐得分替换 Stage 2 核心得分的程度。0 = 仅使用 Stage 2 得分,1 = 仅使用 DTW 得分,0.5(默认)= 等权混合。当程序功率水平相近但时序模式不同时,可提高此值以更多依赖规整对齐。" + }, + "dtw_ensemble_w": { + "label": "规整集成混合", + "doc": "在集成 DTW 模式(默认)下,缩放 L1 与导数 DTW(DDTW)的权重分配。1.0 = 全部缩放-L1,0 = 全部 DDTW,0.7 = 默认。缩放-L1 变体比较功率水平;导数变体对功率如何随时间变化做出响应。集成模式将二者结合。" + }, + "dtw_ddtw_scale": { + "label": "导数规整尺度", + "doc": "导数 DTW 评分的半饱和距离。当 DDTW 距离等于此值时,得分减半。越小对形状差异越敏感。默认 30 针对典型家电功率曲线进行了校准。仅影响集成和 ddtw DTW 模式。" + }, + "dtw_refine_top_n": { + "label": "规整精化数量", + "doc": "DTW 重新评分的 Stage 2 顶部候选数量。由于 DTW 计算代价较高,仅对前 N 个候选应用。默认 5。若正确档案有时在 Stage 2 后只排到第 4 或第 5 位,可调高此值(至 7-9),DTW 能将其拉回。降低此值可略微加速匹配。" + }, + "duration_scale": { + "label": "时长锐度", + "doc": "Stage 4 时长一致性惩罚的锐度。这是一致性得分减半时的对数比率。越小越严格:时长不匹配的惩罚越大。默认 0.175 对应半权重下约 18% 的时长容差。请与时长权重配合使用。" + }, + "energy_scale": { + "label": "能耗锐度", + "doc": "Stage 4 能耗一致性惩罚的锐度。越小越严格:能耗不匹配的惩罚越大。默认 0.25 有意比时长锐度更宽松,因为能耗会随负荷变化。请与能耗权重配合使用。" } }, "setting_group": { @@ -1570,24 +1624,24 @@ "lower": "自动标记更多循环,部分可能被误识别" }, "duration_tolerance": { - "higher": "接受更宽的时长范围——匹配更灵活", - "lower": "要求时长更接近——更严格,但可能拒绝非常规循环" + "higher": "接受更宽的时长范围––匹配更灵活", + "lower": "要求时长更接近––更严格,但可能拒绝非常规循环" }, "end_energy_threshold": { "higher": "仅关闭消耗大量能量的循环", "lower": "也关闭较短或低能耗的循环" }, "end_repeat_count": { - "higher": "结束信号需重复更多次——更稳健,但略慢", - "lower": "更少重复后结束——检测更快,但误结束风险更高" + "higher": "结束信号需重复更多次––更稳健,但略慢", + "lower": "更少重复后结束––检测更快,但误结束风险更高" }, "learning_confidence": { - "higher": "仅从高置信度匹配中学习——较慢但更可靠", + "higher": "仅从高置信度匹配中学习––较慢但更可靠", "lower": "从更多循环中学习,模型可能更嘈杂" }, "min_off_gap": { "higher": "新循环开始前需要更长的空闲时间", - "lower": "短暂空闲即触发新循环——连续运行可能被拆分" + "lower": "短暂空闲即触发新循环––连续运行可能被拆分" }, "min_power": { "higher": "对待机功耗不那么敏感,可能遗漏非常安静的程序", @@ -1598,24 +1652,24 @@ "lower": "更快地强制终止卡住的循环" }, "off_delay": { - "higher": "为设备暂停留出更多时间——适合长时间浸泡或烘干阶段", - "lower": "更快检测结束——适合干净的开关设备" + "higher": "为设备暂停留出更多时间––适合长时间浸泡或烘干阶段", + "lower": "更快检测结束––适合干净的开关设备" }, "profile_match_threshold": { "higher": "仅提交高置信度匹配,更多循环保持未标记", "lower": "匹配更多循环,部分可能略有偏差" }, "running_dead_zone": { - "higher": "忽略待机时的短暂功率尖峰——对噪声不那么敏感", - "lower": "响应更短的功率突发——捕捉快速瞬态活动" + "higher": "忽略待机时的短暂功率尖峰––对噪声不那么敏感", + "lower": "响应更短的功率突发––捕捉快速瞬态活动" }, "start_threshold_w": { "higher": "避免因短暂功率尖峰引起的误启动", "lower": "捕捉低功率程序,可能对噪声触发" }, "stop_threshold_w": { - "higher": "更快结束循环——可能在暂停时关闭", - "lower": "等待更长时间结束——减少提前完成" + "higher": "更快结束循环––可能在暂停时关闭", + "lower": "等待更长时间结束––减少提前完成" }, "watchdog_interval": { "higher": "允许更长的静默阶段,减少误强制停止", @@ -1766,6 +1820,69 @@ }, "pg_sweep": { "optimize": "优化:{param}" + }, + "pg_detail": { + "simulate": "模拟周期" + }, + "split": { + "apply": "正在拆分周期" + }, + "trim": { + "apply": "正在裁剪周期" + }, + "merge": { + "apply": "正在合并周期" + }, + "rebuild": { + "envelopes": "正在重建包络" + }, + "cancelling": "正在取消..." + }, + "setup": { + "hdr": { + "card": "设备设置", + "healthy_chip": "设置完成" + }, + "phase0": { + "washer": "WashData 已在检测您的周期。录制第一个周期以启用程序名称和时间估算。", + "dishwasher": "WashData 正在监视。洗碗机的周期较为复杂 – 强烈建议录制第一个周期。如果检测到的周期运行过长,请使用周期编辑器在保存为配置文件前进行裁剪。", + "generic": "WashData 正在监视。录制或标记检测到的周期,开始构建配置文件。" + }, + "phase1a": { + "labelled": "良好的开始 – 您的第一个程序已保存。为获得最准确的数据,建议使用录制小部件录制下一个周期。" + }, + "phase1b": { + "recorded": "您的录制已保存为 {profile_name}。现在录制或标记您的其他常用程序以建立覆盖范围。" + }, + "phase1c": { + "verify": "您有来自社区的 {count} 个程序。运行一个周期以验证 WashData 是否正确识别 – 随着设备积累自己的历史记录,匹配将不断改善。" + }, + "phase2": { + "cluster": "WashData 发现了 {count} 个与任何已保存程序不匹配的周期 – 它们彼此相似。想为这些周期创建新配置文件吗?", + "unmatched": "您的上一个周期与任何已保存的程序不匹配。是新程序吗?" + }, + "phase3": { + "suggestions": "WashData 根据您的周期历史记录提供了设置建议 – 请查看以提高检测精度。", + "groups": "您的部分配置文件看起来像在不同温度下运行的同一程序。将它们整理成组以获得更好的匹配效果。", + "phases": "为 {profile_name} 添加程序阶段,以获得更准确的剩余时间估算。" + }, + "phase4": { + "healthy": "此设备已完全设置好({profile_count} 个配置文件)。" + }, + "cta": { + "start_recording": "开始录制", + "label_detected_cycle": "我已有检测到的周期 – 直接标记", + "browse_cycles": "浏览周期以标记其他", + "view_profiles": "查看您的配置文件", + "create_from_cluster": "从这些周期创建配置文件", + "create_profile": "为此创建配置文件", + "review_suggestions": "查看建议", + "organise_profiles": "整理配置文件", + "configure_phases": "配置阶段", + "skip_step": "跳过此步骤", + "skip_forever": "不再显示", + "hide_guidance": "隐藏指南", + "show_guidance": "显示指南" } } } diff --git a/custom_components/ha_washdata/ws_api.py b/custom_components/ha_washdata/ws_api.py index 6cbd820..ab4517a 100644 --- a/custom_components/ha_washdata/ws_api.py +++ b/custom_components/ha_washdata/ws_api.py @@ -40,9 +40,12 @@ from .const import ( CONF_COMPLETION_MIN_SECONDS, CONF_DEVICE_TYPE, CONF_DOOR_SENSOR_ENTITY, + CONF_ANTI_WRINKLE_EXIT_POWER, + CONF_ANTI_WRINKLE_MAX_POWER, CONF_DURATION_TOLERANCE, CONF_END_ENERGY_THRESHOLD, CONF_END_REPEAT_COUNT, + CONF_ENERGY_SENSOR, CONF_EXTERNAL_END_TRIGGER, CONF_LEARNING_CONFIDENCE, CONF_LINKED_DEVICE, @@ -57,7 +60,9 @@ from .const import ( CONF_PROFILE_MATCH_MIN_DURATION_RATIO, CONF_PROFILE_MATCH_THRESHOLD, CONF_PROFILE_MIN_WARMUP_CYCLES, + CONF_PROFILE_UNMATCH_THRESHOLD, CONF_PUMP_STUCK_DURATION, + CONF_POWER_OFF_THRESHOLD_W, CONF_RUNNING_DEAD_ZONE, CONF_SAMPLING_INTERVAL, CONF_SMOOTHING_WINDOW, @@ -82,6 +87,7 @@ from .const import ( from . import playground from . import task_registry from .cycle_detector import CycleDetectorConfig +from .setup_advisor import compute_setup_phase from .ws_schema import WS_OPEN_RESPONSES, WS_RESPONSE_TYPES _LOGGER = logging.getLogger(__name__) @@ -175,6 +181,12 @@ _SUGGESTION_KEYS: tuple[str, ...] = ( CONF_LEARNING_CONFIDENCE, CONF_PROFILE_MATCH_THRESHOLD, CONF_END_REPEAT_COUNT, + # Device-class-specific suggestions from reconcile_suggestions + CONF_PROFILE_UNMATCH_THRESHOLD, + CONF_POWER_OFF_THRESHOLD_W, + CONF_ANTI_WRINKLE_EXIT_POWER, + CONF_ANTI_WRINKLE_MAX_POWER, + CONF_PUMP_STUCK_DURATION, ) # Suggestion keys coerced to int when applied (mirrors the OptionsFlow). @@ -188,6 +200,7 @@ _SUGGESTION_INT_KEYS: frozenset[str] = frozenset({ CONF_SMOOTHING_WINDOW, CONF_COMPLETION_MIN_SECONDS, CONF_END_REPEAT_COUNT, + CONF_PUMP_STUCK_DURATION, }) @@ -288,8 +301,15 @@ async def _recorder_power(hass: HomeAssistant, entity_id: str, start_dt: Any) -> def _cycle_kwh(c: dict[str, Any]) -> float | None: - """Cycle energy in kWh. Cycles store energy as ``energy_wh``; convert.""" - wh = c.get("energy_wh") + """Cycle energy in kWh for display. + + Prefers the external meter's reading (``energy_meter_wh``, issue #316) when a + cycle recorded one, otherwise the integrated ``energy_wh``. Cycles store energy + in Wh; convert to kWh. + """ + wh = c.get("energy_meter_wh") + if wh is None: + wh = c.get("energy_wh") if wh is not None: try: return round(float(wh) / 1000.0, 4) @@ -449,7 +469,6 @@ _ADMIN_COMMANDS = frozenset({ # with get_, so it is whitelisted here to gate at the 'read' level. _READ_WRITE_COMMANDS = frozenset({ "set_program", - "run_playground_simulation", "run_playground_cycle_detail", "run_playground_history", "run_playground_sweep", @@ -461,6 +480,7 @@ _READ_WRITE_COMMANDS = frozenset({ "get_task_result", "start_playground_history", "start_playground_sweep", + "start_playground_cycle_detail", # Community store: read-only browse is read-level (writes below default to 'edit'). "store_status", "store_search_devices", @@ -1030,6 +1050,7 @@ def async_register_commands(hass: HomeAssistant) -> None: ws_get_devices, ws_get_device_cycles, # Settings ws_get_options, ws_set_options, ws_get_settings_changelog, + ws_get_setup_status, # Profiles ws_get_profiles, ws_create_profile, ws_rename_profile, ws_delete_profile, ws_rebuild_envelopes, ws_get_profile_phases, ws_set_profile_phases, @@ -1076,8 +1097,8 @@ def async_register_commands(hass: HomeAssistant) -> None: ws_revert_ml_models, # Cycle controls (pause / resume / force-stop) ws_pause_cycle, ws_resume_cycle, ws_terminate_cycle, - # Playground (F3): headless what-if replay + DTW visualizer - ws_run_playground_simulation, ws_get_dtw_debug, + # Playground (F3): DTW visualizer + ws_get_dtw_debug, # Playground redesign: faithful single-cycle sim + history table + sweep ws_run_playground_cycle_detail, ws_run_playground_history, ws_run_playground_sweep, @@ -1085,6 +1106,7 @@ def async_register_commands(hass: HomeAssistant) -> None: ws_list_tasks, ws_subscribe_tasks, ws_cancel_task, ws_get_task_result, # Playground batch/sweep as detached registry-tracked tasks ws_start_playground_history, ws_start_playground_sweep, + ws_start_playground_cycle_detail, # Community store (online features): status/connect/disconnect/browse/import/upload ws_store_status, ws_store_connect, ws_store_disconnect, ws_store_search_devices, ws_store_get_profiles, ws_store_get_cycles, @@ -1319,6 +1341,7 @@ async def ws_set_options( CONF_DOOR_SENSOR_ENTITY, CONF_LINKED_DEVICE, CONF_SWITCH_ENTITY, + CONF_ENERGY_SENSOR, ): if key in new_options and not new_options[key]: new_options[key] = None @@ -1413,6 +1436,79 @@ async def ws_get_settings_changelog( _send_result(connection, msg["id"], "get_settings_changelog", {"changelog": changelog}) +@websocket_api.websocket_command( + { + vol.Required("type"): "ha_washdata/get_setup_status", + vol.Required("entry_id"): str, + } +) +@websocket_api.async_response +async def ws_get_setup_status( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Return the current setup phase for the adoption guidance card.""" + manager = _get_manager(hass, msg["entry_id"]) + if not manager: + connection.send_error(msg["id"], "not_found", "Device not found") + return + + # Gather user's skipped steps from user prefs + skipped_steps: dict[str, str | None] = {} + user = getattr(connection, "user", None) + if user: + holder = hass.data.get(_PANEL_DATA_KEY) + if holder: + prefs = holder["data"].get("prefs", {}).get(user.id, {}) + for k, v in prefs.items(): + if k.startswith("setup_skip_"): + skipped_steps[k] = v + + # Gather store data (executor-safe reads) + store = manager.profile_store + profile_names = list(store._data.get("profiles", {}).keys()) + past_cycles = store._data.get("past_cycles", []) + ref_names: set[str] = set() + for rc in store._data.get("reference_cycles", []): + if rc.get("profile_name"): + ref_names.add(rc["profile_name"]) + + coverage_gap = await hass.async_add_executor_job(store.suggest_coverage_gaps) + # suggestions: read from the store (cheap dict lookup; heavy computation happens + # in the SuggestionEngine background task, not here). + suggestions = list((store.get_suggestions() or {}).values()) + pg_data = store._data.get("profile_groups", {}) + pending_groups = (pg_data.get("suggestions") or []) if isinstance(pg_data, dict) else [] + + device_type = manager.device_type + + result = compute_setup_phase( + device_type=device_type, + profile_names=profile_names, + past_cycles=past_cycles, + ref_profile_names=ref_names, + coverage_gap=coverage_gap, + suggestions=suggestions, + profile_groups=pending_groups, + skipped_steps=skipped_steps, + now=dt_util.now(), + ) + + _send_result(connection, msg["id"], "get_setup_status", { + "phase": result.phase, + "message_key": result.message_key, + "message_params": result.message_params, + "cta_label_key": result.cta_label_key, + "cta_action": result.cta_action, + "secondary_label_key": result.secondary_label_key, + "secondary_action": result.secondary_action, + "skippable": result.skippable, + "dismissible": result.dismissible, + "step_key": result.step_key, + }) + + # ─── Profiles ───────────────────────────────────────────────────────────────── @websocket_api.websocket_command( @@ -1721,24 +1817,67 @@ async def ws_delete_profile_group( @websocket_api.websocket_command( {vol.Required("type"): "ha_washdata/rebuild_envelopes", vol.Required("entry_id"): str} ) -@websocket_api.async_response -async def ws_rebuild_envelopes( +@callback +def ws_rebuild_envelopes( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Rebuild power-profile envelopes for all profiles.""" + """Rebuild power-profile envelopes for all profiles. + + Runs as a detached, registry-tracked task that rebuilds one profile per step + (each DTW pass already offloads to the executor): rebuilding every profile + serially inside the WS request stalled low-power hosts for the whole run + (issue #311). Progress/result come via the task registry.""" entry_id: str = msg["entry_id"] - manager = _get_manager(hass, entry_id) - if manager is None: + if _get_manager(hass, entry_id) is None: _err_not_found(connection, msg["id"], entry_id) return + reg = task_registry.get_registry(hass) + task = reg.create( + entry_id, "rebuild", "Rebuilding envelopes", + label_key="task.rebuild.envelopes", label_params={}, + ) + hass.async_create_task(_rebuild_envelopes_task(hass, task, entry_id)) + _send_result(connection, msg["id"], "rebuild_envelopes", {"task_id": task.id}) + +async def _rebuild_envelopes_task(hass: HomeAssistant, task: Any, entry_id: str) -> None: + """Detached runner: rebuild every profile's envelope one at a time, reporting + progress and honouring cancel, so the loop breathes between profiles.""" + reg = task_registry.get_registry(hass) + manager = _get_manager(hass, entry_id) + if manager is None: + reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") + return + store = manager.profile_store + lock = _entry_write_lock(hass, entry_id) + await lock.acquire() try: - await manager.profile_store.async_rebuild_all_envelopes() - _send_result(connection, msg["id"], "rebuild_envelopes", {"success": True}) + names = list(store.get_profiles().keys()) + reg.update(task, total=len(names), done=0) + rebuilt = 0 + for i, name in enumerate(names): + if task.cancel_requested: + break + try: + if await store.async_rebuild_envelope(name): + rebuilt += 1 + except Exception as exc: # pylint: disable=broad-exception-caught + _LOGGER.debug("Envelope rebuild failed for %s/%s: %s", entry_id, name, exc) + reg.update(task, done=i + 1) + if _get_manager(hass, entry_id) is manager: + manager.notify_update() + reg.finish( + task, + state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, + result={"success": True, "rebuilt": rebuilt}, + ) except Exception as exc: # pylint: disable=broad-exception-caught - connection.send_error(msg["id"], "unknown_error", str(exc)) + _LOGGER.warning("Rebuild-envelopes task failed for %s: %s", entry_id, exc) + reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) + finally: + lock.release() @websocket_api.websocket_command( @@ -2814,6 +2953,21 @@ def ws_get_constants( ] from .const import STORE_WEB_ORIGIN from . import store_account + from .const import ( # pylint: disable=import-outside-toplevel + DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO, + DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO, + DEFAULT_DTW_BANDWIDTH, + MATCH_CORR_WEIGHT, + MATCH_KEEP_MIN_SCORE, + MATCH_DTW_BLEND, + MATCH_DTW_ENSEMBLE_W, + MATCH_DDTW_DIST_SCALE, + MATCH_DTW_REFINE_TOP_N, + MATCH_DURATION_WEIGHT, + MATCH_ENERGY_WEIGHT, + MATCH_DURATION_SCALE, + MATCH_ENERGY_SCALE, + ) _send_result(connection, msg["id"], "get_constants", { "device_types": device_types, "state_colors": dict(STATE_COLORS), @@ -2821,6 +2975,24 @@ def ws_get_constants( "ml_suggestions_enabled": ENABLE_ML_SUGGESTIONS, "ml_training_available": ENABLE_ML_TRAINING, "PROFILE_MIN_WARMUP_CYCLES": CONF_PROFILE_MIN_WARMUP_CYCLES, + # Canonical matcher defaults for the Playground's matcher-param fields, so + # the panel's _PG_MATCH_DEFAULTS table cannot silently drift from const.py. + # The panel keeps that table only as an offline fallback. + "pg_match_defaults": { + "profile_match_min_duration_ratio": DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO, + "profile_match_max_duration_ratio": DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO, + "corr_weight": MATCH_CORR_WEIGHT, + "keep_min_score": MATCH_KEEP_MIN_SCORE, + "dtw_bandwidth": DEFAULT_DTW_BANDWIDTH, + "dtw_blend": MATCH_DTW_BLEND, + "dtw_ensemble_w": MATCH_DTW_ENSEMBLE_W, + "dtw_ddtw_scale": MATCH_DDTW_DIST_SCALE, + "dtw_refine_top_n": MATCH_DTW_REFINE_TOP_N, + "duration_weight": MATCH_DURATION_WEIGHT, + "energy_weight": MATCH_ENERGY_WEIGHT, + "duration_scale": MATCH_DURATION_SCALE, + "energy_scale": MATCH_ENERGY_SCALE, + }, # Community store: the panel opens /connect.html for the GitHub # handoff and validates postMessage against new URL(origin).origin. "store_online_available": True, @@ -3082,32 +3254,61 @@ async def ws_get_cycle_power_data( vol.Required("end_s"): vol.Coerce(float), } ) -@websocket_api.async_response -async def ws_trim_cycle( +@callback +def ws_trim_cycle( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Trim a cycle's power data to the [start_s, end_s] offset window.""" + """Trim a cycle's power data to the [start_s, end_s] offset window. + + Runs as a detached, registry-tracked task (returns a task_id immediately): the + trim recomputes the signature and rebuilds the profile envelope, which stalls + low-power hosts if held on the event loop for the whole WS request (issue #311). + Progress/result come via subscribe_tasks/get_task_result.""" entry_id: str = msg["entry_id"] - manager = _get_manager(hass, entry_id) - if manager is None: + if _get_manager(hass, entry_id) is None: _err_not_found(connection, msg["id"], entry_id) return + reg = task_registry.get_registry(hass) + task = reg.create( + entry_id, "trim", "Trimming cycle", label_key="task.trim.apply", label_params={}, + ) + hass.async_create_task(_trim_task( + hass, task, entry_id, msg["cycle_id"], float(msg["start_s"]), float(msg["end_s"]), + )) + _send_result(connection, msg["id"], "trim_cycle", {"task_id": task.id}) + +async def _trim_task( + hass: HomeAssistant, task: Any, entry_id: str, + cycle_id: str, start_s: float, end_s: float, +) -> None: + """Detached runner for a cycle trim (recompute + single-profile envelope + rebuild). Serialized under the per-entry write lock like reprocess.""" + reg = task_registry.get_registry(hass) + manager = _get_manager(hass, entry_id) + if manager is None: + reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") + return + store = manager.profile_store + lock = _entry_write_lock(hass, entry_id) + await lock.acquire() try: - ok = await manager.profile_store.trim_cycle_power_data( - msg["cycle_id"], float(msg["start_s"]), float(msg["end_s"]) - ) - if ok: + reg.update(task, total=1, done=0) + ok = await store.trim_cycle_power_data(cycle_id, start_s, end_s) + reg.update(task, done=1) + if not ok: + reg.finish(task, state=task_registry.STATE_ERROR, error="trim_failed") + return + if _get_manager(hass, entry_id) is manager: manager.notify_update() - _send_result(connection, msg["id"], "trim_cycle", {"success": True}) - else: - connection.send_error( - msg["id"], "trim_failed", "Trim produced no data or cycle not found" - ) + reg.finish(task, state=task_registry.STATE_DONE, result={"success": True}) except Exception as exc: # pylint: disable=broad-exception-caught - connection.send_error(msg["id"], "unknown_error", str(exc)) + _LOGGER.warning("Trim task failed for %s: %s", entry_id, exc) + reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) + finally: + lock.release() @websocket_api.websocket_command( @@ -3173,13 +3374,19 @@ async def ws_analyze_split( vol.Optional("segment_profiles"): list, } ) -@websocket_api.async_response -async def ws_apply_split( +@callback +def ws_apply_split( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Split a cycle at the given offsets, optionally labeling each segment.""" + """Split a cycle at the given offsets, optionally labeling each segment. + + Cheap validation (cycle exists, at least two segments) runs synchronously so a + bad request fails fast; the heavy apply (per-segment extraction + affected + envelope rebuilds + save) then runs as a detached, registry-tracked task so it + never holds the event loop for the whole request on low-power hosts (issue + #311). Progress/result come via subscribe_tasks/get_task_result.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: @@ -3188,40 +3395,69 @@ async def ws_apply_split( store = manager.profile_store cycle_id: str = msg["cycle_id"] - try: - cycle = next( - (c for c in store.get_past_cycles() if c.get("id") == cycle_id), None + cycle = next( + (c for c in store.get_past_cycles() if c.get("id") == cycle_id), None + ) + if not cycle: + connection.send_error(msg["id"], "not_found", f"Cycle {cycle_id!r} not found") + return + + offsets = [float(o) for o in msg["split_offsets"]] + seg_bounds = store.build_split_segments_from_offsets(cycle, offsets) + if len(seg_bounds) < 2: + connection.send_error( + msg["id"], + "split_failed", + "Split points did not produce at least two segments", ) - if not cycle: - connection.send_error(msg["id"], "not_found", f"Cycle {cycle_id!r} not found") - return + return - offsets = [float(o) for o in msg["split_offsets"]] - seg_bounds = store.build_split_segments_from_offsets(cycle, offsets) - if len(seg_bounds) < 2: - connection.send_error( - msg["id"], - "split_failed", - "Split points did not produce at least two segments", - ) - return + profiles = msg.get("segment_profiles") or [] + segments: list[dict[str, Any]] = [] + for i, (seg_start, seg_end) in enumerate(seg_bounds): + prof = profiles[i] if i < len(profiles) else None + if prof in ("", "none", "__none__"): + prof = None + segments.append( + {"start": float(seg_start), "end": float(seg_end), "profile": prof} + ) - profiles = msg.get("segment_profiles") or [] - segments: list[dict[str, Any]] = [] - for i, (seg_start, seg_end) in enumerate(seg_bounds): - prof = profiles[i] if i < len(profiles) else None - if prof in ("", "none", "__none__"): - prof = None - segments.append( - {"start": float(seg_start), "end": float(seg_end), "profile": prof} - ) + reg = task_registry.get_registry(hass) + task = reg.create( + entry_id, "split", "Splitting cycle", label_key="task.split.apply", label_params={}, + ) + hass.async_create_task(_apply_split_task(hass, task, entry_id, cycle_id, segments)) + _send_result(connection, msg["id"], "apply_split", {"task_id": task.id}) + +async def _apply_split_task( + hass: HomeAssistant, task: Any, entry_id: str, + cycle_id: str, segments: list[dict[str, Any]], +) -> None: + """Detached runner for a cycle split. Rebuilds only the affected profiles' + envelopes (done inside apply_split_interactive). Serialized under the per-entry + write lock like reprocess.""" + reg = task_registry.get_registry(hass) + manager = _get_manager(hass, entry_id) + if manager is None: + reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") + return + store = manager.profile_store + lock = _entry_write_lock(hass, entry_id) + await lock.acquire() + try: + reg.update(task, total=1, done=0) new_ids = await store.apply_split_interactive(cycle_id, segments) - await store.async_rebuild_all_envelopes() - manager.notify_update() - _send_result(connection, msg["id"], "apply_split", {"success": True, "new_ids": new_ids}) + reg.update(task, done=1) + if _get_manager(hass, entry_id) is manager: + manager.notify_update() + reg.finish( + task, state=task_registry.STATE_DONE, + result={"success": True, "new_ids": new_ids}, + ) except Exception as exc: # pylint: disable=broad-exception-caught - connection.send_error(msg["id"], "unknown_error", str(exc)) + _LOGGER.warning("Apply-split task failed for %s: %s", entry_id, exc) + reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) @websocket_api.websocket_command( @@ -3233,20 +3469,24 @@ async def ws_apply_split( vol.Optional("new_profile_name"): vol.Any(str, None), } ) -@websocket_api.async_response -async def ws_apply_merge( +@callback +def ws_apply_merge( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: - """Merge two or more cycles into one, optionally labeling the result.""" + """Merge two or more cycles into one, optionally labeling the result. + + Cheap validation runs synchronously; the merge (gap-fill + signature recompute + + affected-profile envelope rebuilds + save) then runs as a detached, + registry-tracked task so it never holds the event loop for the whole request + on low-power hosts (issue #311). Progress/result via the task registry.""" entry_id: str = msg["entry_id"] manager = _get_manager(hass, entry_id) if manager is None: _err_not_found(connection, msg["id"], entry_id) return - store = manager.profile_store ids: list[str] = msg["cycle_ids"] if len(ids) < 2: connection.send_error( @@ -3255,29 +3495,69 @@ async def ws_apply_merge( return target = msg.get("target_profile") - try: - if target == "__create_new__": - name = (msg.get("new_profile_name") or "").strip() - if not name: - connection.send_error( - msg["id"], "invalid_format", "New profile name required" - ) - return - await store.create_profile_standalone(name) - target = name - elif target in ("", "none", "__none__"): - target = None - - new_id = await store.apply_merge_interactive(ids, target) - if not new_id: - connection.send_error(msg["id"], "merge_failed", "Cycles could not be merged") + new_name: str | None = None + if target == "__create_new__": + new_name = (msg.get("new_profile_name") or "").strip() + if not new_name: + connection.send_error(msg["id"], "invalid_format", "New profile name required") return + elif target in ("", "none", "__none__"): + target = None - await store.async_rebuild_all_envelopes() - manager.notify_update() - _send_result(connection, msg["id"], "apply_merge", {"success": True, "new_id": new_id}) + reg = task_registry.get_registry(hass) + task = reg.create( + entry_id, "merge", "Merging cycles", label_key="task.merge.apply", label_params={}, + ) + hass.async_create_task(_apply_merge_task(hass, task, entry_id, ids, target, new_name)) + _send_result(connection, msg["id"], "apply_merge", {"task_id": task.id}) + + +async def _apply_merge_task( + hass: HomeAssistant, task: Any, entry_id: str, + ids: list[str], target: str | None, new_name: str | None, +) -> None: + """Detached runner for a cycle merge. Rebuilds only the affected profiles' + envelopes (done inside apply_merge_interactive). Serialized under the per-entry + write lock like reprocess.""" + reg = task_registry.get_registry(hass) + manager = _get_manager(hass, entry_id) + if manager is None: + reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") + return + store = manager.profile_store + lock = _entry_write_lock(hass, entry_id) + await lock.acquire() + try: + reg.update(task, total=1, done=0) + created_new = False + if new_name: + # Raises ValueError if the name already exists, so reaching the next + # line guarantees we created a brand-new (empty) profile that must be + # rolled back if the merge below fails. + await store.create_profile_standalone(new_name) + created_new = True + target = new_name + new_id = await store.apply_merge_interactive(ids, target) + reg.update(task, done=1) + if not new_id: + if created_new and new_name: + # Merge rejected the cycle set (stale/invalid id, unparseable start + # time, ...). Delete the empty profile we just created so a failed + # merge doesn't orphan a cycle-less profile in the store. + await store.delete_profile(new_name, unlabel_cycles=False) + reg.finish(task, state=task_registry.STATE_ERROR, error="merge_failed") + return + if _get_manager(hass, entry_id) is manager: + manager.notify_update() + reg.finish( + task, state=task_registry.STATE_DONE, + result={"success": True, "new_id": new_id}, + ) except Exception as exc: # pylint: disable=broad-exception-caught - connection.send_error(msg["id"], "unknown_error", str(exc)) + _LOGGER.warning("Apply-merge task failed for %s: %s", entry_id, exc) + reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) + finally: + lock.release() # ─── Profile envelope / member cycles ────────────────────────────────────────── @@ -3492,6 +3772,13 @@ async def ws_set_user_prefs( cur.pop("lang_override", None) # empty clears -> system default elif isinstance(lang, str) and _PREF_LANG_TAG_RE.match(lang): cur["lang_override"] = lang + # Allow setup guidance skip keys: setup_skip_ -> "never" | ISO timestamp + for k, v in p.items(): + if isinstance(k, str) and k.startswith("setup_skip_"): + if v is None: + cur.pop(k, None) + elif v == "never" or (isinstance(v, str) and len(v) <= 40): + cur[k] = v prefs[user.id] = cur await _save_panel_data(hass) _send_result(connection, msg["id"], "set_user_prefs", {"success": True}) @@ -4567,56 +4854,6 @@ def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig: ) -@websocket_api.websocket_command( - { - vol.Required("type"): "ha_washdata/run_playground_simulation", - vol.Required("entry_id"): str, - vol.Optional("cycle_ids", default=list): [str], - vol.Optional("settings_override", default=dict): dict, - vol.Optional("concurrency", default=1): vol.Coerce(int), - } -) -@websocket_api.async_response -async def ws_run_playground_simulation( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Replay stored cycles through a headless detector with overridden settings. - - Returns ``{results: [...], summary: {...}}`` - a per-cycle event log + - outcome plus aggregate counts. Nothing is persisted; this is a pure what-if. - """ - entry_id: str = msg["entry_id"] - manager = _get_manager(hass, entry_id) - if manager is None: - _err_not_found(connection, msg["id"], entry_id) - return - - store = getattr(manager, "profile_store", None) - if store is None: - connection.send_error(msg["id"], "unavailable", "Profile store unavailable") - return - - try: - base_config = _playground_base_config(manager, _get_entry(hass, entry_id)) - cycle_ids = list(msg.get("cycle_ids") or []) - settings_override = dict(msg.get("settings_override") or {}) - concurrency = int(msg.get("concurrency", 1)) - payload = await hass.async_add_executor_job( - playground.run_playground_batch, - store, - cycle_ids, - base_config, - settings_override, - concurrency, - ) - _send_result(connection, msg["id"], "run_playground_simulation", payload) - except Exception as exc: # pylint: disable=broad-exception-caught - _LOGGER.debug("Playground simulation failed for %s: %s", entry_id, exc) - connection.send_error(msg["id"], "unknown_error", str(exc)) - - def _playground_context(hass: HomeAssistant, entry_id: str): """Return (manager, store, base_config, options, price) for a Playground call, or None (after sending the appropriate error) when unavailable.""" @@ -5108,3 +5345,88 @@ def ws_start_playground_sweep( msg["objective"], param_y, list(values_y) if values_y else None, )) _send_result(connection, msg["id"], "start_playground_sweep", {"task_id": task.id}) + + +# Readings replayed per executor job for the single-cycle detail sim. The event +# loop breathes between chunks; a ~233min/5s dishwasher cycle (~2800 readings) +# becomes ~11 short jobs instead of one multi-minute GIL-holding call (issue #311). +_PG_DETAIL_CHUNK = 250 + + +async def _pg_detail_task( + hass: HomeAssistant, task: Any, entry_id: str, + cycle_id: str, override: dict[str, Any] | None, +) -> None: + reg = task_registry.get_registry(hass) + ctx = _playground_context(hass, entry_id) + if ctx is None: + reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable") + return + _manager, store, base_config, options, price = ctx + try: + sim = await hass.async_add_executor_job( + playground.build_cycle_detail_sim_by_id, + store, cycle_id, base_config, override, options, price, + ) + if isinstance(sim, dict): # {"error": ...} marker (not_found / build failure) + if sim.get("error") == "not_found": + reg.finish(task, state=task_registry.STATE_ERROR, error="not_found") + else: + reg.finish(task, state=task_registry.STATE_ERROR, error=str(sim.get("error"))) + return + if not sim.ready: + reg.finish(task, state=task_registry.STATE_DONE, result=sim.empty_payload()) + return + total = sim.n_readings + reg.update(task, total=total) + for i in range(0, total, _PG_DETAIL_CHUNK): + if task.cancel_requested: + break + await hass.async_add_executor_job(sim.step, i, i + _PG_DETAIL_CHUNK) + reg.update(task, done=min(total, i + _PG_DETAIL_CHUNK)) + if not task.cancel_requested: + await hass.async_add_executor_job(sim.run_tail) + payload = await hass.async_add_executor_job(sim.finalize) + payload["partial"] = task.cancel_requested + reg.finish( + task, + state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, + result=payload, + ) + except Exception as exc: # pylint: disable=broad-exception-caught + _LOGGER.debug("Playground detail task failed for %s: %s", entry_id, exc) + reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "ha_washdata/start_playground_cycle_detail", + vol.Required("entry_id"): str, + vol.Required("cycle_id"): str, + vol.Optional("settings_override", default=dict): dict, + } +) +@callback +def ws_start_playground_cycle_detail( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Kick off a detached, registry-tracked single-cycle "Simulate" replay; + returns the task id immediately. The heavy per-5s replay runs chunk-by-chunk + in the executor so a long cycle no longer stalls Home Assistant (issue #311). + Progress/result come via subscribe_tasks/get_task_result.""" + entry_id = msg["entry_id"] + if _playground_context(hass, entry_id) is None: + _err_not_found(connection, msg["id"], entry_id) + return + reg = task_registry.get_registry(hass) + task = reg.create( + entry_id, "pg_detail", "Simulate cycle", + label_key="task.pg_detail.simulate", label_params={}, + ) + override = dict(msg.get("settings_override") or {}) + hass.async_create_task( + _pg_detail_task(hass, task, entry_id, msg["cycle_id"], override) + ) + _send_result(connection, msg["id"], "start_playground_cycle_detail", {"task_id": task.id}) diff --git a/custom_components/ha_washdata/ws_schema.py b/custom_components/ha_washdata/ws_schema.py index 29686f2..78f4396 100644 --- a/custom_components/ha_washdata/ws_schema.py +++ b/custom_components/ha_washdata/ws_schema.py @@ -258,16 +258,6 @@ class AnalyzeSplitResponse(TypedDict): full_duration_s: float -class ApplySplitResponse(TypedDict): - success: bool - new_ids: list[str] - - -class ApplyMergeResponse(TypedDict): - success: bool - new_id: str - - # ─── Profile envelope / member cycles ────────────────────────────────────────── class ProfileEnvelope(TypedDict): @@ -363,24 +353,6 @@ class GetMlTrainingStatusResponse(TypedDict): # ─── Playground (F3) ─────────────────────────────────────────────────────────── -class PlaygroundSummary(TypedDict): - cycles: int - requested: int - concurrency: int - detected: int - missed: int - false_end: int - match_correct: int - match_wrong: int - unmatched: int - skipped_ids: list[str] - - -class RunPlaygroundSimulationResponse(TypedDict): - results: list[dict[str, Any]] - summary: PlaygroundSummary - - class RunPlaygroundCycleDetailResponse(TypedDict, total=False): cycle_id: Any label: str | None @@ -602,12 +574,27 @@ class GetShareableCyclesResponse(TypedDict, total=False): phase_programs: list +class GetSetupStatusResponse(TypedDict, total=False): + """Current adoption phase for the setup guidance card.""" + phase: str + message_key: str + message_params: dict + cta_label_key: str + cta_action: str + secondary_label_key: str | None + secondary_action: str | None + skippable: bool + dismissible: bool + step_key: str | None + + WS_RESPONSE_TYPES: dict[str, type] = { "get_devices": GetDevicesResponse, "get_device_cycles": GetDeviceCyclesResponse, "get_options": GetOptionsResponse, "set_options": SuccessResponse, "get_settings_changelog": GetSettingsChangelogResponse, + "get_setup_status": GetSetupStatusResponse, "get_profiles": GetProfilesResponse, "create_profile": CreateProfileResponse, "rename_profile": SuccessResponse, @@ -616,7 +603,7 @@ WS_RESPONSE_TYPES: dict[str, type] = { "save_profile_group": SuccessResponse, "rename_profile_group": SuccessResponse, "delete_profile_group": SuccessResponse, - "rebuild_envelopes": SuccessResponse, + "rebuild_envelopes": StartTaskResponse, "get_profile_phases": GetProfilePhasesResponse, "set_profile_phases": SuccessResponse, "get_maintenance_log": GetMaintenanceLogResponse, @@ -649,10 +636,10 @@ WS_RESPONSE_TYPES: dict[str, type] = { "clear_suggestions": SuccessResponse, "run_suggestion_analysis": RunSuggestionAnalysisResponse, "get_cycle_power_data": GetCyclePowerDataResponse, - "trim_cycle": SuccessResponse, + "trim_cycle": StartTaskResponse, "analyze_split": AnalyzeSplitResponse, - "apply_split": ApplySplitResponse, - "apply_merge": ApplyMergeResponse, + "apply_split": StartTaskResponse, + "apply_merge": StartTaskResponse, "get_profile_envelope": GetProfileEnvelopeResponse, "get_profile_cycles": GetProfileCyclesResponse, "get_panel_config": GetPanelConfigResponse, @@ -671,7 +658,6 @@ WS_RESPONSE_TYPES: dict[str, type] = { "pause_cycle": OkResponse, "resume_cycle": OkResponse, "terminate_cycle": OkResponse, - "run_playground_simulation": RunPlaygroundSimulationResponse, "run_playground_cycle_detail": RunPlaygroundCycleDetailResponse, "run_playground_history": RunPlaygroundHistoryResponse, "run_playground_sweep": RunPlaygroundSweepResponse, @@ -682,6 +668,7 @@ WS_RESPONSE_TYPES: dict[str, type] = { "get_task_result": TaskSnapshot, "start_playground_history": StartTaskResponse, "start_playground_sweep": StartTaskResponse, + "start_playground_cycle_detail": StartTaskResponse, "store_status": StoreStatusResponse, "store_connect": StoreSimpleResponse, "store_disconnect": StoreSimpleResponse, @@ -751,6 +738,7 @@ WS_COMMANDS: dict[str, dict] = { "get_options": {"params": [_entry()]}, "set_options": {"params": [_entry(), _p("options", "dict")]}, "get_settings_changelog": {"params": [_entry()]}, + "get_setup_status": {"params": [_entry()]}, "get_profiles": {"params": [_entry()]}, "create_profile": {"params": [ _entry(), @@ -911,12 +899,6 @@ WS_COMMANDS: dict[str, dict] = { "pause_cycle": {"params": [_entry()]}, "resume_cycle": {"params": [_entry()]}, "terminate_cycle": {"params": [_entry()]}, - "run_playground_simulation": {"params": [ - _entry(), - _p("cycle_ids", "list[str]", False), - _p("settings_override", "dict", False), - _p("concurrency", "int", False), - ]}, "run_playground_cycle_detail": {"params": [ _entry(), _p("cycle_id", "str"), @@ -960,6 +942,11 @@ WS_COMMANDS: dict[str, dict] = { _p("param_y", "str|null", False), _p("values_y", "list[float]", False), ]}, + "start_playground_cycle_detail": {"params": [ + _entry(), + _p("cycle_id", "str"), + _p("settings_override", "dict", False), + ]}, # Community store (online features) "store_status": {"params": [_entry()]}, "store_connect": {"params": [ diff --git a/custom_components/ha_washdata/www/ha-washdata-panel.js b/custom_components/ha_washdata/www/ha-washdata-panel.js index c6e4a78..cfbf656 100644 --- a/custom_components/ha_washdata/www/ha-washdata-panel.js +++ b/custom_components/ha_washdata/www/ha-washdata-panel.js @@ -147,8 +147,8 @@ const _SETTINGS_SECTIONS = [ { sub: 'Duration Gates', fields: [ { key: 'profile_match_min_duration_ratio', label: 'Min Duration Ratio', type: 'number', step: 0.01, min: 0, max: 1, def: 0.1, doc: 'Minimum cycle length relative to the profile. 0.9 means a cycle must be at least 90% of the profile duration to match.' }, - { key: 'profile_match_max_duration_ratio', label: 'Max Duration Ratio', type: 'number', step: 0.01, min: 0, def: 1.3, - doc: 'Maximum cycle length relative to the profile. 1.3 means a cycle must be under 130% of the profile duration to match.' }, + { key: 'profile_match_max_duration_ratio', label: 'Max Duration Ratio', type: 'number', step: 0.01, min: 0, def: 1.5, + doc: 'Maximum cycle length relative to the profile. 1.5 means a cycle must be under 150% of the profile duration to match.' }, { key: 'profile_duration_tolerance', label: 'Profile Duration Tolerance', type: 'number', step: 0.01, min: 0, max: 1, def: 0.25, doc: 'The +/- band around a profile average duration used during matching. 0.25 means a 60 min profile matches 45-75 min cycles.' }, { key: 'duration_tolerance', label: 'Estimate Tolerance', type: 'number', step: 0.01, min: 0, max: 1, def: 0.1, @@ -161,6 +161,10 @@ const _SETTINGS_SECTIONS = [ doc: 'If the match score falls between this and Auto-Label Confidence, WashData flags the finished cycle for review in the Cycles queue so you can verify the identified program. Below this score the match is too uncertain to surface. Must be kept below Auto-Label Confidence.' }, ] }, ] }, + { id: 'phase_eta', label: 'Time Remaining', intro: 'Phase-aware time-remaining, for machines whose cycle length depends on temperature or spin.', onlyDeviceTypes: ['washing_machine', 'washer_dryer'], fields: [ + { key: 'enable_phase_matching', label: 'Phase-aware time remaining', type: 'checkbox', def: false, + doc: 'Break each running cycle into phases (heating, wash, spin) and budget the time remaining per phase, blended with the classic estimate - leaning on the phase budget early in the cycle and the classic estimate near the end. This personalises the countdown to how long your machine actually heats and runs, which is most noticeable in the first half of a cycle. Off = the classic estimate only. Only the time-remaining display is affected; program matching and cycle detection are unchanged.' }, + ] }, { id: 'timing', label: 'Timing & Watchdog', intro: 'Background cadence, the offline watchdog and housekeeping.', groups: [ { sub: 'Watchdog', fields: [ { key: 'watchdog_interval', label: 'Watchdog Interval', unit: 's', type: 'number', min: 1, def: 30, @@ -271,6 +275,8 @@ const _SETTINGS_SECTIONS = [ doc: 'Android notification channel name for the finish message. Blank reuses the start/live channel.' }, ] }, { sub: 'Energy', fields: [ + { key: 'energy_sensor', label: 'Energy Meter Entity', type: 'entity', domain: 'sensor', optional: true, + doc: 'Optional cumulative energy counter (total_increasing kWh/Wh, e.g. the plug\'s own lifetime meter). When set, each cycle\'s reported energy is taken from this counter\'s start-to-end delta, which avoids the under-counting you get from integrating a slow-reporting power sensor. Falls back to the integrated value if the reading is missing, its unit is unknown, or the delta is not positive. Leave blank to keep integrating the power sensor.' }, { key: 'energy_price_entity', label: 'Energy Price Entity', type: 'entity', domain: 'sensor', basic: true, doc: 'Sensor with the current electricity price per kWh (e.g. a dynamic tariff). Takes precedence over the static price below. Each cycle freezes the price in effect when it finished.' }, { key: 'energy_price_static', label: 'Static Energy Price (per kWh)', type: 'number', step: 0.001, min: 0, basic: true, @@ -316,6 +322,24 @@ for (const sec of _SETTINGS_SECTIONS) { for (const grp of groups) for (const f of (grp.fields || [])) _FIELD_BY_KEY[f.key] = f; } +// Default values for playground-only matcher params (code constants, not stored +// options, so _FIELD_BY_KEY has no entry for them). +const _PG_MATCH_DEFAULTS = { + profile_match_min_duration_ratio: 0.1, + profile_match_max_duration_ratio: 1.5, + corr_weight: 0.45, + keep_min_score: 0.1, + dtw_bandwidth: 0.2, + dtw_blend: 0.5, + dtw_ensemble_w: 0.7, + dtw_ddtw_scale: 30, + dtw_refine_top_n: 5, + duration_weight: 0.22, + energy_weight: 0.22, + duration_scale: 0.175, + energy_scale: 0.25, +}; + // ─── Setting conflict rules ─────────────────────────────────────────────────── // Each rule describes a cross-parameter invariant. `check(vals)` returns true // when the invariant is violated. `fieldErrors(vals)` maps each affected key to @@ -608,22 +632,37 @@ th.wd-tc-flags { color: var(--secondary-text-color); font-weight: 500; } } .wd-field textarea { min-height: 64px; resize: vertical; } .wd-field input[type=checkbox] { width: auto; margin-right: 8px; } -.wd-field .wd-check-row { display: flex; align-items: center; cursor: pointer; text-transform: none; letter-spacing: normal; font-weight: 500; color: var(--primary-text-color); } /* Switch-style boolean settings (replaces the old plain checkbox). Scoped under .wd-field-switch so the switch label wins over the generic ".wd-field label" (display:block, higher specificity) rule and stays a centered flex row. */ .wd-field-switch label { margin: 0; } .wd-field-switch .wd-switch-row { display: flex; align-items: center; gap: 10px; min-height: 22px; } -.wd-field-switch .wd-switch-lbl { display: flex; align-items: center; gap: 10px; cursor: pointer; min-width: 0; margin: 0; } +/* Switch label: a flex row [toggle][text], vertically centred. The .wd-field + .wd-switch-lbl selector is needed to beat .wd-field label (display:block + + .82em + uppercase, specificity 0,1,1) which otherwise leaks in and both breaks + the vertical centring (align-items is a no-op on a block) and shrinks the label. + The bare .wd-switch-lbl covers inline use outside a .wd-field (e.g. adopt). */ +.wd-switch-lbl, +.wd-field .wd-switch-lbl { + display: inline-flex; align-items: center; gap: 10px; cursor: pointer; + min-width: 0; margin: 0; font-size: 1rem; font-weight: 400; + letter-spacing: normal; text-transform: none; +} /* Match the switch label to every other setting name (see .wd-field label). */ .wd-switch-text { font-size: .82em; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--secondary-text-color); } #wd-settings-form .wd-switch-text, #wd-ml-form .wd-switch-text { color: var(--primary-text-color); } +/* Normal-case, readable label for switches outside the Settings form (store / + panel prefs / access control), whose labels are descriptive sentences. */ +.wd-switch-text--plain { font-size: .9em; font-weight: 600; line-height: 1.3; letter-spacing: normal; text-transform: none; color: var(--primary-text-color); } .wd-switch { position: relative; display: inline-flex; flex: 0 0 auto; width: 40px; height: 22px; } .wd-switch input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; } .wd-switch-slider { position: absolute; inset: 0; border-radius: 22px; background: var(--switch-unchecked-track-color, rgba(120,120,120,.5)); transition: background .2s; } .wd-switch-slider::before { content: ""; position: absolute; height: 16px; width: 16px; left: 3px; top: 3px; border-radius: 50%; background: var(--switch-unchecked-button-color, #fafafa); box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .2s; } .wd-switch input:checked + .wd-switch-slider { background: var(--switch-checked-track-color, var(--primary-color, #03a9f4)); } -.wd-switch input:checked + .wd-switch-slider::before { transform: translateX(18px); background: var(--switch-checked-button-color, #fff); } +/* Force a light thumb: some themes set --switch-checked-button-color to the accent, + which made the knob vanish into the (also-accent) track. A white thumb reads on + any track colour. */ +.wd-switch input:checked + .wd-switch-slider::before { transform: translateX(18px); background: #fff; } .wd-switch input:focus-visible + .wd-switch-slider { outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; } /* A11y: a shared keyboard focus ring for all interactive controls (many HA themes suppress the UA default outline). */ @@ -779,9 +818,16 @@ button.wd-profile-card { display: block; } .wd-prof-wrap { position: relative; } .wd-profile-name { font-weight: 600; font-size: 1em; margin-bottom: 6px; } .wd-profile-meta { font-size: .8em; color: var(--secondary-text-color); } +/* Profile-card header: name on the left, mini power-signature sparkline on the + right, both on one line and vertically aligned regardless of badge count. */ +.wd-profile-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } +.wd-profile-head .wd-profile-name { margin: 0; flex: 1 1 auto; min-width: 0; } +/* Status-pill row: wraps cleanly on its own line below the name, and every pill + (health / trend / warm-up / imported) shares one uniform compact pill shape. */ +.wd-profile-badges { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px; } +.wd-profile-badges .wd-badge { margin: 0; padding: 2px 9px; border-radius: 999px; font-size: .72em; font-weight: 600; gap: 4px; } /* D2: mini duration sparkline on profile cards */ -.wd-profile-name { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } -.wd-prof-spark { margin-left: auto; width: 64px; height: 20px; display: block; flex-shrink: 0; } +.wd-prof-spark { width: 64px; height: 20px; display: block; flex-shrink: 0; } /* Community Store */ .wd-store-crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; font-size: .85em; } .wd-crumb { background: none; border: none; color: var(--primary-color); cursor: pointer; padding: 2px 4px; font: inherit; } @@ -929,6 +975,15 @@ button.wd-profile-card { display: block; } .wd-onboard .wd-card-title { margin-top: 0; } .wd-onboard-skip { font-size: .8em; color: var(--secondary-text-color); text-decoration: underline; cursor: pointer; } .wd-onboard-skip:hover { color: var(--primary-color); } +/* Setup card (replaces getting-started card, phases 0-3) and phase-4 chip */ +.wd-setup-card { margin-top: 12px; padding: 16px; border-radius: 10px; border: 1px solid var(--primary-color, #03a9f4); background: color-mix(in srgb, var(--primary-color, #03a9f4) 8%, var(--card-background-color, var(--ha-card-background))); } +.wd-setup-card .wd-card-title { margin-top: 0; } +.wd-setup-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 20px; font-size: .82em; font-weight: 600; cursor: pointer; border: 1px solid var(--divider-color); background: var(--secondary-background-color); margin-top: 12px; } +.wd-setup-chip--healthy { border-color: var(--success-color, #4caf50); background: color-mix(in srgb, var(--success-color, #4caf50) 10%, var(--card-background-color, transparent)); } +.wd-setup-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.wd-setup-dot--green { background: var(--success-color, #4caf50); } +.wd-link { background: none; border: none; padding: 0; color: var(--primary-color); cursor: pointer; font: inherit; text-decoration: underline; display: inline; } +.wd-link:hover { opacity: .8; } /* Logs page */ .wd-logbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; } .wd-logs { font-family: monospace; font-size: .76em; background: var(--secondary-background-color); border-radius: var(--wd-radius-md); padding: 10px; height: 56vh; min-height: 140px; overflow: auto; resize: vertical; } @@ -1073,6 +1128,9 @@ button.wd-profile-card { display: block; } .wd-task-pill-eta { opacity: .75; } .wd-task-pill-x { border: none; background: rgba(0,0,0,.18); color: inherit; width: 16px; height: 16px; border-radius: 50%; cursor: pointer; font-size: .9em; line-height: 1; display: inline-flex; align-items: center; justify-content: center; padding: 0; } .wd-task-pill-x:hover { background: rgba(0,0,0,.32); } +.wd-task-pill--cancelling { background: rgba(255,160,0,.28); } +.wd-task-pill-cancelling { opacity: .9; font-style: italic; } +.wd-task-pill-x--cancelling { opacity: .4; cursor: default; pointer-events: none; } .wd-task-spin { width: 10px; height: 10px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: wd-spin-kf .8s linear infinite; opacity: .9; } @keyframes wd-spin-kf { to { transform: rotate(360deg); } } .wd-pg-batchrow { display: flex; align-items: center; gap: 10px; margin: 6px 0 8px; } @@ -1168,6 +1226,35 @@ function _safeHttpUrl(u) { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; } +// Apply an alpha channel to any CSS color and return a valid rgba() string. +// The base color may come from a theme's `--primary-color` (issues #314/#315), +// which `getPropertyValue` returns as the raw declared token: `#03a9f4` for hex +// themes but `rgb(238, 147, 0)` for many custom themes / HA's accent picker. +// The old `col + '55'` alpha-suffix concatenation produced `rgb(238, 147, 0)55`, +// an invalid color that made canvas `addColorStop`/`fillStyle` throw and froze +// the panel. This parses hex (#rgb/#rgba/#rrggbb/#rrggbbaa) and rgb()/rgba(), +// and never throws -- an unrecognised color falls back to itself so the draw +// still succeeds (worst case: no transparency) rather than aborting the render. +function _withAlpha(col, alpha) { + const s = String(col == null ? '' : col).trim(); + let m = s.match(/^#([0-9a-fA-F]{3,8})$/); + if (m) { + let h = m[1]; + if (h.length === 3 || h.length === 4) h = h.split('').map(c => c + c).join(''); + if (h.length === 6 || h.length === 8) { + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + } + m = s.match(/^rgba?\(([^)]+)\)$/i); + if (m) { + const parts = m[1].split(',').map(p => p.trim()); + if (parts.length >= 3) return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`; + } + return s; +} function _num(v, def) { const n = parseFloat(v); return isNaN(n) ? def : n; } // Visible, keyboard-focusable descendants of `root` (for modal focus trapping). function _focusableEls(root) { @@ -1436,6 +1523,20 @@ function _tip(text, diagram) { return `i${dg}${_esc(text)}`; } +// Settings-style toggle switch, reused everywhere instead of raw checkboxes. +// `inner` is the attributes (id / data-* / checked / disabled), `label` +// is plain-case text, `tip` is an optional _tip(...) string placed inline at the +// end of the row. Inline variant (no .wd-field wrapper) fits inside flex rows. +function _switchInline(inner, label, tip = '') { + return `${tip || ''}`; +} +function _switchRow(inner, label, tip = '', hint = '') { + return `
${_switchInline(inner, label, tip)}
` + + `${hint ? `
${_esc(hint)}
` : ''}
`; +} + // Small conceptual diagrams illustrating each parameter (from SETTINGS_VISUALIZED). function _diagram(id) { const wrap = inner => `${inner}`; @@ -1601,7 +1702,7 @@ class HaWashdataPanel extends HTMLElement { this._hassUpdateThrottle = null; this._evtUnsubs = []; // Data - this._constants = { stateColors: {}, deviceTypes: [], mlLabEnabled: false, mlSuggestionsEnabled: false, mlTrainingAvailable: false, storeOnlineAvailable: false, storeOnlineEnabled: false, storeWebOrigin: '', storePrefs: {} }; + this._constants = { stateColors: {}, deviceTypes: [], mlLabEnabled: false, mlSuggestionsEnabled: false, mlTrainingAvailable: false, storeOnlineAvailable: false, storeOnlineEnabled: false, storeWebOrigin: '', storePrefs: {}, pgMatchDefaults: {} }; this._constantsLoaded = false; this._devices = []; this._cycles = []; @@ -1627,6 +1728,7 @@ class HaWashdataPanel extends HTMLElement { this._mlSettings = {}; // conf key -> {classic_value, ml_value, ml_reason, ...} this._mlSettingsLoading = false; this._mlTrainingStatus = null; // {enabled, running, last_trained, cycle_count, min_cycles, ...} + this._setupStatus = null; // result of ws_get_setup_status // UI state this._selIdx = 0; this._tab = 'status'; @@ -1648,6 +1750,7 @@ class HaWashdataPanel extends HTMLElement { this._pendingSettings = {}; // unsaved edits accumulated across section switches this._busy = new Set(); // in-flight long operations (drives spinners) this._tasks = {}; // id -> background-task snapshot (registry, reconnect-safe) + this._cancellingTasks = new Set(); // task ids for which cancel was requested but not yet confirmed this._taskCallbacks = {}; // id -> fn(taskSnapshot) run once when a tracked task settles this._tasksSubscribed = false; // subscribe_tasks succeeded (else poll fallback) this._pgHistoryTaskId = null; // active Test-on-history task id @@ -1846,6 +1949,7 @@ class HaWashdataPanel extends HTMLElement { const prev = this._tasks[t.id]; if (prev && (prev.updated_at || 0) > (t.updated_at || 0)) return; this._tasks[t.id] = t; + if (t.state !== 'running') this._cancellingTasks.delete(t.id); this._updateTaskPills(); this._pgAdoptTask(t); this._onTrackedTaskProgress(t); @@ -1949,7 +2053,11 @@ class HaWashdataPanel extends HTMLElement { tid = r && r.task_id; if (!tid) throw new Error('no task id'); const kind = String(msg.type || '').endsWith('reprocess_history') ? 'reprocess' - : String(msg.type || '').endsWith('trigger_ml_training') ? 'ml_training' : 'task'; + : String(msg.type || '').endsWith('trigger_ml_training') ? 'ml_training' + : String(msg.type || '').endsWith('apply_split') ? 'split' + : String(msg.type || '').endsWith('trim_cycle') ? 'trim' + : String(msg.type || '').endsWith('apply_merge') ? 'merge' + : String(msg.type || '').endsWith('rebuild_envelopes') ? 'rebuild' : 'task'; this._addProvisionalTask(tid, kind, msg.entry_id, 0); } catch (e) { this._busy.delete(busyKey); @@ -2036,6 +2144,11 @@ class HaWashdataPanel extends HTMLElement { const m = { pg_history: this._t('lbl.task_pg_history', {}, 'Test on history'), pg_sweep: this._t('lbl.task_pg_sweep', {}, 'Optimize'), + pg_detail: this._t('lbl.task_pg_detail', {}, 'Simulate cycle'), + split: this._t('lbl.task_split', {}, 'Splitting cycle'), + trim: this._t('lbl.task_trim', {}, 'Trimming cycle'), + merge: this._t('lbl.task_merge', {}, 'Merging cycles'), + rebuild: this._t('lbl.task_rebuild', {}, 'Rebuilding envelopes'), reprocess: this._t('lbl.task_reprocess', {}, 'Reprocessing'), ml_training: this._t('lbl.task_ml_training', {}, 'Learning'), }; @@ -2065,17 +2178,23 @@ class HaWashdataPanel extends HTMLElement { const running = Object.values(this._tasks || {}).filter(t => t.state === 'running'); if (!running.length) return ''; return running.map(t => { + const cancelling = this._cancellingTasks.has(t.id); const dev = this._deviceName(t.entry_id); const action = t.label_key ? this._t(t.label_key, t.label_params || {}, t.label || this._taskActionLabel(t.kind)) : this._taskActionLabel(t.kind); const label = (dev ? dev + ' · ' : '') + action; const pct = t.progress != null ? Math.round(t.progress * 100) + '%' : ''; const eta = (t.eta_s != null && t.eta_s > 0) ? this._fmtEta(t.eta_s) : ''; - return `` + const cancellingLabel = this._t('task.cancelling', {}, 'Cancelling…'); + const pillClass = 'wd-task-pill' + (cancelling ? ' wd-task-pill--cancelling' : ''); + const titleSuffix = cancelling ? (' · ' + cancellingLabel) : (pct ? ' ' + pct : ''); + return `` + `` + `${_esc(label)}` - + (pct ? `${pct}` : '') - + (eta ? `${_esc(eta)}` : '') - + `` + + (cancelling + ? `${_esc(cancellingLabel)}` + : (pct ? `${pct}` : '') + + (eta ? `${_esc(eta)}` : '')) + + `` + ``; }).join(''); } @@ -2215,7 +2334,7 @@ class HaWashdataPanel extends HTMLElement { if (!this._constantsLoaded) { try { const c = await this._ws({ type: `${_DOMAIN}/get_constants` }); - this._constants = { stateColors: c.state_colors || {}, deviceTypes: c.device_types || [], mlLabEnabled: !!(c.ml_lab_enabled), mlSuggestionsEnabled: !!(c.ml_suggestions_enabled), mlTrainingAvailable: !!(c.ml_training_available), storeOnlineAvailable: !!(c.store_online_available), storeOnlineEnabled: !!(c.store_online_enabled), storeWebOrigin: c.store_web_origin || '', storePrefs: c.store_prefs || {} }; + this._constants = { stateColors: c.state_colors || {}, deviceTypes: c.device_types || [], mlLabEnabled: !!(c.ml_lab_enabled), mlSuggestionsEnabled: !!(c.ml_suggestions_enabled), mlTrainingAvailable: !!(c.ml_training_available), storeOnlineAvailable: !!(c.store_online_available), storeOnlineEnabled: !!(c.store_online_enabled), storeWebOrigin: c.store_web_origin || '', storePrefs: c.store_prefs || {}, pgMatchDefaults: c.pg_match_defaults || {}, PROFILE_MIN_WARMUP_CYCLES: c.PROFILE_MIN_WARMUP_CYCLES }; } catch (_) { /* fall back to humanized labels */ } try { this._panelCfg = await this._ws({ type: `${_DOMAIN}/get_panel_config` }); @@ -2247,7 +2366,11 @@ class HaWashdataPanel extends HTMLElement { // Keep the Manual Recording widget's live duration / sample count fresh // while a recording is running (the backend reports them live; without // this poll the widget stays frozen at its start-of-recording snapshot). - if (this._canEdit() && this._recState && this._recState.state === 'recording') { + // Gate on the authoritative dev.recording flag rather than the polled + // _recState, which is null on first load and would otherwise never be + // populated -- leaving the widget showing "Start Recording" during an + // active recording (#313). + if (this._canEdit() && dev.recording) { try { this._recState = await this._ws({ type: `${_DOMAIN}/get_recording_state`, entry_id: dev.entry_id }); } catch (_) { /* keep previous */ } } } @@ -2791,6 +2914,13 @@ class HaWashdataPanel extends HTMLElement { try { this._matchDebug = await this._ws({ type: `${_DOMAIN}/get_match_debug`, entry_id: eid }); } catch (_) { /* keep */ } } if (this._canEdit()) { try { this._recState = await this._ws({ type: `${_DOMAIN}/get_recording_state`, entry_id: eid }); } catch (_) {} } + // Fetch setup guidance phase (always, not just when profileCount === 0) + try { + this._setupStatus = await this._ws({ + type: `${_DOMAIN}/get_setup_status`, + entry_id: eid, + }); + } catch (_) { this._setupStatus = null; } } else if (this._tab === 'history') { await this._fetchCycles(eid); if (!this._profiles.length) await this._fetchProfiles(eid); @@ -3453,17 +3583,20 @@ class HaWashdataPanel extends HTMLElement { ${this._statusEnv ? `` : ''} ${this._pref('show_raw', false) ? `` : ''} `; - // F1 first-run wizard: on a fresh device (no profiles yet, onboarding not - // skipped) replace the empty chart placeholder with a getting-started card - // until enough cycles are observed. A live cycle (hasCurve) always wins so - // the user sees their appliance being watched in real time. + // Setup card: phase-aware guidance replacing the old getting-started card. + // A live cycle (hasCurve) always wins so the user sees their appliance. const cycleCount = this._cyclesTotal || 0; const profileCount = (this._profiles || []).length; - const showGettingStarted = !this._pref('onboarding_dismissed', false) && profileCount === 0 && !hasCurve; + const setupDismissed = this._pref('setup_card_dismissed', false); + const setupStatus = this._setupStatus; + // Phase 3 and 4 collapse to a chip when dismissed; earlier phases always show + // the full guidance card regardless of the dismissed pref. + const showSetupCard = setupStatus && !hasCurve; + const setupCardHtml = showSetupCard ? this._htmlSetupCard(setupStatus, setupDismissed) : ''; const curveHtml = hasCurve ? `
${legend}` - : (showGettingStarted - ? this._htmlGettingStarted(cycleCount) + : (showSetupCard + ? setupCardHtml : `

${this._t('msg.live_chart_loading', {}, 'Live power chart appears as readings arrive.')}

`); const showDebug = this._pref('show_debug', false); @@ -3514,7 +3647,7 @@ class HaWashdataPanel extends HTMLElement {
${_esc(label)} - ${dev.sub_state ? `(${_esc(dev.sub_state)})` : ''} + ${!rec && dev.sub_state && dev.sub_state.toLowerCase() !== state ? `(${_esc(dev.sub_state)})` : ''}
${programCtl}
@@ -3524,7 +3657,7 @@ class HaWashdataPanel extends HTMLElement {
${progressHtml} ${this._htmlPhaseTimeline(dev, prog, isRunning)} - ${showGettingStarted ? '' : `
${this._t('hdr.live_power', {}, 'Live Power')}
`} + ${showSetupCard ? '' : `
${this._t('hdr.live_power', {}, 'Live Power')}
`} ${curveHtml} ${this._canEdit() ? this._htmlRecordingWidget() : ''} @@ -3533,35 +3666,131 @@ class HaWashdataPanel extends HTMLElement { `; } - // F1: first-run guided card shown in the Status power-chart area of a fresh - // device. Below 3 observed cycles it explains the "just run it" learning phase - // with a 0..3 progress meter; at 3+ it nudges toward creating the first - // profile (reusing the existing create-profile entry point). A "Skip setup" - // link dismisses it permanently via the onboarding_dismissed user pref. - _htmlGettingStarted(cycleCount) { - const n = Math.max(0, Math.min(3, cycleCount || 0)); - const heading = `
${this._t('hdr.getting_started', {}, 'Getting started')}
`; - const skip = `
${this._t('btn.skip_setup', {}, 'Skip setup')}
`; - if (cycleCount >= 3) { - // Enough cycles observed — point the user at naming their first program. - const createBtn = this._canEdit() - ? `
` - : ''; - return `
- ${heading} -

${this._t('msg.name_first_program', {}, 'You have enough cycles — name your first program to start matching.')}

- ${createBtn} - ${skip} -
`; + // Setup Card — phase-aware guidance for device onboarding. Replaces the old + // getting-started card for backends that support get_setup_status (Task 4). + // Phase 4 renders as a compact chip (device fully set up); phases 0–3 render + // the full guidance card with a primary CTA, optional secondary action, skip + // links, and a permanent-hide link when dismissible. + _htmlSetupCard(status, dismissed = false) { + if (!status) return ''; + const { phase, message_key, message_params, cta_label_key, cta_action, + secondary_label_key, secondary_action, skippable, dismissible, + step_key } = status; + + // Phase 4: always a collapsed chip (setup complete — no full card). + if (phase === 'phase4') { + return ` +
+ + ${this._t('setup.hdr.healthy_chip', {}, 'Setup complete')} +
`; } - const pct = (n / 3) * 100; - return `
- ${heading} -

${this._t('msg.onboarding_watching', {}, 'Run your appliance normally — WashData is watching. After 3 cycles, program matching will begin.')}

-
-
${this._t('msg.onboarding_progress', {n}, `${n} / 3 cycles observed`)}
- ${skip} -
`; + + // Phase 3 dismissed: compact chip so the user can restore guidance without + // the full card. Click clears setup_card_dismissed and re-renders the full card. + if (phase === 'phase3' && dismissed) { + return ` +
+ + ${this._t('setup.hdr.card', {}, 'Device Setup')} +
`; + } + + const msg = this._t(message_key, message_params || {}, ''); + const ctaLabel = this._t(cta_label_key, {}, 'Continue'); + const secLabel = secondary_label_key + ? this._t(secondary_label_key, {}, '') + : null; + + const skipHtml = skippable ? ` + + ${this._t('setup.cta.skip_step', {}, 'Skip this step')} + ${this._t('setup.cta.skip_forever', {}, "Don't show again")} + ` : ''; + + const hideHtml = dismissible ? ` + + ${this._t('setup.cta.hide_guidance', {}, 'Hide guidance')} + ` : ''; + + return ` +
+
${this._t('setup.hdr.card', {}, 'Device Setup')}
+

${_esc(msg)}

+
+ + ${secLabel ? ` + ${_esc(secLabel)} + ` : ''} +
+ ${skipHtml} + ${hideHtml} +
`; + } + + // Dispatch a setup card CTA action to the appropriate panel destination. + _dispatchSetupCta(ctaAction, params) { + if (!ctaAction) return; + if (ctaAction === 'open_recorder') { + // Scroll to recorder widget on the Status tab + this.shadowRoot.querySelector('.wd-rec-dot')?.closest('.wd-card')?.scrollIntoView({ behavior: 'smooth' }); + return; + } + if (ctaAction === 'open_cycles' || ctaAction === 'open_cycles_unlabeled') { + this._tab = 'history'; + if (ctaAction === 'open_cycles_unlabeled') { + this._cycleFilter = { ...(this._cycleFilter || {}), status: 'unlabeled' }; + } + this._fetchTabData(); + return; + } + if (ctaAction === 'open_profiles' || ctaAction === 'open_profiles_groups') { + this._tab = 'profiles'; + this._fetchTabData(); + return; + } + if (ctaAction === 'open_suggestions') { + this._tab = 'settings'; + this._settingsSugOnly = true; + this._fetchTabData(); + return; + } + if (ctaAction === 'create_profile_from_cluster') { + // No modal shortcut yet — open the profiles tab where the user can create + // a profile; a future task may pre-populate from cluster cycle IDs. + this._tab = 'profiles'; + this._fetchTabData(); + return; + } + if (ctaAction && ctaAction.startsWith('open_cycle:')) { + // No direct cycle modal shortcut yet — open the history tab. + this._tab = 'history'; + this._fetchTabData(); + return; + } + } + + // Reload the setup guidance phase from the backend and re-render. + async _reloadSetupStatus() { + const dev = this._devices[this._selIdx]; + if (!dev) return; + try { + this._setupStatus = await this._ws({ + type: `${_DOMAIN}/get_setup_status`, + entry_id: dev.entry_id, + }); + } catch (_) { this._setupStatus = null; } + this._render(); } // D1: compact horizontal phase timeline for the matched profile, drawn below @@ -3597,11 +3826,18 @@ class HaWashdataPanel extends HTMLElement { _htmlRecordingWidget() { const rs = this._recState; - const state = rs ? rs.state : 'idle'; + // dev.recording (from get_devices) is the authoritative recording flag. + // _recState is a separately-polled detail source that is null on first load + // and only refreshed while already recording, so trusting it alone showed + // "Start Recording" during an active recording (#313). Derive the state from + // the authoritative flag and use _recState only for the live duration/samples. + const dev = this._devices[this._selIdx]; + const recording = !!(dev && dev.recording); + const state = recording ? 'recording' : (rs ? rs.state : 'idle'); const dotCls = state === 'recording' ? 'wd-rec-active' : state === 'stopped' ? 'wd-rec-ready' : 'wd-rec-idle'; const stateLabel = state === 'recording' ? this._t('status.recording', {}, 'Recording…') : state === 'stopped' ? this._t('status.ready', {}, 'Ready to process') : this._t('status.idle', {}, 'Idle'); let detail = ''; - if (state === 'recording') detail = `${_fmtDuration(rs.duration_s)} · ${rs.sample_count || 0} samples`; + if (state === 'recording') detail = rs ? `${_fmtDuration(rs.duration_s)} · ${rs.sample_count || 0} samples` : ''; else if (state === 'stopped') detail = `${rs.sample_count || 0} samples · ${_fmtDuration(rs.duration_s)}`; const buttons = state === 'recording' ? `` @@ -3872,10 +4108,10 @@ class HaWashdataPanel extends HTMLElement { // match immediately), shown with an "Imported" badge instead of "Still learning". const isWarmup = cycleCount < warmupThreshold && !p.is_imported; const warmupBadge = isWarmup - ? `${this._t('msg.warmup_badge', {done: cycleCount, needed: warmupThreshold}, `Still learning (${cycleCount}/${warmupThreshold} cycles)`)}` + ? `${this._t('msg.warmup_badge', {done: cycleCount, needed: warmupThreshold}, `Still learning (${cycleCount}/${warmupThreshold} cycles)`)}` : ''; const importedBadge = p.is_imported - ? `📥 ${this._t('status.imported', {}, 'Imported')}` + ? `📥 ${this._t('status.imported', {}, 'Imported')}` : ''; const badges = [healthBadge, trendBadge, warmupBadge, importedBadge].filter(Boolean).join(' '); // Mini power-signature curve: the profile's real average power shape (from its @@ -3887,7 +4123,11 @@ class HaWashdataPanel extends HTMLElement { return `
`; @@ -3932,42 +4172,6 @@ class HaWashdataPanel extends HTMLElement { const groupedNames = new Set(); pg.groups.forEach(g => (g.members || []).forEach(m => groupedNames.add(m))); - // Suggestion banner: near-duplicate clusters the user can confirm as groups. - const sugBanner = (canEdit && (pg.suggestions || []).length) ? ` -
- 🔗 ${pg.suggestions.length} ${this._t('msg.near_duplicate_cluster', {}, 'near-duplicate profile cluster' + (pg.suggestions.length > 1 ? 's' : '') + ' detected. Grouping lets matching reliably pick between look-alikes (e.g. same program at different temperature/spin).')} - ${pg.suggestions.map((s, i) => ``).join('')} -
` : ''; - - // Coverage gap banner: unmatched cycles that might represent unknown programs. - const cg = this._coverageGaps || {}; - const cgBanner = (canEdit && cg.suggest_create) ? (() => { - const clusters = (cg.duration_clusters || []).slice(0, 3); - const clusterHints = clusters.map(cl => `~${cl.duration_bucket_min}–${cl.duration_bucket_min + 15} min (${cl.count}×)`).join(', '); - const profileSuggestions = (cg.profile_suggestions || []).slice(0, 2); - const suggestionHtml = profileSuggestions.length > 0 - ? profileSuggestions.map(ps => ` -
- ${this._t('msg.coverage_gap_similar_cycles', {count: ps.count}, `${ps.count} similar unlabelled cycles found — create a profile to start matching them.`)} - -
`).join('') - : ''; - return `
- 📂 ${cg.unmatched_count} ${this._t('msg.coverage_gap', {pct: Math.round(cg.unmatched_rate * 100)}, `recent cycles have no matching profile (${Math.round(cg.unmatched_rate * 100)}% of last 30).`)}${clusterHints ? ` ${this._t('lbl.duration', {}, 'Duration')}: ${clusterHints}.` : ''} ${this._t('msg.consider_new_profile', {}, 'Consider creating a new profile.')} - ${canEdit ? `` : ''} - ${suggestionHtml} -
`; - })() : ''; - - // Recommendations: actionable maintenance advisories derived from the - // per-profile health/trend signals (drift, poor fit). Informational only. - const advisories = this._profileAdvisories || []; - const advBanner = advisories.length ? ` -
- 💡 ${this._t('hdr.recommendations', {n: advisories.length}, `Recommendations (${advisories.length})`)} - ${advisories.slice(0, 5).map(a => `
${a.severity === 'warning' ? '⚠' : 'ℹ️'} ${_esc(a.message_key ? this._t(a.message_key, a.message_params || {}, a.message || '') : (a.message || ''))}
`).join('')} -
` : ''; - // Group sections (with cohesion badge + low-cohesion warning). const groupSections = pg.groups.map(g => { const memCards = (g.members || []).map(m => byName[m] ? this._profileCardHtml(byName[m]) : '').join(''); @@ -4014,9 +4218,6 @@ class HaWashdataPanel extends HTMLElement { ` : ''} - ${sugBanner} - ${cgBanner} - ${advBanner} ${groupSections} ${this._profiles.length === 0 ? `
📊
${this._t('msg.no_profiles_yet', {}, 'No profiles yet. Create one from a labelled cycle.')}
` @@ -4991,20 +5192,38 @@ class HaWashdataPanel extends HTMLElement { ['end_repeat_count', 'End Repeat Count', '', 'Low readings in a row before ending', 'advanced'], ['abrupt_drop_watts', 'Abrupt Drop Threshold', 'W', 'Sudden drop treated as immediate end', 'advanced'], ['interrupted_min_seconds', 'Interrupted Min', 's', 'Short cycles flagged as interrupted', 'advanced'], - ['profile_match_min_duration_ratio', 'Min Duration Ratio', '', 'Shortest run (vs the profile) still allowed to match', 'matching'], - ['profile_match_max_duration_ratio', 'Max Duration Ratio', '', 'Longest run (vs the profile) still allowed to match', 'matching'], + ['profile_match_min_duration_ratio', 'Min Duration Ratio', '', 'Stage 1: shortest run (vs the profile) still allowed to match', 'matching'], + ['profile_match_max_duration_ratio', 'Max Duration Ratio', '', 'Stage 1: longest run (vs the profile) still allowed to match', 'matching'], + ['corr_weight', 'Correlation Weight', '', 'Stage 2: balance between curve shape (correlation) and power level (MAE); default 0.45', 'matching'], + ['keep_min_score', 'Keep Min Score', '', 'Stage 2: floor score to stay in the race; default 0.1 admits weak matches, later stages pick the best', 'matching'], + ['dtw_bandwidth', 'DTW Bandwidth', '', 'Stage 3: Sakoe-Chiba warp band (0 = DTW off; default 0.2 = 20% of cycle length)', 'matching'], + ['dtw_blend', 'DTW Blend', '', 'Stage 3: 0 = core score only, 1 = DTW score only, 0.5 = equal blend (default)', 'matching'], + ['dtw_ensemble_w', 'DTW Ensemble Weight','', 'Stage 3 (ensemble mode): weight on scaled-L1 vs derivative DTW; default 0.7 favours level-aware', 'matching'], + ['dtw_ddtw_scale', 'DDTW Scale', '', 'Stage 3: DDTW half-saturation distance; smaller = more shape-sensitive (default 30)', 'matching'], + ['dtw_refine_top_n', 'DTW Refine Top-N', '', 'Stage 3: candidates DTW re-scores; raise to 7-9 if correct profile ranks 4th-5th (default 5)', 'matching'], + ['duration_weight', 'Duration Weight', '', 'Stage 4: how strongly run-length agreement affects the final score (default 0.22)', 'matching'], + ['energy_weight', 'Energy Weight', '', 'Stage 4: how strongly energy agreement affects the final score (default 0.22)', 'matching'], + ['duration_scale', 'Duration Scale', '', 'Stage 4: log-ratio where duration agreement halves; smaller = stricter penalty (default 0.175)', 'matching'], + ['energy_scale', 'Energy Scale', '', 'Stage 4: log-ratio where energy agreement halves; smaller = stricter penalty (default 0.25)', 'matching'], ]; } // Resolve a pre-fill value for an override field: staged override → live option - // → field default → ''. + // → field default (settings schema) → matcher constant default → ''. _pgFieldVal(key, store) { const s = store || {}; if (s[key] !== undefined) return s[key]; const o = this._opts || {}; if (o[key] !== undefined && o[key] !== null) return o[key]; const f = _FIELD_BY_KEY[key] || {}; - return f.def !== undefined ? f.def : ''; + if (f.def !== undefined) return f.def; + // Prefer backend-provided matcher defaults (const.py, via get_constants) so the + // Playground can't drift from the real defaults; the hardcoded _PG_MATCH_DEFAULTS + // table below is only an offline fallback for when the payload hasn't loaded. + const be = (this._constants && this._constants.pgMatchDefaults) || {}; + if (be[key] !== undefined) return be[key]; + if (_PG_MATCH_DEFAULTS[key] !== undefined) return _PG_MATCH_DEFAULTS[key]; + return ''; } _htmlPlayground() { @@ -5668,10 +5887,33 @@ class HaWashdataPanel extends HTMLElement { const override = { ...this._pgParamOverrides }; if (this._pgThreshStart != null) override.start_threshold_w = this._pgThreshStart; if (this._pgThreshStop != null) override.stop_threshold_w = this._pgThreshStop; + // Run the heavy per-5s replay as a detached, chunked background task so a long + // cycle no longer stalls Home Assistant (issue #311). We still await its + // completion here (the caller drives the busy spinner + redraw), but the + // backend yields the event loop between chunks. A header pill shows progress. try { - const d = await this._ws({ type: `${_DOMAIN}/run_playground_cycle_detail`, entry_id: entryId, cycle_id: cid, settings_override: override }); + const r = await this._ws({ type: `${_DOMAIN}/start_playground_cycle_detail`, entry_id: entryId, cycle_id: cid, settings_override: override }); + const tid = r && r.task_id; + if (!tid) throw new Error('no task id'); + this._addProvisionalTask(tid, 'pg_detail', entryId, 0); + const result = await new Promise((resolve) => { + this._taskCallbacks[tid] = async (t) => { + if (t.state === 'done' || t.state === 'cancelled') { + try { + const rr = await this._ws({ type: `${_DOMAIN}/get_task_result`, task_id: t.id }); + resolve(rr && rr.result); + } catch (_) { resolve(null); } + } else { + resolve(null); // error / lost + } + }; + // Race guard: task may have finished before the callback was registered. + const known = this._tasks[tid]; + if (known && known.state !== 'running') this._settleTaskCallback(known); + else if (!this._tasksSubscribed) this._pollTaskGeneric(tid); + }); if (!this._isActiveEntry(entryId)) return; // device switched mid-flight - drop stale result - this._pgDetail = (d && !d.error) ? d : null; + this._pgDetail = (result && !result.error) ? result : null; this._pgNeedsRestart = false; } catch (e) { if (this._pgIsUnknownCmd(e)) this._pgNeedsRestart = true; @@ -5863,7 +6105,7 @@ class HaWashdataPanel extends HTMLElement { pts.forEach(p => ctx.lineTo(toX(p.t), toY(p.w))); ctx.lineTo(toX(pts[pts.length-1].t), toY(0)); ctx.closePath(); - ctx.fillStyle = primary + '1a'; ctx.fill(); + ctx.fillStyle = _withAlpha(primary, 0.10); ctx.fill(); // Cycle power trace line ctx.beginPath(); ctx.strokeStyle = primary; ctx.lineWidth = 2*dpr; @@ -6412,10 +6654,10 @@ class HaWashdataPanel extends HTMLElement {
${this._t('hdr.status_graph', {}, 'Status Graph')}
-
-
+ ${_switchRow(`id="wd-pref-expected" ${(cur.show_expected !== false) ? 'checked' : ''}`, this._t('lbl.show_expected', {}, 'Show expected curve overlay (matched profile, orange)'))} + ${_switchRow(`id="wd-pref-raw" ${cur.show_raw ? 'checked' : ''}`, this._t('lbl.show_raw', {}, 'Show raw socket toggle in live power graph'))}
${this._t('hdr.diagnostics_pref', {}, 'Diagnostics')}
-
+ ${_switchRow(`id="wd-pref-debug" ${cur.show_debug ? 'checked' : ''}`, this._t('lbl.show_debug', {}, 'Show live match debug card on the Status page (confidence, ambiguity, top candidates)'))}
`; } @@ -6432,13 +6674,13 @@ class HaWashdataPanel extends HTMLElement { const dtOpts = tabOpts.map(([v, l]) => ``).join(''); const hidden = p.hidden_tabs || []; const hideChecks = tabOpts.filter(([v]) => v !== 'status') - .map(([v, l]) => ``).join(''); + .map(([v, l]) => _switchInline(`data-hidetab="${v}" ${hidden.includes(v) ? 'checked' : ''}`, l)).join(''); return `
${this._t('hdr.panel_settings', {}, 'Panel Settings (all users)')}
-
${hideChecks}
+
${hideChecks}
`; } @@ -6461,8 +6703,7 @@ class HaWashdataPanel extends HTMLElement { const adminNote = users.filter(u => u.is_admin).map(u => `${_esc(u.name)} - full (admin)`).join(' '); return `
${this._t('hdr.access_control', {}, 'Access Control')}
-
-
${this._t('msg.rbac_hint', {}, 'When off, every Home Assistant user has full access (the default). Administrators always have full access and can manage everyone.')}
+ ${_switchRow(`id="wd-rbac-enabled" ${rbac.enabled ? 'checked' : ''}`, this._t('lbl.enable_access_control', {}, 'Enable per-user access control'), '', this._t('msg.rbac_hint', {}, 'When off, every Home Assistant user has full access (the default). Administrators always have full access and can manage everyone.'))}
${this._levelSelect('id="wd-rbac-default"', rbac.default_level || 'none', false)}
${adminNote ? `
${adminNote}
` : ''}
@@ -6556,7 +6797,7 @@ class HaWashdataPanel extends HTMLElement { const dlBusy = this._busy.has('store-download-device'); const dlHeader = (this._canEdit() && dev && items.length) ? `
${this._t('msg.store_download_device_intro', {}, 'Adopt every shared program and its reference cycles onto your device. Your own recorded cycles and stats are not affected.')} - + ${_switchInline(`data-action="store-toggle-dl-settings" ${this._dlSettings ? 'checked' : ''} ${dlBusy ? 'disabled' : ''}`, this._t('lbl.adopt_settings', {}, 'Also adopt settings'), _tip(this._t('msg.adopt_settings_hint', {}, 'Overwrite this device\'s detection & matching thresholds with the shared ones. Your notifications, entities and energy price are never changed.')))}
` : ''; return dlHeader + list; @@ -6652,7 +6893,7 @@ class HaWashdataPanel extends HTMLElement { return `
${this._t('hdr.online_account', {}, 'Community Store & online features')}

${this._t('msg.online_intro_global', {}, 'Browse and share reference recordings with other WashData users, and confirm appliance entries. One connection applies to your whole WashData integration; appliance brand and model are set per device under Basic. All online features are opt-in and off by default.')}

-
+ ${_switchRow(`data-action="store-toggle-online" ${on ? 'checked' : ''} ${busy ? 'disabled' : ''}`, this._t('lbl.enable_online', {}, 'Enable online features'))} ${on ? this._htmlStorePrefs(busy) : ''} ${on ? connBlock : ''}
`; @@ -6664,7 +6905,7 @@ class HaWashdataPanel extends HTMLElement { const prefs = (this._constants && this._constants.storePrefs) || {}; return _STORE_PREFS.map(p => { const checked = prefs[p.key] !== false; // defaults on; get_constants sends the full set - return `
${_tip(this._t(p.docKey, {}, p.docFb))}
`; + return _switchRow(`data-action="store-toggle-pref" data-pref="${_esc(p.key)}" ${checked ? 'checked' : ''} ${busy ? 'disabled' : ''}`, this._t(p.labelKey, {}, p.labelFb), _tip(this._t(p.docKey, {}, p.docFb))); }).join(''); } @@ -6848,7 +7089,7 @@ class HaWashdataPanel extends HTMLElement { ctx.beginPath(); opts.band.max.forEach((p, i) => i ? ctx.lineTo(X(p[0]), Y(p[1])) : ctx.moveTo(X(p[0]), Y(p[1]))); for (let i = opts.band.min.length - 1; i >= 0; i--) ctx.lineTo(X(opts.band.min[i][0]), Y(opts.band.min[i][1])); - ctx.closePath(); ctx.fillStyle = opts.band.fill || (primary + '22'); ctx.fill(); + ctx.closePath(); ctx.fillStyle = opts.band.fill || _withAlpha(primary, 0.13); ctx.fill(); } series.forEach(s => { const pts = s.points || []; if (!pts.length) return; @@ -6856,7 +7097,7 @@ class HaWashdataPanel extends HTMLElement { if (s.fill) { ctx.beginPath(); pts.forEach((p, i) => i ? ctx.lineTo(X(p[0]), Y(p[1])) : ctx.moveTo(X(p[0]), Y(p[1]))); ctx.lineTo(X(pts[pts.length - 1][0]), Y(0)); ctx.lineTo(X(pts[0][0]), Y(0)); ctx.closePath(); - const g = ctx.createLinearGradient(0, padT, 0, ch - padB); g.addColorStop(0, col + '55'); g.addColorStop(1, col + '08'); ctx.fillStyle = g; ctx.fill(); + const g = ctx.createLinearGradient(0, padT, 0, ch - padB); g.addColorStop(0, _withAlpha(col, 0.33)); g.addColorStop(1, _withAlpha(col, 0.03)); ctx.fillStyle = g; ctx.fill(); } ctx.beginPath(); pts.forEach((p, i) => i ? ctx.lineTo(X(p[0]), Y(p[1])) : ctx.moveTo(X(p[0]), Y(p[1]))); ctx.strokeStyle = col; ctx.lineWidth = (s.width || 1.5) * dpr; ctx.lineJoin = 'round'; @@ -7622,6 +7863,14 @@ class HaWashdataPanel extends HTMLElement { ${this._t('msg.shape_drift_detail', {}, 'The power pattern for this profile has shifted over time — possible appliance wear or maintenance needed (e.g. descaling, filter cleaning).')}
`; })() : ''} + ${(() => { + const adv = (this._profileAdvisories || []).find(a => a && a.profile === m.name && a.code === 'phase_inconsistent'); + if (!adv) return ''; + return `
+ ${this._t('msg.advisory_phase_inconsistent_title', {}, '⚠ Possibly mixed programs')} + ${_esc(this._t(adv.message_key, adv.message_params, adv.message))} +
`; + })()} ${env.avg && env.avg.length ? `
` : `

${this._t('msg.no_envelope', {}, 'No envelope yet - rebuild after labelling cycles.')}

`}`; } else if (m.tab === 'phases') { const cat = m.catalog || []; @@ -7852,7 +8101,7 @@ class HaWashdataPanel extends HTMLElement { if (!m || !m.env || !(m.env.avg || []).length) return; const env = m.env; const full = env.target_duration || env.avg[env.avg.length - 1][0]; - const bands = (m.phases || []).map((ph, i) => ({ x0: ph.start, x1: ph.end, fill: _PALETTE[i % _PALETTE.length] + '33' })); + const bands = (m.phases || []).map((ph, i) => ({ x0: ph.start, x1: ph.end, fill: _withAlpha(_PALETTE[i % _PALETTE.length], 0.20) })); const vlines = []; (m.phases || []).forEach((ph, i) => { const col = _PALETTE[i % _PALETTE.length]; @@ -9204,9 +9453,41 @@ class HaWashdataPanel extends HTMLElement { } else if (a === 'create-profile') { this._modal = { type: 'create-profile' }; this._render(); - } else if (a === 'skip-onboarding') { - // F1: dismiss the first-run wizard permanently for this user. - this._setPref('onboarding_dismissed', true); + } else if (a === 'setup-cta') { + // Setup card primary / secondary CTA — navigate to the relevant panel section. + const ctaAction = btn.dataset.ctaAction || ''; + let params = {}; + try { params = JSON.parse(btn.dataset.ctaParams || '{}'); } catch (_) {} + this._dispatchSetupCta(ctaAction, params); + + } else if (a === 'setup-skip') { + // Setup card step skip (snooze 14 days or never). + const stepKey = btn.dataset.step; + const snooze = btn.dataset.snooze; // "never" or "14d" + if (stepKey) { + let val; + if (snooze === 'never') { + val = 'never'; + } else { + const until = new Date(); + until.setDate(until.getDate() + 14); + val = until.toISOString(); + } + this._setPref(stepKey, val); + this._reloadSetupStatus(); // async fire-and-forget; calls _render() when done + } + + } else if (a === 'hide-setup-card') { + // Setup card permanent hide (only offered when dismissible, i.e. phase 3/4). + // Keep _setupStatus so _htmlSetupCard can collapse to the phase-3 chip + // immediately (nulling it here would hide the card entirely instead — the + // sibling setup-skip / expand-setup handlers also leave the status intact). + this._setPref('setup_card_dismissed', true); + this._render(); + + } else if (a === 'expand-setup') { + // Phase 3/4 chip tapped — restore full guidance card by clearing the pref. + this._setPref('setup_card_dismissed', false); this._render(); } else if (a === 'set-settings-level') { @@ -9237,10 +9518,13 @@ class HaWashdataPanel extends HTMLElement { }); } else if (a === 'rebuild-envelopes') { - this._busyRun('rebuild-envelopes', async () => { - try { await this._ws({ type: `${_DOMAIN}/rebuild_envelopes`, entry_id: eid }); this._showToast(this._t('toast.envelopes_rebuilt', {}, 'Envelopes rebuilt')); await this._fetchProfiles(eid); } - catch (e) { this._showToast(this._t('toast.rebuild_failed', {error: e.message || e}, 'Rebuild failed: ' + (e.message || e)), 'error'); } - }); + // Backgrounded task (issue #311): rebuilding every profile serially can + // stall a low-power host, so run it via the registry with a header pill. + this._kickAndTrack( + { type: `${_DOMAIN}/rebuild_envelopes`, entry_id: eid }, + 'rebuild-envelopes', + async () => { this._showToast(this._t('toast.envelopes_rebuilt', {}, 'Envelopes rebuilt')); await this._fetchProfiles(eid); }, + ); } else if (a === 'rec-start') { this._ws({ type: `${_DOMAIN}/start_recording`, entry_id: eid }).then(() => { this._showToast(this._t('toast.recording_started', {}, 'Recording started')); return this._fetchRecState(eid); }).then(() => this._render()).catch(e => this._showToast(this._t('toast.start_failed', {error: e.message || e}, 'Start failed: ' + (e.message || e)), 'error')); @@ -9382,7 +9666,16 @@ class HaWashdataPanel extends HTMLElement { if (tid) this._ws({ type: `${_DOMAIN}/cancel_task`, task_id: tid }).catch(() => {}); } else if (a === 'task-cancel') { const tid = btn.dataset.taskId; - if (tid) this._ws({ type: `${_DOMAIN}/cancel_task`, task_id: tid }).catch(() => {}); + if (tid) { + this._cancellingTasks.add(tid); + this._updateTaskPills(); + this._ws({ type: `${_DOMAIN}/cancel_task`, task_id: tid }).catch(() => { + // Cancel request itself failed (dropped socket, etc.) — re-enable the + // ✕ so the user can retry instead of leaving the pill stuck "Cancelling…". + this._cancellingTasks.delete(tid); + this._updateTaskPills(); + }); + } } else if (a === 'pg-load-run') { const tid = btn.dataset.taskId; if (!tid) return; @@ -9817,19 +10110,34 @@ class HaWashdataPanel extends HTMLElement { return; } if (action === 'cyc-apply-trim') { + // Backgrounded task (issue #311): recompute + envelope rebuild can stall a + // low-power host, so run it via the registry with a header pill. const cid = m.cycleId, s = m.trim.start, e2 = m.trim.end; - await this._busyRun('cyc-trim-apply', async () => { - try { await this._ws({ type: `${_DOMAIN}/trim_cycle`, entry_id: eid, cycle_id: cid, start_s: s, end_s: e2 }); this._showToast(this._t('toast.cycle_trimmed', {}, 'Cycle trimmed')); await this._closeCycleDetail(eid); await this._fetchCycles(eid); } - catch (e) { this._showToast(this._t('toast.trim_failed', {error: e.message || e}, 'Trim failed: ' + (e.message || e)), 'error'); } - }); + this._kickAndTrack( + { type: `${_DOMAIN}/trim_cycle`, entry_id: eid, cycle_id: cid, start_s: s, end_s: e2 }, + 'cyc-trim-apply', + async () => { + this._showToast(this._t('toast.cycle_trimmed', {}, 'Cycle trimmed')); + await this._closeCycleDetail(eid); + await this._fetchCycles(eid); + }, + ); return; } if (action === 'cyc-apply-split') { + // Backgrounded task (issue #311): per-segment extraction + affected + // envelope rebuilds can stall a low-power host, so run it via the registry. const cid = m.cycleId, offs = m.split.offsets.slice(), profs = m.split.profiles.slice(); - await this._busyRun('cyc-split-apply', async () => { - try { const r = await this._ws({ type: `${_DOMAIN}/apply_split`, entry_id: eid, cycle_id: cid, split_offsets: offs, segment_profiles: profs }); this._showToast(this._t('toast.split_complete', {count: (r.new_ids || []).length}, `Split into ${(r.new_ids || []).length} cycles`)); await this._closeCycleDetail(eid); await this._fetchCycles(eid); await this._fetchProfiles(eid); } - catch (e) { this._showToast(this._t('toast.split_failed', {error: e.message || e}, 'Split failed: ' + (e.message || e)), 'error'); } - }); + this._kickAndTrack( + { type: `${_DOMAIN}/apply_split`, entry_id: eid, cycle_id: cid, split_offsets: offs, segment_profiles: profs }, + 'cyc-split-apply', + async (result) => { + this._showToast(this._t('toast.split_complete', {count: (result.new_ids || []).length}, `Split into ${(result.new_ids || []).length} cycles`)); + await this._closeCycleDetail(eid); + await this._fetchCycles(eid); + await this._fetchProfiles(eid); + }, + ); return; } } @@ -9887,10 +10195,19 @@ class HaWashdataPanel extends HTMLElement { return; } if (action === 'pp-rebuild') { - await this._busyRun('pp-rebuild', async () => { - try { await this._ws({ type: `${_DOMAIN}/rebuild_envelopes`, entry_id: eid }); const r = await this._ws({ type: `${_DOMAIN}/get_profile_envelope`, entry_id: eid, profile_name: m.name }); if (this._modal) this._modal.env = r.envelope; this._showToast(this._t('toast.envelope_rebuilt', {}, 'Envelope rebuilt')); } - catch (e) { this._showToast(this._t('msg.toast_rebuild_failed', {error: e.message || e}, 'Rebuild failed: ' + (e.message || e)), 'error'); } - }); + // Backgrounded task (issue #311): rebuild runs via the registry; the + // profile's fresh envelope is fetched only once the task has settled. + this._kickAndTrack( + { type: `${_DOMAIN}/rebuild_envelopes`, entry_id: eid }, + 'pp-rebuild', + async () => { + try { + const r = await this._ws({ type: `${_DOMAIN}/get_profile_envelope`, entry_id: eid, profile_name: m.name }); + if (this._modal) this._modal.env = r.envelope; + } catch (_) { /* modal may have closed */ } + this._showToast(this._t('toast.envelope_rebuilt', {}, 'Envelope rebuilt')); + }, + ); return; } if (action === 'pp-delete') { @@ -9980,14 +10297,17 @@ class HaWashdataPanel extends HTMLElement { const newName = sr.getElementById('wd-merge-newname')?.value?.trim() || null; const ids = m.ids || []; this._modal = null; this._render(); - await this._busyRun('cyc-merge', async () => { - try { - await this._ws({ type: `${_DOMAIN}/apply_merge`, entry_id: eid, cycle_ids: ids, target_profile: target || null, new_profile_name: newName }); + // Backgrounded task (issue #311): gap-fill + affected envelope rebuilds can + // stall a low-power host, so run it via the registry with a header pill. + this._kickAndTrack( + { type: `${_DOMAIN}/apply_merge`, entry_id: eid, cycle_ids: ids, target_profile: target || null, new_profile_name: newName }, + 'cyc-merge', + async () => { this._showToast(this._t('toast.cycles_merged', {}, 'Cycles merged')); this._cycleSel.clear(); this._selectMode = false; await this._fetchCycles(eid); await this._fetchProfiles(eid); - } catch (e) { this._showToast(this._t('toast.merge_failed', {error: e.message || e}, 'Merge failed: ' + (e.message || e)), 'error'); } - }); + }, + ); } else if (action === 'bulk-relabel-ok' && eid) { // D6: apply the chosen label to every selected cycle via label_cycle. const profileName = sr.getElementById('wd-relabel-profile')?.value || ''; diff --git a/custom_components/ha_washdata/www/ws-types.d.ts b/custom_components/ha_washdata/www/ws-types.d.ts index 23d787d..f58feb5 100644 --- a/custom_components/ha_washdata/www/ws-types.d.ts +++ b/custom_components/ha_washdata/www/ws-types.d.ts @@ -19,16 +19,6 @@ export interface AnalyzeSplitResponse { full_duration_s: number; } -export interface ApplyMergeResponse { - success: boolean; - new_id: string; -} - -export interface ApplySplitResponse { - success: boolean; - new_ids: string[]; -} - export interface ApplySuggestionsResponse { success: boolean; applied: string[]; @@ -263,6 +253,19 @@ export interface GetSettingsChangelogResponse { changelog: Record[]; } +export interface GetSetupStatusResponse { + phase?: string; + message_key?: string; + message_params?: Record; + cta_label_key?: string; + cta_action?: string; + secondary_label_key?: string | null; + secondary_action?: string | null; + skippable?: boolean; + dismissible?: boolean; + step_key?: string | null; +} + export interface GetShareableCyclesResponse { items?: unknown[]; phase_programs?: unknown[]; @@ -280,19 +283,6 @@ export interface OkResponse { ok: boolean; } -export interface PlaygroundSummary { - cycles: number; - requested: number; - concurrency: number; - detected: number; - missed: number; - false_end: number; - match_correct: number; - match_wrong: number; - unmatched: number; - skipped_ids: string[]; -} - export interface ProfileEnvelope { avg: number[][]; min: number[][]; @@ -330,11 +320,6 @@ export interface RunPlaygroundHistoryResponse { diff?: Record; } -export interface RunPlaygroundSimulationResponse { - results: Record[]; - summary: PlaygroundSummary; -} - export interface RunPlaygroundSweepResponse { param?: string; objective?: string; @@ -500,6 +485,10 @@ export interface GetSettingsChangelogRequest { entry_id: string; } +export interface GetSetupStatusRequest { + entry_id: string; +} + export interface GetProfilesRequest { entry_id: string; } @@ -817,13 +806,6 @@ export interface TerminateCycleRequest { entry_id: string; } -export interface RunPlaygroundSimulationRequest { - entry_id: string; - cycle_ids?: string[]; - settings_override?: Record; - concurrency?: number; -} - export interface RunPlaygroundCycleDetailRequest { entry_id: string; cycle_id: string; @@ -885,6 +867,12 @@ export interface StartPlaygroundSweepRequest { values_y?: number[]; } +export interface StartPlaygroundCycleDetailRequest { + entry_id: string; + cycle_id: string; + settings_override?: Record; +} + export interface StoreStatusRequest { entry_id: string; } @@ -996,6 +984,7 @@ export interface WashDataWsRequests { "ha_washdata/get_options": GetOptionsRequest; "ha_washdata/set_options": SetOptionsRequest; "ha_washdata/get_settings_changelog": GetSettingsChangelogRequest; + "ha_washdata/get_setup_status": GetSetupStatusRequest; "ha_washdata/get_profiles": GetProfilesRequest; "ha_washdata/create_profile": CreateProfileRequest; "ha_washdata/rename_profile": RenameProfileRequest; @@ -1059,7 +1048,6 @@ export interface WashDataWsRequests { "ha_washdata/pause_cycle": PauseCycleRequest; "ha_washdata/resume_cycle": ResumeCycleRequest; "ha_washdata/terminate_cycle": TerminateCycleRequest; - "ha_washdata/run_playground_simulation": RunPlaygroundSimulationRequest; "ha_washdata/run_playground_cycle_detail": RunPlaygroundCycleDetailRequest; "ha_washdata/run_playground_history": RunPlaygroundHistoryRequest; "ha_washdata/run_playground_sweep": RunPlaygroundSweepRequest; @@ -1070,6 +1058,7 @@ export interface WashDataWsRequests { "ha_washdata/get_task_result": GetTaskResultRequest; "ha_washdata/start_playground_history": StartPlaygroundHistoryRequest; "ha_washdata/start_playground_sweep": StartPlaygroundSweepRequest; + "ha_washdata/start_playground_cycle_detail": StartPlaygroundCycleDetailRequest; "ha_washdata/store_status": StoreStatusRequest; "ha_washdata/store_connect": StoreConnectRequest; "ha_washdata/store_disconnect": StoreDisconnectRequest; @@ -1096,6 +1085,7 @@ export interface WashDataWsResponses { "ha_washdata/get_options": GetOptionsResponse; "ha_washdata/set_options": SuccessResponse; "ha_washdata/get_settings_changelog": GetSettingsChangelogResponse; + "ha_washdata/get_setup_status": GetSetupStatusResponse; "ha_washdata/get_profiles": GetProfilesResponse; "ha_washdata/create_profile": CreateProfileResponse; "ha_washdata/rename_profile": SuccessResponse; @@ -1104,7 +1094,7 @@ export interface WashDataWsResponses { "ha_washdata/save_profile_group": SuccessResponse; "ha_washdata/rename_profile_group": SuccessResponse; "ha_washdata/delete_profile_group": SuccessResponse; - "ha_washdata/rebuild_envelopes": SuccessResponse; + "ha_washdata/rebuild_envelopes": StartTaskResponse; "ha_washdata/get_profile_phases": GetProfilePhasesResponse; "ha_washdata/set_profile_phases": SuccessResponse; "ha_washdata/get_maintenance_log": GetMaintenanceLogResponse; @@ -1137,10 +1127,10 @@ export interface WashDataWsResponses { "ha_washdata/clear_suggestions": SuccessResponse; "ha_washdata/run_suggestion_analysis": RunSuggestionAnalysisResponse; "ha_washdata/get_cycle_power_data": GetCyclePowerDataResponse; - "ha_washdata/trim_cycle": SuccessResponse; + "ha_washdata/trim_cycle": StartTaskResponse; "ha_washdata/analyze_split": AnalyzeSplitResponse; - "ha_washdata/apply_split": ApplySplitResponse; - "ha_washdata/apply_merge": ApplyMergeResponse; + "ha_washdata/apply_split": StartTaskResponse; + "ha_washdata/apply_merge": StartTaskResponse; "ha_washdata/get_profile_envelope": GetProfileEnvelopeResponse; "ha_washdata/get_profile_cycles": GetProfileCyclesResponse; "ha_washdata/get_panel_config": GetPanelConfigResponse; @@ -1159,7 +1149,6 @@ export interface WashDataWsResponses { "ha_washdata/pause_cycle": OkResponse; "ha_washdata/resume_cycle": OkResponse; "ha_washdata/terminate_cycle": OkResponse; - "ha_washdata/run_playground_simulation": RunPlaygroundSimulationResponse; "ha_washdata/run_playground_cycle_detail": RunPlaygroundCycleDetailResponse; "ha_washdata/run_playground_history": RunPlaygroundHistoryResponse; "ha_washdata/run_playground_sweep": RunPlaygroundSweepResponse; @@ -1170,6 +1159,7 @@ export interface WashDataWsResponses { "ha_washdata/get_task_result": TaskSnapshot; "ha_washdata/start_playground_history": StartTaskResponse; "ha_washdata/start_playground_sweep": StartTaskResponse; + "ha_washdata/start_playground_cycle_detail": StartTaskResponse; "ha_washdata/store_status": StoreStatusResponse; "ha_washdata/store_connect": StoreSimpleResponse; "ha_washdata/store_disconnect": StoreSimpleResponse; diff --git a/custom_components/mail_and_packages/__pycache__/__init__.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..ef73d6f Binary files /dev/null and b/custom_components/mail_and_packages/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/__pycache__/application_credentials.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/application_credentials.cpython-314.pyc new file mode 100644 index 0000000..bee69a7 Binary files /dev/null and b/custom_components/mail_and_packages/__pycache__/application_credentials.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/__pycache__/const.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/const.cpython-314.pyc new file mode 100644 index 0000000..64ac638 Binary files /dev/null and b/custom_components/mail_and_packages/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/__pycache__/coordinator.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/coordinator.cpython-314.pyc new file mode 100644 index 0000000..f702f17 Binary files /dev/null and b/custom_components/mail_and_packages/__pycache__/coordinator.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/__pycache__/entity.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/entity.cpython-314.pyc new file mode 100644 index 0000000..a7f587a Binary files /dev/null and b/custom_components/mail_and_packages/__pycache__/entity.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/__pycache__/helpers.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/helpers.cpython-314.pyc new file mode 100644 index 0000000..b7821eb Binary files /dev/null and b/custom_components/mail_and_packages/__pycache__/helpers.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/shippers/__pycache__/__init__.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..502ee91 Binary files /dev/null and b/custom_components/mail_and_packages/shippers/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/shippers/__pycache__/amazon.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/amazon.cpython-314.pyc new file mode 100644 index 0000000..abb146b Binary files /dev/null and b/custom_components/mail_and_packages/shippers/__pycache__/amazon.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/shippers/__pycache__/base.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000..0955a01 Binary files /dev/null and b/custom_components/mail_and_packages/shippers/__pycache__/base.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/shippers/__pycache__/generic.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/generic.cpython-314.pyc new file mode 100644 index 0000000..dacdd35 Binary files /dev/null and b/custom_components/mail_and_packages/shippers/__pycache__/generic.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/shippers/__pycache__/post_de.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/post_de.cpython-314.pyc new file mode 100644 index 0000000..e872651 Binary files /dev/null and b/custom_components/mail_and_packages/shippers/__pycache__/post_de.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/shippers/__pycache__/usps.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/usps.cpython-314.pyc new file mode 100644 index 0000000..e6d84d2 Binary files /dev/null and b/custom_components/mail_and_packages/shippers/__pycache__/usps.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/__init__.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..7275fcd Binary files /dev/null and b/custom_components/mail_and_packages/utils/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/amazon.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/amazon.cpython-314.pyc new file mode 100644 index 0000000..8e312b7 Binary files /dev/null and b/custom_components/mail_and_packages/utils/__pycache__/amazon.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/cache.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/cache.cpython-314.pyc new file mode 100644 index 0000000..8e3a001 Binary files /dev/null and b/custom_components/mail_and_packages/utils/__pycache__/cache.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/date.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/date.cpython-314.pyc new file mode 100644 index 0000000..410cc0b Binary files /dev/null and b/custom_components/mail_and_packages/utils/__pycache__/date.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/email.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/email.cpython-314.pyc new file mode 100644 index 0000000..b7220d4 Binary files /dev/null and b/custom_components/mail_and_packages/utils/__pycache__/email.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/image.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/image.cpython-314.pyc new file mode 100644 index 0000000..8e0eeae Binary files /dev/null and b/custom_components/mail_and_packages/utils/__pycache__/image.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/imap.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/imap.cpython-314.pyc new file mode 100644 index 0000000..18f798a Binary files /dev/null and b/custom_components/mail_and_packages/utils/__pycache__/imap.cpython-314.pyc differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/shipper.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/shipper.cpython-314.pyc new file mode 100644 index 0000000..16d8ff3 Binary files /dev/null and b/custom_components/mail_and_packages/utils/__pycache__/shipper.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/__init__.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/__init__.cpython-314.pyc index 2152b44..0dd6bf3 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/__init__.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/binary_sensor.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/binary_sensor.cpython-314.pyc index e08256c..9184e18 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/binary_sensor.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/binary_sensor.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/button.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/button.cpython-314.pyc index 4e08fa3..f27618b 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/button.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/button.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/calendar.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/calendar.cpython-314.pyc index 6080651..c47d01c 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/calendar.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/calendar.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/condition.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/condition.cpython-314.pyc index 352d52e..f4bc755 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/condition.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/condition.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/config_flow.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/config_flow.cpython-314.pyc index c28a1f3..f6bb68b 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/config_flow.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/config_flow_helpers.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/config_flow_helpers.cpython-314.pyc index 956a32e..01e56e4 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/config_flow_helpers.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/config_flow_helpers.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/config_flow_options_global.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/config_flow_options_global.cpython-314.pyc index 7777627..9a9be89 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/config_flow_options_global.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/config_flow_options_global.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/config_flow_trigger.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/config_flow_trigger.cpython-314.pyc index b020f83..63f470b 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/config_flow_trigger.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/config_flow_trigger.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/const.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/const.cpython-314.pyc index 1d4a631..0da4397 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/const.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/coordinator.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/coordinator.cpython-314.pyc index 74d7133..28a75c4 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/coordinator.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/coordinator.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/diagnostics.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/diagnostics.cpython-314.pyc index 90d0e4b..66354da 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/diagnostics.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/diagnostics.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/intent.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/intent.cpython-314.pyc index 2760175..50b7dca 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/intent.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/intent.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/logbook.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/logbook.cpython-314.pyc index 769c8ca..ab48527 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/logbook.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/logbook.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/panel.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/panel.cpython-314.pyc index 6aa487b..ea19e84 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/panel.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/panel.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/parts_runtime.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/parts_runtime.cpython-314.pyc index 63e0ef7..68c46d2 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/parts_runtime.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/parts_runtime.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/repairs.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/repairs.cpython-314.pyc index 8ab8ebc..7c557ab 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/repairs.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/repairs.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/sensor.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/sensor.cpython-314.pyc index a799e90..dcc0b91 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/sensor.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/sensor.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/storage.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/storage.cpython-314.pyc index 2833e12..fa091bf 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/storage.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/storage.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/templates.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/templates.cpython-314.pyc index bb46fa6..a4f2303 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/templates.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/templates.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/templates_i18n.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/templates_i18n.cpython-314.pyc index 7a2ff21..58d550d 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/templates_i18n.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/templates_i18n.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/todo.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/todo.cpython-314.pyc index 4d822e7..745723f 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/todo.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/todo.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/trigger.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/trigger.cpython-314.pyc index 503b673..69b748d 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/trigger.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/trigger.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/views.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/views.cpython-314.pyc index e0c87ef..d69f85d 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/views.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/views.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/config_flow_options_task_base.py b/custom_components/maintenance_supporter/config_flow_options_task_base.py index e4cdbea..522158c 100644 --- a/custom_components/maintenance_supporter/config_flow_options_task_base.py +++ b/custom_components/maintenance_supporter/config_flow_options_task_base.py @@ -195,9 +195,47 @@ class _OptionsFlowBase(TriggerConfigMixin, OptionsFlow): def _show_task_action_menu(self) -> ConfigFlowResult: """Show the task_action menu (sync helper for callbacks).""" tasks_data = self.config_entry.data.get(CONF_TASKS, {}) - task = tasks_data.get(self._selected_task_id or "", {}) + task_id = self._selected_task_id or "" + task = tasks_data.get(task_id, {}) return self.async_show_menu( step_id="task_action", menu_options=self._build_task_action_menu(), - description_placeholders={"task_name": task.get("name", "Unknown")}, + description_placeholders={ + "task_name": task.get("name", "Unknown"), + "next_dates": self._next_dates_line(task_id, task), + }, ) + + def _next_dates_line(self, task_id: str, static_task: dict[str, Any]) -> str: + """The task's next three due dates — the flow-side twin of the panel's + live schedule preview (#83), computed by the SAME engine helper + (helpers.schedule.preview_occurrences) so the two surfaces stay DRY. + + Rendered ISO (YYYY-MM-DD): server-side text has no user locale, and + ISO is the only order-unambiguous form. Menus re-render after every + schedule edit, so the line updates right after saving.""" + from datetime import date as date_cls + + from homeassistant.util import dt as dt_util + + from .helpers.schedule import Schedule, preview_occurrences + + merged: dict[str, Any] = dict(static_task) + rd = getattr(self.config_entry, "runtime_data", None) + store = getattr(rd, "store", None) if rd else None + if store is not None: + merged = store.merge_task_data(task_id, merged) + + try: + sched = Schedule.parse(merged) + lp_raw = merged.get("last_performed") + lp = date_cls.fromisoformat(lp_raw[:10]) if isinstance(lp_raw, str) and lp_raw else None + times = sum(1 for e in merged.get("history") or [] if e.get("type") == "completed") + dates, _ended = preview_occurrences( + sched, last_performed=lp, times_performed=times, today=dt_util.now().date() + ) + except (TypeError, ValueError): # pragma: no cover — malformed legacy data + return "—" + if not dates: + return "—" + return " · ".join(d.isoformat() for d in dates) diff --git a/custom_components/maintenance_supporter/config_flow_options_task_crud.py b/custom_components/maintenance_supporter/config_flow_options_task_crud.py index f5f6c78..0235e09 100644 --- a/custom_components/maintenance_supporter/config_flow_options_task_crud.py +++ b/custom_components/maintenance_supporter/config_flow_options_task_crud.py @@ -63,6 +63,21 @@ if TYPE_CHECKING: from homeassistant.core import HomeAssistant +class _OptionalTimeSelector(selector.TimeSelector): + """TimeSelector that also accepts "". + + The schedule_time field defaults to "" (no time stored) and is cleared by + submitting "" — a bare TimeSelector rejects both, which 400s every save of + the untouched form. Wrapping in vol.Any is no option: voluptuous_serialize + cannot convert it and the form itself would 500. Subclassing keeps the + serialized schema a plain time selector.""" + + def __call__(self, data: Any) -> Any: + if data == "": + return "" + return super().__call__(data) + + class TaskCrudMixin: """Manage, edit, delete tasks + checklist editing.""" @@ -311,7 +326,12 @@ class TaskCrudMixin: continue user_options.append(selector.SelectOptionDict(value=user.id, label=user.name or user.id)) - user_id_default = task.get(CONF_RESPONSIBLE_USER_ID, "") + # Guard the default: a stored None (or a user since deleted, hence no + # longer in the dropdown) would fail the select's own validation and + # 400 the whole form on save. + user_id_default = task.get(CONF_RESPONSIBLE_USER_ID) or "" + if user_id_default not in {o["value"] for o in user_options}: + user_id_default = "" user_id_key = vol.Optional(CONF_RESPONSIBLE_USER_ID, default=user_id_default) # Rotation pool options = the real users (no empty sentinel). pool_options = [o for o in user_options if o["value"]] @@ -379,7 +399,7 @@ class TaskCrudMixin: vol.Optional( CONF_TASK_SCHEDULE_TIME, default=task.get("schedule_time", ""), - ): selector.TimeSelector(), + ): _OptionalTimeSelector(), } if self._get_global_options().get(CONF_ADVANCED_SCHEDULE_TIME, False) else dict[Any, Any]() @@ -403,8 +423,7 @@ class TaskCrudMixin: # Seasonal window + finite-series end — recurring kinds only. **( season_ends_schema(task.get("schedule")) - if sched["schedule_type"] == ScheduleType.TIME_BASED - or sched["schedule_type"] in CALENDAR_KIND_VALUES + if sched["schedule_type"] == ScheduleType.TIME_BASED or sched["schedule_type"] in CALENDAR_KIND_VALUES else dict[Any, Any]() ), vol.Optional( diff --git a/custom_components/maintenance_supporter/config_flow_trigger.py b/custom_components/maintenance_supporter/config_flow_trigger.py index 7551779..626888d 100644 --- a/custom_components/maintenance_supporter/config_flow_trigger.py +++ b/custom_components/maintenance_supporter/config_flow_trigger.py @@ -221,15 +221,22 @@ class TriggerConfigMixin: attr = user_input.get(CONF_TRIGGER_ATTRIBUTE, "_state") entity_ids = getattr(self, "_trigger_entity_ids", [self._trigger_entity_id]) - # Rebuilding from form fields — carry over the panel-managed - # recovery flag (#53) so an options-flow trigger edit doesn't - # silently drop it. + # Rebuilding from form fields — carry over panel-managed keys the + # flow has no fields for, so an options-flow trigger edit doesn't + # silently drop them: the recovery flag (#53) and the counting + # start value (#102/#103 class; the flow's counter step re-writes + # target+delta but never the baseline). prev_tc = self._current_task.get("trigger_config") or {} self._current_task["trigger_config"] = { "entity_id": entity_ids[0] if entity_ids else self._trigger_entity_id, "entity_ids": entity_ids, "attribute": None if attr == "_state" else attr, **({"auto_complete_on_recovery": True} if prev_tc.get("auto_complete_on_recovery") else {}), + **( + {"trigger_baseline_value": prev_tc["trigger_baseline_value"]} + if "trigger_baseline_value" in prev_tc + else {} + ), } return await next_step() diff --git a/custom_components/maintenance_supporter/coordinator.py b/custom_components/maintenance_supporter/coordinator.py index c1d6343..05ed877 100644 --- a/custom_components/maintenance_supporter/coordinator.py +++ b/custom_components/maintenance_supporter/coordinator.py @@ -725,9 +725,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): scope = next((v for v in list_saved_views(self.hass) if v["id"] == scope_view_id), None) if scope is not None: - notifiable = [ - row for row in notifiable if view_matches_task(scope["filters"], row[1]) - ] + notifiable = [row for row in notifiable if view_matches_task(scope["filters"], row[1])] if not notifiable: # No notifications needed — still update the cache @@ -964,7 +962,9 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): completed_by: str | None = None, photo_doc_id: str | None = None, reading_value: float | None = None, - restock_quantity: int | None = None, + restock_quantity: float | None = None, + used_parts: list[dict[str, Any]] | None = None, + auto: bool = False, ) -> None: """Mark a task as completed and persist.""" merged = self._get_merged_tasks_data() @@ -1007,6 +1007,20 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): except (ValueError, TypeError): actual_interval = None + # #99: enrich the per-completion parts selection with names so the + # history entry is readable without a part-id lookup. + enriched_used: list[dict[str, Any]] | None = None + if used_parts is not None: + parts_catalog = self.entry.data.get("parts") or {} + enriched_used = [ + { + "part_id": link["part_id"], + "name": (parts_catalog.get(link["part_id"]) or {}).get("name") or link["part_id"], + "quantity": link.get("quantity", 1), + } + for link in used_parts + ] + task.complete( notes=notes, cost=cost, @@ -1016,6 +1030,8 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): completed_by=completed_by, photo_doc_id=photo_doc_id, reading_value=reading_value, + used_parts=enriched_used, + auto=auto, ) # Link the completion photo to this task so it also surfaces under the @@ -1075,6 +1091,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.entry, merged[task_id], restock_quantity=restock_quantity, + used_parts=used_parts, ) except Exception: _LOGGER.exception("Part consumption failed for task %s", task_id) @@ -1148,6 +1165,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): await self.complete_maintenance( task_id, notes=f"Auto-completed: sensor recovered ({trigger_value:g})", + auto=True, ) async def reset_maintenance( diff --git a/custom_components/maintenance_supporter/entity/__pycache__/__init__.cpython-314.pyc b/custom_components/maintenance_supporter/entity/__pycache__/__init__.cpython-314.pyc index 4742b94..1f962d0 100644 Binary files a/custom_components/maintenance_supporter/entity/__pycache__/__init__.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/__pycache__/entity_base.cpython-314.pyc b/custom_components/maintenance_supporter/entity/__pycache__/entity_base.cpython-314.pyc index ac04266..5243aee 100644 Binary files a/custom_components/maintenance_supporter/entity/__pycache__/entity_base.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/__pycache__/entity_base.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/__pycache__/summary_coordinator.cpython-314.pyc b/custom_components/maintenance_supporter/entity/__pycache__/summary_coordinator.cpython-314.pyc index dcca67a..7d8cebb 100644 Binary files a/custom_components/maintenance_supporter/entity/__pycache__/summary_coordinator.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/__pycache__/summary_coordinator.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/__init__.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/__init__.cpython-314.pyc index 865f16f..553babf 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/__init__.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/base_trigger.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/base_trigger.cpython-314.pyc index c110c6f..f4aba5e 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/base_trigger.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/base_trigger.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/compound.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/compound.cpython-314.pyc index 3887073..6ee0435 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/compound.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/compound.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/counter.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/counter.cpython-314.pyc index f5d45d8..e3f7b5a 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/counter.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/counter.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/runtime.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/runtime.cpython-314.pyc index 2653153..9a6daa3 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/runtime.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/runtime.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/state_change.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/state_change.cpython-314.pyc index 3a28a55..9b4312d 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/state_change.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/state_change.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/threshold.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/threshold.cpython-314.pyc index dac9f4b..64734da 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/threshold.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/threshold.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/counter.py b/custom_components/maintenance_supporter/entity/triggers/counter.py index dde97ab..b01640a 100644 --- a/custom_components/maintenance_supporter/entity/triggers/counter.py +++ b/custom_components/maintenance_supporter/entity/triggers/counter.py @@ -44,7 +44,12 @@ class CounterTrigger(BaseTrigger): # Restore persisted baseline BEFORE super().async_setup() which calls # evaluate(). Without this, evaluate() sets _baseline_value to the # current sensor value, discarding the saved baseline from the Store. - if self._delta_mode and self._baseline_value is None: + # The Store baseline wins even over an explicit trigger_baseline_value + # from the config: the config value is only the INITIAL baseline, while + # the Store holds the LIVING one — after a completion re-baselines to + # e.g. 80, falling back to the config's 0 on restart would re-fire the + # just-completed task (issue #102 family). + if self._delta_mode: saved = self.config.get("_trigger_state", {}).get(self.entity_id, {}).get("baseline_value") if saved is not None: self._baseline_value = saved @@ -154,6 +159,13 @@ class CounterTrigger(BaseTrigger): entity_id=self.entity_id, immediate=True, ) + # Surface the moved baseline in the coordinator read-model right + # away. Without this, a freshly adopted delta task shows no delta + # for up to a full update interval and the panel's progress bar + # falls back to the RAW counter value — a 27,000 km odometer reads + # as "27000/15000, overdue" (issue #102). Debounced upstream, and + # the periodic refresh never writes baselines, so no loop. + await self._coordinator.async_request_refresh() def reset(self) -> None: """Reset trigger and baseline.""" diff --git a/custom_components/maintenance_supporter/entity/triggers/runtime.py b/custom_components/maintenance_supporter/entity/triggers/runtime.py index 062a504..0f1ad8c 100644 --- a/custom_components/maintenance_supporter/entity/triggers/runtime.py +++ b/custom_components/maintenance_supporter/entity/triggers/runtime.py @@ -3,6 +3,12 @@ Tracks accumulated 'on' time of a binary entity (input_boolean, switch, binary_sensor, etc.) and triggers when the total runtime reaches a configured threshold in hours. + +When ``attribute`` is configured, the tracked value is that ATTRIBUTE of the +entity instead of its state — a climate entity's ``hvac_action`` says whether +the unit is actually conditioning (cooling/heating/fan), while its state only +reports the standby MODE. State-change events fire on attribute changes too, +so the same listener covers both. """ from __future__ import annotations @@ -11,7 +17,7 @@ import logging from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback from homeassistant.helpers.event import ( EventStateChangedData, async_track_state_change_event, @@ -87,7 +93,7 @@ class RuntimeTrigger(BaseTrigger): return # Entity exists — check if currently ON - if self._is_on(state.state): + if self._is_on(self._tracked_value(state)): if self._on_since_dt is None: # No restored timestamp — start tracking from now now = dt_util.utcnow() @@ -154,7 +160,9 @@ class RuntimeTrigger(BaseTrigger): if new_state is None: return - new_val = new_state.state + new_val = self._tracked_value(new_state) + # Availability is judged on the raw STATE even in attribute mode. + raw_state = new_state.state # Entity appeared for the first time if old_state is None: @@ -172,10 +180,10 @@ class RuntimeTrigger(BaseTrigger): self._update_evaluation() return - old_val = old_state.state + old_val = self._tracked_value(old_state) # Handle unavailable/unknown — pause accumulation - if new_val in ("unavailable", "unknown"): + if raw_state in ("unavailable", "unknown"): if self._on_since_dt is not None: self._accumulate_elapsed() self._on_since_dt = None @@ -185,7 +193,7 @@ class RuntimeTrigger(BaseTrigger): _LOGGER.warning( "Runtime trigger entity %s became %s (runtime paused)", self.entity_id, - new_val, + raw_state, ) self._logged_unavailable = True return @@ -236,6 +244,12 @@ class RuntimeTrigger(BaseTrigger): """Check if state represents 'on'.""" return state_value.lower() in self._on_states + def _tracked_value(self, state: State) -> str: + """The tracked string: the configured attribute if set, else the state.""" + if self.attribute: + return str(state.attributes.get(self.attribute, "") or "") + return str(state.state) + def _accumulate_elapsed(self, now: datetime | None = None) -> None: """Add elapsed time since _on_since to accumulated total.""" if self._on_since_dt is None: diff --git a/custom_components/maintenance_supporter/entity/triggers/state_change.py b/custom_components/maintenance_supporter/entity/triggers/state_change.py index 95aba7a..d75f495 100644 --- a/custom_components/maintenance_supporter/entity/triggers/state_change.py +++ b/custom_components/maintenance_supporter/entity/triggers/state_change.py @@ -19,6 +19,11 @@ from .base_trigger import BaseTrigger _LOGGER = logging.getLogger(__name__) +def _norm_state(value: str | None) -> str | None: + """Case/whitespace-insensitive state form for from/to comparisons.""" + return value.strip().casefold() if isinstance(value, str) else value + + class StateChangeTrigger(BaseTrigger): """Trigger that activates after counting state transitions. @@ -35,8 +40,13 @@ class StateChangeTrigger(BaseTrigger): """Initialize state change trigger.""" super().__init__(hass, entity, trigger_config) - self._from_state: str | None = trigger_config.get("trigger_from_state") - self._to_state: str | None = trigger_config.get("trigger_to_state") + # Case-insensitive matching, like RuntimeTrigger's on_states: the + # options flow lowercases these on save while the panel keeps the + # user's casing, and HA states themselves can be capitalized + # (input_select "Home") — normalizing BOTH sides at compare time is + # the only variant that works for every surface combination. + self._from_state: str | None = _norm_state(trigger_config.get("trigger_from_state")) + self._to_state: str | None = _norm_state(trigger_config.get("trigger_to_state")) self._target_changes: int = trigger_config.get("trigger_target_changes", 1) # Restore persisted change count from config, default to 0 self._change_count: int = trigger_config.get("trigger_change_count", 0) @@ -64,12 +74,27 @@ class StateChangeTrigger(BaseTrigger): # Restore triggered state from persisted change count if self._change_count >= self._target_changes: - self._triggered = True - self.entity.async_update_trigger_state( - is_triggered=True, - current_value=float(self._change_count), - trigger_entity_id=self.entity_id, - ) + # Latch reconciliation: a single-shot state alarm (target_changes + # == 1) that already left its alert state while we were down is no + # longer active. Clear it quietly — the recovery transition was + # never observed, so we must NOT auto-complete for it here (that + # path only runs on a live off event, guarded against double-count). + if ( + self._to_state is not None + and self._target_changes == 1 + and _norm_state(state.state) != self._to_state + ): + self._change_count = 0 + self._current_value = 0.0 + if self.hass.is_running: + self.hass.async_create_task(self._persist_change_count()) + else: + self._triggered = True + self.entity.async_update_trigger_state( + is_triggered=True, + current_value=float(self._change_count), + trigger_entity_id=self.entity_id, + ) # Register state change listener (override base: we handle events differently) self._unsub_listener = async_track_state_change_event(self.hass, [self.entity_id], self._handle_state_transition) @@ -137,9 +162,9 @@ class StateChangeTrigger(BaseTrigger): # Check if transition matches pattern matches = True - if self._from_state is not None and effective_old != self._from_state: + if self._from_state is not None and _norm_state(effective_old) != self._from_state: matches = False - if self._to_state is not None and new_val != self._to_state: + if self._to_state is not None and _norm_state(new_val) != self._to_state: matches = False if matches and effective_old != new_val: @@ -166,6 +191,25 @@ class StateChangeTrigger(BaseTrigger): elif not is_triggered and was_triggered: self._on_trigger_deactivated(float(self._change_count)) + # Latch recovery: a single-shot state alarm (target_changes == 1 — an + # adopted problem sensor or an appliance event) clears when the entity + # leaves its alert state. Reset the counter so the next occurrence can + # fire again, and run the deactivation path — which auto-completes on + # recovery when opted in. Multi-count triggers keep accumulating and + # only reset on manual completion, so they are untouched here. + elif ( + self._to_state is not None + and self._target_changes == 1 + and self._triggered + and _norm_state(new_val) != self._to_state + ): + self._change_count = 0 + self._current_value = 0.0 + if self.hass.is_running: + self.hass.async_create_task(self._persist_change_count()) + self._triggered = False + self._on_trigger_deactivated(0.0) + self._last_state = new_val def evaluate(self, value: float) -> bool: diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/date-format.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/date-format.test.ts index 68f70b5..438754d 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/date-format.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/date-format.test.ts @@ -34,6 +34,16 @@ describe("formatDate with HA profile date_format (#97)", () => { expect(formatDate("2026-08-10", "de")).to.equal("10.08.2026"); }); + it("pl/cs/sv derive their own locale, not en-US (live-check find)", () => { + setDateTimePrefs({ date_format: "language" }); + // These three were missing from langToLocale and silently rendered + // US-ordered dates. Day must come before month for all of them. + expect(formatDate("2026-08-10", "pl")).to.equal("10.08.2026"); + expect(formatDate("2026-08-10", "cs")).to.equal("10. 08. 2026"); // Czech spaces its dots + expect(formatDate("2026-08-10", "sv")).to.equal("2026-08-10"); // sv-SE = ISO order + reset(); + }); + it("null/invalid input unchanged by prefs", () => { setDateTimePrefs({ date_format: "DMY" }); expect(formatDate(null, "en")).to.equal("—"); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/panel-budget-spent-only.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/panel-budget-spent-only.test.ts new file mode 100644 index 0000000..ecda9cf --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/panel-budget-spent-only.test.ts @@ -0,0 +1,51 @@ +/** + * Dashboard budget display: spent totals without a maximum (#104). + * + * With budget tracking enabled but no monthly/yearly maximum configured, + * the spent totals used to be invisible (a bar needs a denominator). + * Pins: spent-only lines render without a bar, a configured maximum still + * renders the classic bar, and the mixed case shows one of each. + */ + +import { expect } from "@open-wc/testing"; +import { DEFAULT_FEATURES, DEFAULT_SETTINGS_RESPONSE } from "./_test-utils.js"; +import { mountPanel, obj, resetTaskSeq, sr, task } from "./_panel-utils.js"; + +function handlers(budget: Record) { + return { + "maintenance_supporter/settings": () => ({ + ...DEFAULT_SETTINGS_RESPONSE, + features: { ...DEFAULT_FEATURES, budget: true }, + }), + "maintenance_supporter/budget_status": () => ({ + alert_threshold_pct: 80, + currency_symbol: "€", + ...budget, + }), + }; +} + +describe("dashboard budget: spent-only display (#104)", () => { + beforeEach(() => resetTaskSeq()); + + it("no maximum set: renders spent lines without bars", async () => { + const { el } = await mountPanel([obj("e1", [task()])], handlers({ + monthly_budget: 0, yearly_budget: 0, monthly_spent: 9, yearly_spent: 429.6, + })); + const items = sr(el).querySelectorAll(".budget-spent-only"); + expect(items.length).to.equal(2); + expect(items[0].textContent).to.contain("9.00 €"); + expect(items[1].textContent).to.contain("429.60 €"); + expect(sr(el).querySelectorAll(".budget-bar").length).to.equal(0); + }); + + it("mixed: monthly maximum renders a bar, yearly stays spent-only", async () => { + const { el } = await mountPanel([obj("e1", [task()])], handlers({ + monthly_budget: 150, yearly_budget: 0, monthly_spent: 9, yearly_spent: 429.6, + })); + expect(sr(el).querySelectorAll(".budget-bar").length).to.equal(1); + const spentOnly = sr(el).querySelectorAll(".budget-spent-only"); + expect(spentOnly.length).to.equal(1); + expect(spentOnly[0].textContent).to.contain("429.60 €"); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/suggested-setups-baseline.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/suggested-setups-baseline.test.ts new file mode 100644 index 0000000..92de802 --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/suggested-setups-baseline.test.ts @@ -0,0 +1,112 @@ +/** + * : the optional counting start value + * (#102 — "the last service was at reading X"). + * + * Pins: usage_delta duties on a selected row render the baseline input + * (other directions don't), an entered value is forwarded per task in the + * adopt payload, and empty/invalid values are omitted. + */ + +import { expect, fixture, html } from "@open-wc/testing"; +import "../components/suggested-setups-dialog.js"; +import type { MaintenanceSuggestedSetupsDialog } from "../components/suggested-setups-dialog"; +import { type SentMessage, createMockHass } from "./_test-utils.js"; + +const SETUPS = [ + { + device_id: "car1", device_name: "Kia EV6", area_name: "Garage", + integration: "kia_uvo", integration_name: "Kia Uvo", + suggested_entry_id: null, suggested_object_name: "Kia EV6", + tasks: [ + { + task_name: "Annual Service", entity_ids: ["sensor.kia_odometer"], + threshold: 15000, direction: "usage_delta", + }, + { + task_name: "Tire Rotation", entity_ids: ["sensor.kia_odometer"], + threshold: 10000, direction: "usage_delta", + }, + ], + }, + { + device_id: "vac1", device_name: "Roborock", area_name: null, + integration: "roborock", integration_name: "Roborock", + suggested_entry_id: null, suggested_object_name: "Roborock", + tasks: [ + { + task_name: "Replace Filter", entity_ids: ["sensor.vac_filter"], + threshold: 24, direction: "duration_left", + }, + ], + }, +]; + +async function mountOpen(): Promise<{ el: MaintenanceSuggestedSetupsDialog; sent: SentMessage[] }> { + const { hass, sent } = createMockHass({ + handlers: { + "maintenance_supporter/integration_setups/discover": () => ({ setups: SETUPS }), + "maintenance_supporter/objects": () => ({ + objects: [{ entry_id: "obj_existing", object: { name: "My Vacuum" } }], + }), + "maintenance_supporter/integration_setups/adopt": () => ({ + tasks_created: 3, objects_created: 2, total: 2, + }), + }, + }); + const el = await fixture(html` + + `); + await el.open(); + await el.updateComplete; + return { el, sent }; +} + +describe("suggested-setups dialog: counting start value (#102)", () => { + it("renders baseline inputs for usage_delta duties only", async () => { + const { el } = await mountOpen(); + const rows = el.shadowRoot!.querySelectorAll(".row"); + expect(rows.length).to.equal(2); + expect(rows[0].querySelectorAll(".baseline-field").length).to.equal(2); // both car duties + expect(rows[1].querySelectorAll(".baseline-field").length).to.equal(0); // duration_left + }); + + it("target picker: choosing an existing object forwards its entry_id (#105)", async () => { + const { el, sent } = await mountOpen(); + const selects = el.shadowRoot!.querySelectorAll(".target-select"); + expect(selects.length).to.equal(2); // one per selected row + // First row (car1): pick the existing object instead of "create new". + selects[0].value = "obj_existing"; + selects[0].dispatchEvent(new Event("change", { bubbles: true })); + await el.updateComplete; + + el.shadowRoot!.querySelectorAll("ha-button")[1].click(); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + + const adopt = sent.find((m) => m.type === "maintenance_supporter/integration_setups/adopt")! as { + selections: Array<{ device_id: string; entry_id?: string }>; + }; + const byId = Object.fromEntries(adopt.selections.map((s) => [s.device_id, s])); + expect(byId["car1"].entry_id).to.equal("obj_existing"); + expect(byId["vac1"].entry_id).to.equal(undefined); // untouched row keeps default + }); + + it("forwards entered start values per task and omits empty ones", async () => { + const { el, sent } = await mountOpen(); + const input = el.shadowRoot!.querySelector(".baseline-field input")!; + input.value = "12000"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await el.updateComplete; + + el.shadowRoot!.querySelectorAll("ha-button")[1].click(); // Set up selected + await el.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + + const adopt = sent.find((m) => m.type === "maintenance_supporter/integration_setups/adopt")! as { + selections: Array<{ device_id: string; baselines?: Record }>; + }; + const byId = Object.fromEntries(adopt.selections.map((s) => [s.device_id, s])); + expect(byId["car1"].baselines).to.deep.equal({ "Annual Service": 12000 }); + expect(byId["vac1"].baselines).to.equal(undefined); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-baseline-field.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-baseline-field.test.ts new file mode 100644 index 0000000..86d9f3f --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-baseline-field.test.ts @@ -0,0 +1,68 @@ +/** + * : the delta-counter start-value field (#102). + * + * Pins the create/edit split: creating shows the "count from the current + * reading" help, while editing shows the "keep the existing counting" help + * plus the LIVE effective anchor (Store baseline from the read-model) — an + * adopted delta task has no config baseline, so without the live line the + * field would read as empty/zero even though counting is anchored. + */ + +import { expect, fixture, html } from "@open-wc/testing"; +import "../components/task-dialog.js"; +import type { MaintenanceTaskDialog } from "../components/task-dialog"; +import { createMockHass } from "./_test-utils.js"; + +async function mountDialog(): Promise { + const { hass } = createMockHass({ states: { "sensor.odometer": { state: "27100" } } }); + const el = await fixture(html` + + `); + await el.updateComplete; + return el; +} + +describe("task-dialog delta start-value field (#102)", () => { + it("edit mode shows the edit help and the live effective anchor", async () => { + const el = await mountDialog(); + await el.openEdit("entry_x", { + id: "t1", + name: "Annual Service", + type: "custom", + schedule_type: "sensor_based", + warning_days: 7, + enabled: true, + trigger_config: { + type: "counter", + entity_id: "sensor.odometer", + trigger_target_value: 15000, + trigger_delta_mode: true, + }, + trigger_baseline_value: 27000, // live anchor from the read-model + } as any); + await el.updateComplete; + + const helps = [...el.shadowRoot!.querySelectorAll(".field-help")] + .map((n) => n.textContent ?? "") + .join(" | "); + expect(helps).to.contain("keep the existing counting"); + const effective = el.shadowRoot!.querySelector(".baseline-effective"); + expect(effective?.textContent).to.contain("27000"); + }); + + it("create mode shows the count-from-current help and no effective line", async () => { + const el = await mountDialog(); + await el.openCreate("entry_x", []); + await el.updateComplete; + (el as any)._scheduleType = "sensor_based"; + (el as any)._triggerType = "counter"; + (el as any)._triggerDeltaMode = true; + await el.updateComplete; + + const helps = [...el.shadowRoot!.querySelectorAll(".field-help")] + .map((n) => n.textContent ?? "") + .join(" | "); + expect(helps).to.contain("count from the current value"); + expect(el.shadowRoot!.querySelector(".baseline-effective")).to.equal(null); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-runtime-onstates.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-runtime-onstates.test.ts new file mode 100644 index 0000000..97a2a39 --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-runtime-onstates.test.ts @@ -0,0 +1,76 @@ +/** + * : runtime trigger_on_states in the UI (#103). + * + * Pins: the field is rendered for runtime triggers, an adopted task's + * on_states (e.g. ["mowing"]) hydrate into it and SURVIVE a save roundtrip + * (before this fix the dialog rebuilt trigger_config without + * trigger_on_states — any edit silently reset a mower task to ["on"] and + * stopped the accumulation), and an empty field omits the key (backend + * default ["on"]). + */ + +import { expect, fixture, html } from "@open-wc/testing"; +import "../components/task-dialog.js"; +import type { MaintenanceTaskDialog } from "../components/task-dialog"; +import { type SentMessage, createMockHass } from "./_test-utils.js"; + +async function mountDialog(): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> { + const { hass, sent } = createMockHass({ + states: { "lawn_mower.navi": { state: "mowing" } }, + handlers: { "maintenance_supporter/task/update": () => ({ success: true }) }, + }); + const el = await fixture(html` + + `); + await el.updateComplete; + return { el, sent }; +} + +const MOWER_TASK = { + id: "t1", + name: "Replace Blades", + type: "replacement", + schedule_type: "sensor_based", + warning_days: 7, + enabled: true, + trigger_config: { + type: "runtime", + entity_id: "lawn_mower.navi", + entity_ids: ["lawn_mower.navi"], + trigger_runtime_hours: 100, + trigger_on_states: ["mowing"], + }, +}; + +describe("task-dialog runtime on-states (#103)", () => { + it("hydrates adopted on_states into the field", async () => { + const { el } = await mountDialog(); + await el.openEdit("entry_x", MOWER_TASK as any); + await el.updateComplete; + expect((el as any)._triggerOnStates).to.equal("mowing"); + }); + + it("on_states survive a save roundtrip (no silent reset to ['on'])", async () => { + const { el, sent } = await mountDialog(); + await el.openEdit("entry_x", MOWER_TASK as any); + await el.updateComplete; + await (el as any)._save(); + const update = sent.find((m) => m.type === "maintenance_supporter/task/update")! as { + trigger_config: { trigger_on_states?: string[] }; + }; + expect(update.trigger_config.trigger_on_states).to.deep.equal(["mowing"]); + }); + + it("an empty field omits trigger_on_states (backend default)", async () => { + const { el, sent } = await mountDialog(); + await el.openEdit("entry_x", MOWER_TASK as any); + await el.updateComplete; + (el as any)._triggerOnStates = ""; + await el.updateComplete; + await (el as any)._save(); + const update = sent.find((m) => m.type === "maintenance_supporter/task/update")! as { + trigger_config: { trigger_on_states?: string[] }; + }; + expect(update.trigger_config.trigger_on_states).to.equal(undefined); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-schedule-preview.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-schedule-preview.test.ts new file mode 100644 index 0000000..5d91ea5 --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-schedule-preview.test.ts @@ -0,0 +1,79 @@ +/** + * : the live "next dates" schedule preview (#83). + * + * Pins: the dialog debounce-fetches maintenance_supporter/schedule/preview + * with the DRAFT schedule (engine dict form, mirroring the save mapping), + * renders the returned occurrences as weekday-prefixed dates, appends the + * series-end hint, and hides the box for manual schedules. + */ + +import { expect, fixture, html } from "@open-wc/testing"; +import "../components/task-dialog.js"; +import type { MaintenanceTaskDialog } from "../components/task-dialog"; +import { type SentMessage, createMockHass } from "./_test-utils.js"; + +async function mountCreate(previewResponse: { + occurrences: string[]; + series_ended: boolean; +}): Promise<{ el: MaintenanceTaskDialog; sent: SentMessage[] }> { + const { hass, sent } = createMockHass({ + handlers: { + "maintenance_supporter/schedule/preview": () => previewResponse, + }, + }); + const el = await fixture(html` + + `); + await el.updateComplete; + await el.openCreate("entry_x", []); + await el.updateComplete; + return { el, sent }; +} + +const settle = async (el: MaintenanceTaskDialog) => { + await new Promise((r) => setTimeout(r, 400)); // debounce is 300 ms + await el.updateComplete; +}; + +describe("task-dialog schedule preview (#83)", () => { + it("fetches the draft schedule and renders weekday-prefixed dates", async () => { + const { el, sent } = await mountCreate({ + occurrences: ["2027-01-09", "2027-07-10", "2028-01-08"], + series_ended: false, + }); + (el as any)._scheduleType = "nth_weekday"; + (el as any)._nth = "2"; + (el as any)._nthWeekday = "5"; + (el as any)._seasonMonths = [1, 7]; + await settle(el); + + const req = sent.find((m) => m.type === "maintenance_supporter/schedule/preview") as any; + expect(req, "preview request sent").to.exist; + expect(req.schedule).to.deep.include({ kind: "nth_weekday", nth: 2, weekday: 5 }); + expect(req.schedule.season_months).to.deep.equal([1, 7]); + + const box = el.shadowRoot!.querySelector(".schedule-preview"); + expect(box, "preview box rendered").to.exist; + const text = box!.textContent!.replace(/\s+/g, " "); + expect(text).to.contain("2027"); + expect(text).to.match(/Sat|Sa/); // weekday prefix from the shared helper + }); + + it("shows the series-end hint and hides for manual schedules", async () => { + const { el } = await mountCreate({ + occurrences: ["2026-07-26", "2026-08-02"], + series_ended: true, + }); + (el as any)._scheduleType = "time_based"; + (el as any)._intervalDays = "7"; + (el as any)._endsMode = "count"; + (el as any)._endsCount = "2"; + await settle(el); + const box = el.shadowRoot!.querySelector(".schedule-preview"); + expect(box!.textContent).to.contain("series ends"); + + (el as any)._scheduleType = "manual"; + await settle(el); + expect(el.shadowRoot!.querySelector(".schedule-preview")).to.equal(null); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-roundtrip.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-roundtrip.test.ts new file mode 100644 index 0000000..2752c04 --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-roundtrip.test.ts @@ -0,0 +1,182 @@ +/** + * : full trigger_config save-roundtrip closure + * (#103 class). + * + * The dialog rebuilds trigger_config from scratch on save, so every key the + * engine knows must survive openEdit -> _save unchanged. Pins a MAXIMAL + * config per trigger type — including per-condition attribute / baseline / + * entity_logic inside compound triggers, which travel through the editor's + * `carry` passthrough without having form fields. + */ + +import { expect, fixture, html } from "@open-wc/testing"; +import "../components/task-dialog.js"; +import type { MaintenanceTaskDialog } from "../components/task-dialog"; +import { type SentMessage, createMockHass } from "./_test-utils.js"; + +async function saveRoundtrip(triggerConfig: Record): Promise> { + const { hass, sent } = createMockHass({ + states: { "sensor.a": { state: "42" }, "sensor.b": { state: "7" } }, + handlers: { "maintenance_supporter/task/update": () => ({ success: true }) }, + }); + const el = await fixture(html` + + `); + await el.updateComplete; + await el.openEdit("entry_x", { + id: "t1", + name: "Roundtrip", + type: "custom", + schedule_type: "sensor_based", + warning_days: 7, + enabled: true, + trigger_config: triggerConfig, + } as any); + await el.updateComplete; + await (el as any)._save(); + const update = sent.find((m) => m.type === "maintenance_supporter/task/update") as any; + expect(update, "update message sent").to.exist; + return update.trigger_config; +} + +describe("task-dialog trigger_config roundtrip closure (#103 class)", () => { + it("threshold: attribute, both bounds, for_minutes, entity_logic, recovery", async () => { + const tc = await saveRoundtrip({ + type: "threshold", + entity_id: "sensor.a", + entity_ids: ["sensor.a", "sensor.b"], + entity_logic: "all", + attribute: "level", + trigger_above: 80, + trigger_below: 10, + trigger_for_minutes: 5, + auto_complete_on_recovery: true, + }); + expect(tc).to.deep.include({ + type: "threshold", + entity_logic: "all", + attribute: "level", + trigger_above: 80, + trigger_below: 10, + trigger_for_minutes: 5, + auto_complete_on_recovery: true, + }); + }); + + it("threshold stored with ONLY plural entity_ids survives an edit (#106)", async () => { + // The Battery Fleet task's trigger has no singular entity_id; the save + // path gates on _triggerEntityId, so before the hydration fallback an + // unrelated edit sent trigger_config: null and wiped the trigger. + const tc = await saveRoundtrip({ + type: "threshold", + entity_ids: ["sensor.a"], + entity_logic: "any", + trigger_above: 0, + auto_complete_on_recovery: true, + }); + expect(tc, "trigger_config must not be nulled").to.exist; + expect(tc).to.deep.include({ + type: "threshold", + entity_id: "sensor.a", + trigger_above: 0, + auto_complete_on_recovery: true, + }); + expect(tc.entity_ids).to.deep.equal(["sensor.a"]); + }); + + it("counter: delta mode with start value", async () => { + const tc = await saveRoundtrip({ + type: "counter", + entity_id: "sensor.a", + entity_ids: ["sensor.a"], + trigger_target_value: 15000, + trigger_delta_mode: true, + trigger_baseline_value: 12000, + }); + expect(tc).to.deep.include({ + type: "counter", + trigger_target_value: 15000, + trigger_delta_mode: true, + trigger_baseline_value: 12000, + }); + }); + + it("runtime: on_states and attribute", async () => { + const tc = await saveRoundtrip({ + type: "runtime", + entity_id: "sensor.a", + entity_ids: ["sensor.a"], + trigger_runtime_hours: 250, + trigger_on_states: ["cooling", "heating"], + attribute: "hvac_action", + }); + expect(tc).to.deep.include({ + type: "runtime", + trigger_runtime_hours: 250, + attribute: "hvac_action", + }); + expect(tc.trigger_on_states).to.deep.equal(["cooling", "heating"]); + }); + + it("state_change: from/to states and target changes", async () => { + const tc = await saveRoundtrip({ + type: "state_change", + entity_id: "sensor.a", + entity_ids: ["sensor.a"], + trigger_from_state: "unlocked", + trigger_to_state: "locked", + trigger_target_changes: 500, + auto_complete_on_recovery: true, + }); + expect(tc).to.deep.include({ + type: "state_change", + trigger_from_state: "unlocked", + trigger_to_state: "locked", + trigger_target_changes: 500, + auto_complete_on_recovery: true, + }); + }); + + it("compound: per-condition attribute/baseline/entity_logic survive via carry", async () => { + const tc = await saveRoundtrip({ + type: "compound", + compound_logic: "OR", + conditions: [ + { + type: "runtime", + entity_id: "sensor.a", + entity_ids: ["sensor.a"], + trigger_runtime_hours: 500, + trigger_on_states: ["printing"], + attribute: "job_state", + }, + { + type: "counter", + entity_id: "sensor.b", + entity_ids: ["sensor.b"], + trigger_target_value: 100, + trigger_delta_mode: true, + trigger_baseline_value: 40, + entity_logic: "any", + }, + ], + }); + expect(tc.type).to.equal("compound"); + expect(tc.compound_logic).to.equal("OR"); + const conds = tc.conditions as Array>; + expect(conds).to.have.length(2); + expect(conds[0]).to.deep.include({ + type: "runtime", + trigger_runtime_hours: 500, + attribute: "job_state", + }); + expect(conds[0].trigger_on_states).to.deep.equal(["printing"]); + expect(conds[1]).to.deep.include({ + type: "counter", + trigger_target_value: 100, + trigger_delta_mode: true, + trigger_baseline_value: 40, + entity_logic: "any", + }); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/trigger-progress-delta.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/trigger-progress-delta.test.ts new file mode 100644 index 0000000..61469bf --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/trigger-progress-delta.test.ts @@ -0,0 +1,54 @@ +/** + * renderTriggerProgress: delta-mode counters must never fall back to the RAW + * counter value (issue #102 — a 27,000 km odometer with a 15,000 km interval + * rendered as a full red "27000/15000" bar right after adoption, before the + * baseline reached the read-model). Pins: delta used when present, computed + * from baseline when only the baseline is known, NOTHING rendered while the + * baseline is still unknown, and absolute-mode counters keep the raw value. + */ + +import { expect, fixture, html } from "@open-wc/testing"; +import { renderTriggerProgress } from "../renderers/progress.js"; +import type { TaskRow } from "../types"; + +function row(overrides: Partial): TaskRow { + return { + trigger_config: { + type: "counter", + trigger_target_value: 15000, + trigger_delta_mode: true, + }, + trigger_current_value: 27000, + trigger_current_delta: null, + trigger_baseline_value: null, + ...overrides, + } as unknown as TaskRow; +} + +async function labelOf(r: TaskRow): Promise { + const el = await fixture(html`
${renderTriggerProgress(r)}
`); + return el.querySelector(".trigger-progress-label")?.textContent?.trim() ?? null; +} + +describe("renderTriggerProgress — delta-mode counter (issue #102)", () => { + it("renders nothing while the baseline is still unknown (no raw-value lie)", async () => { + expect(await labelOf(row({}))).to.equal(null); + }); + + it("uses the exposed delta when present", async () => { + const label = await labelOf(row({ trigger_current_delta: 100 })); + expect(label).to.contain("100.0 / 15000"); + }); + + it("computes the delta from the baseline when only the baseline is exposed", async () => { + const label = await labelOf(row({ trigger_baseline_value: 27000 })); + expect(label).to.contain("0.0 / 15000"); + }); + + it("keeps the raw value for absolute-mode counters", async () => { + const label = await labelOf( + row({ trigger_config: { type: "counter", trigger_target_value: 30000 } as never }), + ); + expect(label).to.contain("27000.0 / 30000"); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts index 7796bf6..2b9b8ce 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts @@ -11,7 +11,8 @@ import { property, state } from "lit/decorators.js"; import { t, ensureLocale } from "../styles"; import { describeWsError } from "../ws-errors"; -import type { HomeAssistant } from "../types"; +import { UserService } from "../user-service"; +import type { HAUser, HomeAssistant } from "../types"; interface ProblemSensor { entity_id: string; @@ -33,6 +34,7 @@ interface DiscoverResponse { interface AdoptResponse { tasks_created: number; objects_created: number; + created: Array<{ entry_id: string; task_id: string; name: string }>; total: number; errors?: string[]; } @@ -46,8 +48,11 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { @state() private _error = ""; @state() private _sensors: ProblemSensor[] = []; @state() private _selected: Set = new Set(); + @state() private _users: HAUser[] = []; + @state() private _responsible = ""; private _localeReady = false; + private _userService: UserService | null = null; private get _lang(): string { return this.hass?.language || "en"; @@ -66,12 +71,20 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { this._error = ""; this._sensors = []; this._selected = new Set(); + this._responsible = ""; try { - const resp = await this.hass.connection.sendMessagePromise({ - type: "maintenance_supporter/problem_sensors/discover", - }); + if (!this._userService) this._userService = new UserService(this.hass); + else this._userService.updateHass(this.hass); + const [resp, users] = await Promise.all([ + this.hass.connection.sendMessagePromise({ + type: "maintenance_supporter/problem_sensors/discover", + }), + // Best-effort: adoption works fine without the user list. + this._userService.getUsers().catch(() => [] as HAUser[]), + ]); this._sensors = resp.sensors || []; this._selected = new Set(this._sensors.map((s) => s.entity_id)); + this._users = users; } catch (e) { this._error = describeWsError(e, this._lang); } finally { @@ -112,6 +125,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { object_name: s.suggested_object_name, device_id: s.device_id ?? undefined, part_id: s.suggested_part_id ?? undefined, + responsible_user_id: this._responsible || undefined, })); const result = await this.hass.connection.sendMessagePromise({ type: "maintenance_supporter/problem_sensors/adopt", @@ -200,6 +214,25 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { `} + ${!this._loading && this._sensors.length > 0 && this._users.length > 0 + ? html` + + ` + : nothing} +
${t("cancel", L)} @@ -234,7 +267,7 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { display: flex; flex-direction: column; gap: 12px; - min-width: 360px; + min-width: min(360px, calc(100vw - 24px)); max-width: 560px; width: 90vw; max-height: 80vh; @@ -341,6 +374,24 @@ export class MaintenanceAdoptProblemSensorsDialog extends LitElement { background: var(--divider-color); color: var(--secondary-text-color); } + .responsible { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--secondary-text-color); + flex-wrap: wrap; + } + .responsible select { + flex: 1; + min-width: 140px; + padding: 4px 6px; + border-radius: 4px; + border: 1px solid var(--divider-color); + background: var(--card-background-color, #fff); + color: var(--primary-text-color); + font-size: 13px; + } .actions { display: flex; justify-content: flex-end; diff --git a/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts b/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts new file mode 100644 index 0000000..1b37149 --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/components/battery-fleet-section.ts @@ -0,0 +1,322 @@ +/** Battery-fleet detail section, rendered inside the single "Replace low + * batteries" task's overview tab (task.battery_fleet_task). Self-contained: + * fetches the live aggregate over Battery Notes and offers the mark-replaced + * action — the fleet's one surface, instead of 30-70 per-battery tasks. */ + +import { css, html, LitElement, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; + +import { t, ensureLocale } from "../styles"; +import { describeWsError } from "../ws-errors"; +import type { HomeAssistant } from "../types"; + +interface BatteryRow { + entity_id: string; + device_name: string; + battery_type: string; + quantity: number; + level: number | null; + days_until: number | null; + available?: boolean; +} +interface Overview { + available: boolean; + configured: boolean; + task_ok?: boolean; + total: number; + low: BatteryRow[]; + soon: BatteryRow[]; + needs_now: Record; + needs_soon: Record; + types: string[]; +} + +export class MaintenanceBatteryFleetSection extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _ov: Overview | null = null; + @state() private _loading = false; + @state() private _marking = false; + @state() private _error = ""; + private _localeReady = false; + + private get _lang(): string { + return this.hass?.language || "en"; + } + + connectedCallback(): void { + super.connectedCallback(); + if (this.hass) this._load(); + } + + updated(changed: Map): void { + if (changed.has("hass") && this.hass && !this._localeReady) { + this._localeReady = true; + ensureLocale(this._lang).then(() => this.requestUpdate()); + if (this._ov === null && !this._loading) this._load(); + } + } + + private async _load(): Promise { + this._loading = true; + this._error = ""; + try { + this._ov = await this.hass.connection.sendMessagePromise({ + type: "maintenance_supporter/battery_fleet/overview", + }); + } catch (e) { + this._error = describeWsError(e, this._lang); + } finally { + this._loading = false; + } + } + + private _markAll = async (): Promise => { + await this._mark(undefined); + }; + + // Re-runs the idempotent setup, which restores the fleet task's trigger + // when a user edit wiped it (issue #106) or recreates a deleted task. + private _repair = async (): Promise => { + if (this._marking) return; + this._marking = true; + this._error = ""; + try { + await this.hass.connection.sendMessagePromise({ + type: "maintenance_supporter/battery_fleet/setup", + }); + await this._load(); + } catch (e) { + this._error = describeWsError(e, this._lang); + } finally { + this._marking = false; + } + }; + + private async _mark(entityIds: string[] | undefined): Promise { + if (this._marking) return; + this._marking = true; + this._error = ""; + try { + await this.hass.connection.sendMessagePromise({ + type: "maintenance_supporter/battery_fleet/mark_replaced", + ...(entityIds ? { entity_ids: entityIds } : {}), + }); + await this._load(); + } catch (e) { + this._error = describeWsError(e, this._lang); + } finally { + this._marking = false; + } + } + + private _shoppingLine(needs: Record): string { + return Object.entries(needs) + .map(([type, qty]) => `${qty}× ${type}`) + .join(" · "); + } + + render() { + const L = this._lang; + if (this._loading && this._ov === null) return html`
`; + const ov = this._ov; + if (!ov) { + return this._error ? html`
${this._error}
` : nothing; + } + const lowCount = ov.low.length; + return html` +
+
+ + ${t("battery_fleet_title", L)} + ${lowCount} +
+ ${this._error ? html`
${this._error}
` : nothing} + + ${ov.configured && ov.task_ok === false + ? html` +
+ ${t("battery_fleet_trigger_lost", L)} + + ${t("battery_fleet_repair", L)} + +
+ ` + : nothing} + + ${lowCount === 0 + ? html`
${t("battery_fleet_none_low", L)}
` + : html` +
+ ${t("battery_fleet_buy_now", L)} + ${this._shoppingLine(ov.needs_now)} +
+
+ ${ov.low.map( + (b) => html` +
+ ${b.device_name} + ${b.available === false + ? html`${t("battery_fleet_offline", L)}` + : nothing} + ${b.quantity}× ${b.battery_type} + ${b.level != null ? html`${b.level}%` : nothing} + +
+ `, + )} +
+
+ + ${t("battery_fleet_mark_all", L)} + +
+ `} + + ${ov.soon.length + ? html` +
+ ${t("battery_fleet_soon", L)} + ${this._shoppingLine(ov.needs_soon)} +
${t("battery_fleet_soon_hint", L)}
+
+ ` + : nothing} +
${t("battery_fleet_total", L).replace("{n}", String(ov.total))}
+
+ `; + } + + static styles = css` + .bf-card { + background: var(--card-background-color, #fff); + border: 1px solid var(--divider-color); + border-radius: 10px; + padding: 14px 16px; + margin: 12px 0; + display: flex; + flex-direction: column; + gap: 10px; + } + .bf-head { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + } + .bf-title { + flex: 1; + } + .bf-count { + font-size: 13px; + padding: 1px 9px; + border-radius: 10px; + } + .bf-count.bad { + background: var(--error-color, #f44336); + color: #fff; + } + .bf-count.ok { + background: var(--success-color, #4caf50); + color: #fff; + } + .bf-error { + color: var(--error-color, #f44336); + font-size: 13px; + } + .bf-repair { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + background: color-mix(in srgb, var(--warning-color, #ff9800) 12%, transparent); + font-size: 13px; + } + .bf-empty { + color: var(--secondary-text-color); + font-size: 14px; + } + .bf-shopping, + .bf-soon { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + } + .bf-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--secondary-text-color); + } + .bf-list { + font-weight: 500; + } + .bf-rows { + display: flex; + flex-direction: column; + gap: 4px; + } + .bf-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 0; + border-bottom: 1px solid var(--divider-color); + } + .bf-dev { + flex: 1; + min-width: 0; + } + .bf-type { + color: var(--secondary-text-color); + font-size: 13px; + } + .bf-level { + font-size: 12px; + color: var(--error-color, #f44336); + } + .bf-mark { + background: transparent; + border: none; + color: var(--primary-color); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: inline-flex; + } + .bf-mark:hover { + background: var(--secondary-background-color); + } + .bf-actions { + display: flex; + justify-content: flex-end; + } + .bf-soon { + border-top: 1px solid var(--divider-color); + padding-top: 8px; + } + .bf-soon-hint { + width: 100%; + font-size: 12px; + color: var(--secondary-text-color); + } + .bf-total { + font-size: 12px; + color: var(--secondary-text-color); + } + `; +} + +if (!customElements.get("maintenance-battery-fleet-section")) { + customElements.define("maintenance-battery-fleet-section", MaintenanceBatteryFleetSection); +} diff --git a/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts index 0d0d399..29a0ad8 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts @@ -19,6 +19,10 @@ export class MaintenanceCompleteDialog extends LitElement { @property() public readingUnit = ""; /** Buy task (part_ref): default restock quantity — shows an editable qty field. */ @property({ attribute: false }) public restockDefault: number | null = null; + /** #99: the object's parts — enables the editable "parts used" section. */ + @property({ attribute: false }) public parts: Array<{ id: string; name: string; unit?: string | null; stock?: number | null }> = []; + /** #99: the task's fixed consumes_parts links (prefill for the section). */ + @property({ attribute: false }) public consumesParts: Array<{ part_id: string; quantity: number }> = []; /** "Consumes: 1× HEPA-Filter (Shelf B)" hint lines for consuming tasks. */ @property({ type: Array }) public consumesInfo: string[] = []; @state() private _open = false; @@ -34,6 +38,7 @@ export class MaintenanceCompleteDialog extends LitElement { @state() private _photoUploading = false; @state() private _readingValue = ""; @state() private _restockQty = ""; + @state() private _usedParts: Record = {}; public open(): void { if (this._open) return; @@ -49,6 +54,9 @@ export class MaintenanceCompleteDialog extends LitElement { this._photoUploading = false; this._readingValue = ""; this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : ""; + // #99: prefill "parts used" with the task's fixed links — the user can + // untick or adjust before completing. + this._usedParts = Object.fromEntries(this.consumesParts.map((l) => [l.part_id, l.quantity])); } private _toggleCheck(idx: number): void { @@ -136,9 +144,16 @@ export class MaintenanceCompleteDialog extends LitElement { if (!isNaN(rv)) data.reading_value = rv; } if (this.restockDefault !== null && this._restockQty !== "") { - const rq = parseInt(this._restockQty, 10); + const rq = parseFloat(this._restockQty); if (!isNaN(rq) && rq >= 1) data.restock_quantity = rq; } + // #99: with a parts section shown, send the explicit selection — it + // replaces the automatic consumes_parts deduction (empty = none used). + if (this.parts.length > 0) { + data.used_parts = Object.entries(this._usedParts) + .filter(([, qty]) => Number.isFinite(qty) && qty > 0) + .map(([part_id, quantity]) => ({ part_id, quantity })); + } await this.hass.connection.sendMessagePromise(data); this._open = false; this.dispatchEvent(new CustomEvent("task-completed")); @@ -181,16 +196,44 @@ export class MaintenanceCompleteDialog extends LitElement { @input=${(e: Event) => (this._readingValue = (e.target as HTMLInputElement).value)} /> ` : nothing} - ${this.consumesInfo.length - ? html`
- ${this.consumesInfo.map((line) => html`
${line}
`)} + ${this.parts.length + ? html`
+ ${t("complete_parts_used", L)} + ${this.parts.map((pt) => { + const qty = this._usedParts[pt.id]; + const checked = qty !== undefined; + return html`
+ + ${checked + ? html` { + const v = parseFloat((e.target as HTMLInputElement).value); + this._usedParts = { ...this._usedParts, [pt.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 }; + }} />` + : nothing} +
`; + })}
` - : nothing} + : this.consumesInfo.length + ? html`
+ ${this.consumesInfo.map((line) => html`
${line}
`)} +
` + : nothing} ${this.restockDefault !== null ? html` ` @@ -215,7 +258,7 @@ export class MaintenanceCompleteDialog extends LitElement { @@ -297,6 +340,20 @@ export class MaintenanceCompleteDialog extends LitElement { padding: 4px 8px; margin: 4px 0 8px; } + /* #99: editable per-completion parts selection */ + .used-parts { margin: 4px 0 8px; display: flex; flex-direction: column; gap: 4px; } + .used-part-row { display: flex; align-items: center; gap: 8px; } + .used-part-check { + display: flex; align-items: center; gap: 6px; flex: 1; + font-size: 13px; cursor: pointer; + } + .used-part-check input { cursor: pointer; } + .used-part-qty { + width: 76px; padding: 4px 6px; border-radius: 4px; font: inherit; font-size: 13px; + border: 1px solid var(--divider-color); + background: var(--card-background-color); + color: var(--primary-text-color); + } .error { color: var(--error-color, #f44336); font-size: 13px; diff --git a/custom_components/maintenance_supporter/frontend-src/components/group-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/group-dialog.ts index fe08c4c..9c9594b 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/group-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/group-dialog.ts @@ -177,7 +177,7 @@ export class MaintenanceGroupDialog extends LitElement { display: flex; flex-direction: column; gap: 12px; - min-width: 360px; + min-width: min(360px, calc(100vw - 24px)); max-width: 520px; max-height: 60vh; overflow-y: auto; diff --git a/custom_components/maintenance_supporter/frontend-src/components/parts-section.ts b/custom_components/maintenance_supporter/frontend-src/components/parts-section.ts index 772cba2..05a4e7c 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/parts-section.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/parts-section.ts @@ -151,6 +151,9 @@ export class MaintenancePartsSection extends LitElement { } private async _delete(part: MaintenancePart): Promise { + // Deleting a part drops its stock tracking, task links and buy reminder — + // destructive enough to warrant an explicit confirmation (user request). + if (!window.confirm(t("part_delete_confirm", this._lang).replace("{name}", part.name))) return; const result = await this._send<{ success: boolean }>({ type: "maintenance_supporter/part/delete", entry_id: this.entryId, @@ -160,7 +163,7 @@ export class MaintenancePartsSection extends LitElement { } private async _restock(part: MaintenancePart): Promise { - const qty = parseInt(this._restockQty, 10); + const qty = parseFloat(this._restockQty); if (!Number.isFinite(qty) || qty === 0) { // Don't silently swallow a no-op amount — keep the input open and mark // it so the user sees WHY nothing happened (0 / empty / not a number). diff --git a/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts b/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts index 039c1ca..813fadc 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/settings-view.ts @@ -375,6 +375,7 @@ export class MaintenanceSettingsView extends LitElement { @state() private _allTemplates: Array<{ id: string; name: string; category: string; disabled?: boolean }> = []; @state() private _templateCategories: Record> = {}; + @state() private _tplOpenGroups: Set = new Set(); // One-shot request guard: keyed on a plain flag, NOT on the result being // non-empty — an empty catalog answer would otherwise re-trigger the load @@ -419,9 +420,25 @@ export class MaintenanceSettingsView extends LitElement {

${t("settings_templates_hint", L)}

${[...byCat.entries()].filter(([, tpls]) => tpls.length > 0).map(([catId, tpls]) => { const enabled = tpls.filter((tpl) => !hidden.has(tpl.id)).length; + // Collapsed by default (user request): the gallery lists 30+ + // templates — folded groups with an enabled/total count keep the + // settings page short; expand only what you're curating. + const open = this._tplOpenGroups.has(catId); return html`
-
`; })} @@ -457,6 +477,14 @@ export class MaintenanceSettingsView extends LitElement { this._updateSetting("disabled_template_ids", [...hidden]); } + /** Expand/collapse one gallery group (collapsed by default). */ + private _toggleTplGroupOpen(catId: string): void { + const next = new Set(this._tplOpenGroups); + if (next.has(catId)) next.delete(catId); + else next.add(catId); + this._tplOpenGroups = next; + } + /** Toggle-all for one category group in the template gallery. */ private _toggleTemplateGroup(ids: string[], visible: boolean): void { const hidden = new Set(this._settings!.disabled_template_ids || []); @@ -1590,6 +1618,8 @@ export class MaintenanceSettingsView extends LitElement { font-weight: 600; } .tpl-group-head ha-icon { --mdc-icon-size: 18px; color: var(--primary-color); } + .tpl-group-head .tpl-chevron { color: var(--secondary-text-color); } + .tpl-group-head:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } .tpl-group-name { flex: 1; } .tpl-group-count { font-size: 12px; diff --git a/custom_components/maintenance_supporter/frontend-src/components/suggested-setups-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/suggested-setups-dialog.ts new file mode 100644 index 0000000..bf27a20 --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/components/suggested-setups-dialog.ts @@ -0,0 +1,350 @@ +/** Dialog for integration-aware suggested setups (verified entity signatures). + * + * Lists devices of catalogued integrations (Roborock, Xiaomi Miio, Dreame, + * IPP/Brother printers, …) whose consumable entities can back maintenance + * tasks, and adopts the selected ones: the object is bound to the device and + * every task arrives with its sensor threshold trigger PRE-WIRED (below N + * hours left / below N % remaining, auto-resolving on replacement). The wiring + * comes from the server-side source-verified catalog — never from this client. + */ + +import { css, html, LitElement, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; + +import { t, ensureLocale } from "../styles"; +import { describeWsError } from "../ws-errors"; +import type { HomeAssistant } from "../types"; + +interface SetupTask { + task_name: string; + task_name_localized?: string; + entity_ids: string[]; + threshold: number; + direction: string; +} + +interface SuggestedSetup { + device_id: string; + device_name: string; + area_name: string; + integration: string; + integration_name: string; + suggested_entry_id: string | null; + suggested_object_name: string; + tasks: SetupTask[]; +} + +interface AdoptResponse { + tasks_created: number; + objects_created: number; + total: number; + errors?: unknown[]; +} + +export class MaintenanceSuggestedSetupsDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _open = false; + @state() private _loading = false; + @state() private _adopting = false; + @state() private _error = ""; + @state() private _setups: SuggestedSetup[] = []; + @state() private _selected: Set = new Set(); + // #102: optional counting start values, keyed "deviceId taskName". + // Only usage_delta duties render the input; raw strings until adopt. + @state() private _baselines: Map = new Map(); + // #105: adopt target per device — entry_id of an existing object, or "" + // for the default (the suggested bound object, else a new object). + @state() private _targets: Map = new Map(); + @state() private _objects: Array<{ entry_id: string; name: string }> = []; + + private _localeReady = false; + + private get _lang(): string { + return this.hass?.language || "en"; + } + + updated(changed: Map): void { + if (changed.has("hass") && this.hass && !this._localeReady) { + this._localeReady = true; + ensureLocale(this._lang).then(() => this.requestUpdate()); + } + } + + public async open(): Promise { + this._open = true; + this._loading = true; + this._error = ""; + this._setups = []; + this._selected = new Set(); + try { + const resp = await this.hass.connection.sendMessagePromise<{ setups: SuggestedSetup[] }>({ + type: "maintenance_supporter/integration_setups/discover", + }); + this._setups = resp.setups || []; + this._selected = new Set(this._setups.map((s) => s.device_id)); + this._baselines = new Map(); + this._targets = new Map(); + try { + const objs = await this.hass.connection.sendMessagePromise<{ + objects: Array<{ entry_id: string; object: { name: string } }>; + }>({ type: "maintenance_supporter/objects" }); + this._objects = (objs.objects || []) + .map((o) => ({ entry_id: o.entry_id, name: o.object?.name || o.entry_id })) + .sort((a, b) => a.name.localeCompare(b.name)); + } catch { + this._objects = []; // picker degrades to the default target only + } + } catch (e) { + this._error = describeWsError(e, this._lang); + } finally { + this._loading = false; + } + } + + private _close(): void { + this._open = false; + } + + private _toggle = (deviceId: string): void => { + const next = new Set(this._selected); + if (next.has(deviceId)) next.delete(deviceId); + else next.add(deviceId); + this._selected = next; + }; + + private _adopt = async (): Promise => { + if (this._selected.size === 0 || this._adopting) return; + this._adopting = true; + this._error = ""; + try { + const result = await this.hass.connection.sendMessagePromise({ + type: "maintenance_supporter/integration_setups/adopt", + selections: [...this._selected].map((device_id) => { + const sel: { device_id: string; entry_id?: string; baselines?: Record } = { + device_id, + }; + const target = this._targets.get(device_id); + if (target) sel.entry_id = target; + const setup = this._setups.find((s) => s.device_id === device_id); + for (const task of setup?.tasks ?? []) { + const raw = this._baselines.get(`${device_id} ${task.task_name}`); + const b = raw ? parseFloat(raw) : NaN; + if (!isNaN(b) && b >= 0) (sel.baselines ??= {})[task.task_name] = b; + } + return sel; + }), + }); + this.dispatchEvent( + new CustomEvent("integration-setups-adopted", { + bubbles: true, + composed: true, + detail: result, + }), + ); + this._open = false; + } catch (e) { + this._error = describeWsError(e, this._lang); + } finally { + this._adopting = false; + } + }; + + render() { + if (!this._open) return html``; + const L = this._lang; + + return html` +
+
e.stopPropagation()}> +
${t("setups_title", L)}
+
${t("setups_hint", L)}
+ ${this._error ? html`
${this._error}
` : nothing} + + ${this._loading + ? html`
` + : this._setups.length === 0 + ? html`
${t("setups_none", L)}
` + : html` +
+ ${this._setups.map((s) => { + const checked = this._selected.has(s.device_id); + const sub = [s.integration_name, s.area_name].filter(Boolean).join(" · "); + return html` + + `; + })} +
+ `} + +
+ + ${t("cancel", L)} + + + ${t("setups_adopt", L)} + +
+
+
+ `; + } + + static styles = css` + .overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + .card { + background: var(--card-background-color, #fff); + color: var(--primary-text-color); + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + min-width: min(360px, calc(100vw - 24px)); + max-width: 560px; + width: 90vw; + max-height: 80vh; + overflow: hidden; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + } + .title { font-size: 18px; font-weight: 500; } + .hint { color: var(--secondary-text-color); font-size: 13px; } + .error { color: var(--error-color, #f44336); font-size: 13px; } + .loading, .empty { color: var(--secondary-text-color); font-size: 14px; padding: 12px 0; } + .list { display: flex; flex-direction: column; gap: 6px; overflow-y: auto; max-height: 50vh; } + .row { + display: flex; align-items: flex-start; gap: 10px; padding: 8px; + border: 1px solid var(--divider-color); border-radius: 6px; cursor: pointer; + } + .row input { margin-top: 2px; cursor: pointer; } + .row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; } + .row-name { font-weight: 500; font-size: 13px; } + .row-sub, .row-target { color: var(--secondary-text-color); font-size: 12px; } + .new-tag { font-style: italic; } + .row-tasks { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; } + .chip { + display: inline-flex; align-items: center; gap: 4px; + font-size: 11px; padding: 2px 8px; border-radius: 10px; + background: var(--secondary-background-color, rgba(0, 0, 0, 0.06)); + color: var(--primary-text-color); white-space: nowrap; + } + .chip ha-icon { --mdc-icon-size: 12px; color: var(--primary-color); } + .target-select { + font-size: 12px; padding: 2px 4px; max-width: 100%; + border: 1px solid var(--divider-color); border-radius: 4px; + background: var(--card-background-color, #fff); + color: var(--primary-text-color); + } + .baseline-field { + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + margin-top: 4px; font-size: 12px; color: var(--secondary-text-color); + } + .baseline-field input { + width: 110px; padding: 3px 6px; font-size: 12px; + border: 1px solid var(--divider-color); border-radius: 4px; + background: var(--card-background-color, #fff); + color: var(--primary-text-color); + } + .actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 8px; } + `; +} + +if (!customElements.get("maintenance-suggested-setups-dialog")) { + customElements.define( + "maintenance-suggested-setups-dialog", + MaintenanceSuggestedSetupsDialog, + ); +} diff --git a/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts index 861d21f..95a34a3 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/task-dialog.ts @@ -3,7 +3,7 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import type { AdaptiveConfig, HomeAssistant, MaintenanceTask, TriggerConfig, HAUser } from "../types"; -import { t, weekdayName } from "../styles"; +import { formatDate, t, weekdayName } from "../styles"; import { UserService } from "../user-service"; import { describeWsError } from "../ws-errors"; @@ -32,16 +32,30 @@ interface CompoundConditionDraft { toState: string; targetChanges: string; runtimeHours: string; + onStates: string; + /** Original keys this editor has no fields for (attribute, baseline, ...). + * Spread back on save so a compound roundtrip never drops them (#103 class). */ + carry: Partial; } function emptyCondition(): CompoundConditionDraft { return { entityIds: "", type: "threshold", above: "", below: "", forMinutes: "0", targetValue: "", deltaMode: false, fromState: "", toState: "", - targetChanges: "", runtimeHours: "", + targetChanges: "", runtimeHours: "", onStates: "", carry: {}, }; } +/** Keys the compound editor owns via its own form fields — everything else + * travels through `carry` untouched. */ +const MANAGED_CONDITION_KEYS = new Set([ + "entity_id", "entity_ids", "type", + "trigger_above", "trigger_below", "trigger_for_minutes", + "trigger_target_value", "trigger_delta_mode", + "trigger_from_state", "trigger_to_state", "trigger_target_changes", + "trigger_runtime_hours", "trigger_on_states", +]); + /** Map a persisted compound condition (storage shape) to an editable draft. */ function conditionToDraft(c: TriggerConfig): CompoundConditionDraft { const ids = c.entity_ids || (c.entity_id ? [c.entity_id] : []); @@ -57,6 +71,10 @@ function conditionToDraft(c: TriggerConfig): CompoundConditionDraft { toState: c.trigger_to_state || "", targetChanges: c.trigger_target_changes?.toString() ?? "", runtimeHours: c.trigger_runtime_hours?.toString() ?? "", + onStates: (c.trigger_on_states || []).join(", "), + carry: Object.fromEntries( + Object.entries(c).filter(([k]) => !MANAGED_CONDITION_KEYS.has(k) && !k.startsWith("_")), + ) as Partial, }; } @@ -65,7 +83,7 @@ function conditionToDraft(c: TriggerConfig): CompoundConditionDraft { function draftToCondition(d: CompoundConditionDraft): TriggerConfig | null { const ids = d.entityIds.split(",").map((s) => s.trim()).filter(Boolean); if (ids.length === 0) return null; - const c: TriggerConfig = { entity_id: ids[0], entity_ids: ids, type: d.type }; + const c: TriggerConfig = { ...(d.carry || {}), entity_id: ids[0], entity_ids: ids, type: d.type }; if (d.type === "threshold") { const a = parseFloat(d.above); if (!isNaN(a)) c.trigger_above = a; const b = parseFloat(d.below); if (!isNaN(b)) c.trigger_below = b; @@ -79,6 +97,8 @@ function draftToCondition(d: CompoundConditionDraft): TriggerConfig | null { const n = parseInt(d.targetChanges, 10); if (!isNaN(n)) c.trigger_target_changes = n; } else if (d.type === "runtime") { const h = parseFloat(d.runtimeHours); if (!isNaN(h)) c.trigger_runtime_hours = h; + const on = (d.onStates || "").split(",").map((s) => s.trim()).filter(Boolean); + if (on.length > 0) c.trigger_on_states = on; } return c; } @@ -136,6 +156,12 @@ export class MaintenanceTaskDialog extends LitElement { @state() private _endsMode: "never" | "count" | "until" = "never"; @state() private _endsCount = ""; @state() private _endsUntil = ""; + // #83: live "next dates" preview — dates come from the BACKEND engine via + // schedule/preview (never a frontend reimplementation; the #103 lesson). + @state() private _schedulePreview: string[] = []; + @state() private _schedulePreviewEnded = false; + private _previewTimer: ReturnType | undefined; + private _previewSeq = 0; @state() private _notes = ""; @state() private _documentationUrl = ""; @state() private _customIcon = ""; @@ -154,11 +180,22 @@ export class MaintenanceTaskDialog extends LitElement { @state() private _triggerForMinutes = "0"; @state() private _triggerTargetValue = ""; @state() private _triggerDeltaMode = false; + @state() private _triggerBaselineValue = ""; + // The LIVE counting anchor from the read-model (Store baseline — moves on + // completion). Display-only: adopted delta tasks have no config baseline, + // so without this the edit dialog would show an empty start-value field + // even though counting is anchored at e.g. 27,000 km. + @state() private _liveBaselineValue: number | null = null; @state() private _autoCompleteOnRecovery = false; @state() private _triggerFromState = ""; @state() private _triggerToState = ""; @state() private _triggerTargetChanges = ""; @state() private _triggerRuntimeHours = ""; + // Comma-separated "running" states for the runtime trigger (#103) — + // empty = the backend default ["on"]. Must roundtrip on edit: adopted + // tasks ship e.g. ["mowing"], and dropping it on save silently stops + // the accumulation. + @state() private _triggerOnStates = ""; // Compound trigger (type === "compound"): a list of conditions + AND/OR logic @state() private _compoundLogic: "AND" | "OR" = "AND"; @state() private _compoundConditions: CompoundConditionDraft[] = []; @@ -320,7 +357,11 @@ export class MaintenanceTaskDialog extends LitElement { if (task.trigger_config) { const tc = task.trigger_config; - this._triggerEntityId = tc.entity_id || ""; + // A trigger stored with only the plural entity_ids (e.g. the Battery + // Fleet task) must still hydrate the singular field — the save path + // gates on _triggerEntityId and would otherwise NULL the whole trigger + // on an unrelated edit (issue #106). + this._triggerEntityId = tc.entity_id || (tc.entity_ids && tc.entity_ids[0]) || ""; this._triggerEntityIds = tc.entity_ids || (tc.entity_id ? [tc.entity_id] : []); this._triggerEntityLogic = tc.entity_logic || "any"; this._triggerAttribute = tc.attribute || ""; @@ -330,11 +371,14 @@ export class MaintenanceTaskDialog extends LitElement { this._triggerForMinutes = tc.trigger_for_minutes?.toString() || "0"; this._triggerTargetValue = tc.trigger_target_value?.toString() || ""; this._triggerDeltaMode = tc.trigger_delta_mode || false; + this._triggerBaselineValue = tc.trigger_baseline_value?.toString() || ""; + this._liveBaselineValue = task.trigger_baseline_value ?? null; this._autoCompleteOnRecovery = tc.auto_complete_on_recovery || false; this._triggerFromState = tc.trigger_from_state || ""; this._triggerToState = tc.trigger_to_state || ""; this._triggerTargetChanges = tc.trigger_target_changes?.toString() || ""; this._triggerRuntimeHours = tc.trigger_runtime_hours?.toString() || ""; + this._triggerOnStates = (tc.trigger_on_states || []).join(", "); if (tc.type === "compound") { this._compoundLogic = tc.compound_logic === "OR" ? "OR" : "AND"; this._compoundConditions = (tc.conditions || []).map(conditionToDraft); @@ -423,11 +467,14 @@ export class MaintenanceTaskDialog extends LitElement { this._triggerForMinutes = "0"; this._triggerTargetValue = ""; this._triggerDeltaMode = false; + this._triggerBaselineValue = ""; + this._liveBaselineValue = null; this._autoCompleteOnRecovery = false; this._triggerFromState = ""; this._triggerToState = ""; this._triggerTargetChanges = ""; this._triggerRuntimeHours = ""; + this._triggerOnStates = ""; this._compoundLogic = "AND"; this._compoundConditions = []; } @@ -844,12 +891,21 @@ export class MaintenanceTaskDialog extends LitElement { } else if (this._triggerType === "counter") { if (this._triggerTargetValue) { const v = parseFloat(this._triggerTargetValue); if (!isNaN(v)) triggerConfig.trigger_target_value = v; } triggerConfig.trigger_delta_mode = this._triggerDeltaMode; + // #102: optional counting start value ("last service was at X"). + // Empty = count from the reading at creation / keep the live + // baseline; the backend clears stale Store state when it changes. + if (this._triggerDeltaMode && this._triggerBaselineValue) { + const b = parseFloat(this._triggerBaselineValue); + if (!isNaN(b) && b >= 0) triggerConfig.trigger_baseline_value = b; + } } else if (this._triggerType === "state_change") { if (this._triggerFromState) triggerConfig.trigger_from_state = this._triggerFromState; if (this._triggerToState) triggerConfig.trigger_to_state = this._triggerToState; if (this._triggerTargetChanges) { const v = parseInt(this._triggerTargetChanges, 10); if (!isNaN(v)) triggerConfig.trigger_target_changes = v; } } else if (this._triggerType === "runtime") { if (this._triggerRuntimeHours) { const v = parseFloat(this._triggerRuntimeHours); if (!isNaN(v)) triggerConfig.trigger_runtime_hours = v; } + const onStates = this._triggerOnStates.split(",").map((s) => s.trim()).filter(Boolean); + if (onStates.length > 0) triggerConfig.trigger_on_states = onStates; } data.trigger_config = triggerConfig; @@ -1146,6 +1202,8 @@ export class MaintenanceTaskDialog extends LitElement { return html` this._patchCondition(i, { runtimeHours: (e.target as HTMLInputElement).value })}> + this._patchCondition(i, { onStates: (e.target as HTMLInputElement).value })}> `; } return nothing; @@ -1175,6 +1233,99 @@ export class MaintenanceTaskDialog extends LitElement { : [...this._weekdays, i]; } + /** The draft schedule in engine (Schedule.to_dict) form — MIRRORS the + * _save mapping; keep both in sync when adding schedule fields. Null = + * nothing to preview (manual, or trigger-only without an interval). */ + private _previewScheduleDict(): Record | null { + if (this._scheduleType === "one_time") { + return this._dueDate ? { kind: "one_time", due_date: this._dueDate } : null; + } + if (CALENDAR_KINDS.includes(this._scheduleType)) { + return { ...this._buildSchedule(), ...this._recurrenceExtras() }; + } + const every = parseInt(this._intervalDays, 10); + if (this._scheduleType === "manual" || !every || every <= 0) return null; + return { + kind: "interval", + every, + unit: this._intervalUnit, + anchor: this._intervalAnchor, + ...this._recurrenceExtras(), + }; + } + + private static readonly _PREVIEW_RELEVANT = new Set([ + "_open", "_scheduleType", "_intervalDays", "_intervalUnit", "_intervalAnchor", + "_dueDate", "_weekdays", "_nth", "_nthWeekday", "_domDay", "_domLastDay", + "_domBusiness", "_calOffset", "_seasonMonths", "_endsMode", "_endsCount", + "_endsUntil", "_lastPerformed", + ]); + + protected updated(changed: Map): void { + super.updated?.(changed); + for (const key of changed.keys()) { + if (MaintenanceTaskDialog._PREVIEW_RELEVANT.has(String(key))) { + this._schedulePreviewRefresh(); + return; + } + } + } + + private _schedulePreviewRefresh(): void { + if (this._previewTimer) clearTimeout(this._previewTimer); + this._previewTimer = setTimeout(() => void this._fetchSchedulePreview(), 300); + } + + private async _fetchSchedulePreview(): Promise { + const sched = this._open ? this._previewScheduleDict() : null; + if (!sched) { + this._schedulePreview = []; + this._schedulePreviewEnded = false; + return; + } + const seq = ++this._previewSeq; + try { + const res = await this.hass.connection.sendMessagePromise<{ + occurrences: string[]; + series_ended: boolean; + }>({ + type: "maintenance_supporter/schedule/preview", + schedule: sched, + ...(this._lastPerformed ? { last_performed: this._lastPerformed } : {}), + }); + if (seq !== this._previewSeq) return; // a newer edit superseded this + this._schedulePreview = res.occurrences || []; + this._schedulePreviewEnded = !!res.series_ended; + } catch { + // Transient WS error — keep the last preview instead of flickering. + } + } + + private _renderSchedulePreview() { + if (this._schedulePreview.length === 0) return nothing; + const L = this._lang; + const time = this.scheduleTimeEnabled && this._scheduleTime ? ` ${this._scheduleTime}` : ""; + const chips = this._schedulePreview + .map((iso, i) => { + const js = new Date(`${iso}T12:00:00`).getDay(); // 0=Sun + const wd = weekdayName(js === 0 ? 6 : js - 1, L, "short"); + return `${wd} ${formatDate(iso, L)}${i === 0 ? time : ""}`; + }) + .join(" · "); + const onTime = + this._scheduleType === "time_based" && this._intervalAnchor === "completion" + ? html`
${t("schedule_preview_ontime", L)}
` + : nothing; + return html` +
+ ${t("schedule_preview_title", L)}: ${chips}${this._schedulePreviewEnded + ? html` ${t("schedule_preview_ends", L)}` + : nothing} + ${onTime} +
+ `; + } + /** Build the nested `schedule` object for the selected calendar kind. */ private _buildSchedule(): Record { const withOffset = (schedule: Record) => { @@ -1463,6 +1614,28 @@ export class MaintenanceTaskDialog extends LitElement { /> ${t("delta_mode", L)} + ${this._triggerDeltaMode + ? html` + (this._triggerBaselineValue = (e.target as HTMLInputElement).value)} + > +
+ ${this._taskId ? t("baseline_start_help_edit", L) : t("baseline_start_help", L)} + ${this._taskId && this._liveBaselineValue != null + ? html`
+ ${t("baseline_current_effective", L).replace( + "{value}", + String(this._liveBaselineValue), + )} +
` + : nothing} +
+ ` + : nothing} `; } if (this._triggerType === "state_change") { @@ -1497,6 +1670,13 @@ export class MaintenanceTaskDialog extends LitElement { .value=${this._triggerRuntimeHours} @input=${(e: Event) => (this._triggerRuntimeHours = (e.target as HTMLInputElement).value)} > + (this._triggerOnStates = (e.target as HTMLInputElement).value)} + > +
${t("runtime_on_states_help", L)}
`; } return nothing; @@ -1583,12 +1763,13 @@ export class MaintenanceTaskDialog extends LitElement { ? html` { - const v = parseInt((e.target as HTMLInputElement).value, 10); - this._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 1 ? v : 1 }; + const v = parseFloat((e.target as HTMLInputElement).value); + this._consumesParts = { ...this._consumesParts, [part.id]: Number.isFinite(v) && v >= 0.01 ? v : 1 }; }} />` : nothing} @@ -1672,6 +1853,7 @@ export class MaintenanceTaskDialog extends LitElement { ` : nothing} ${this._renderRecurrenceExtras()} + ${this._renderSchedulePreview()} o.entry_id === entry_id); + const isBuy = !!(task as any).part_ref; + dlg.parts = isBuy ? [] : (obj?.parts || []); + dlg.consumesParts = isBuy ? [] : ((task as any).consumes_parts || []); dlg.open(); }} > diff --git a/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts b/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts index 3c37fcb..3d14683 100644 --- a/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts +++ b/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts @@ -43,6 +43,9 @@ import "./components/qr-dialog"; import type { MaintenanceQrDialog } from "./components/qr-dialog"; import "./components/adopt-problem-sensors-dialog"; import type { MaintenanceAdoptProblemSensorsDialog } from "./components/adopt-problem-sensors-dialog"; +import "./components/suggested-setups-dialog"; +import "./components/battery-fleet-section"; +import type { MaintenanceSuggestedSetupsDialog } from "./components/suggested-setups-dialog"; // v2.0.0: panel uses the extracted Calendar Card instead of its own // _renderCalendar() method — single source of truth for the calendar view. import "./maintenance-calendar-card"; @@ -129,6 +132,15 @@ export class MaintenanceSupporterPanel extends LitElement { @state() private _moreMenuOpen = false; @state() private _toastMessage = ""; @state() private _toastUndo: (() => void) | null = null; + @state() private _toastActionLabel = ""; + // Narrow-viewport disclosure (UX 2026-07): filters and create-actions are + // occasional-use — collapsed behind two toggle buttons so the task list + // starts above the fold on phones. Desktop renders them inline as before. + @state() private _filtersOpen = false; + @state() private _actionsMenuOpen = false; + // Battery Fleet: offer one-click setup only when Battery Notes is present + // and the fleet isn't set up yet. + @state() private _batteryFleetSetupAvailable = false; private _toastTimer: ReturnType | null = null; private _dismissedSuggestions = new Set(); @@ -368,6 +380,17 @@ export class MaintenanceSupporterPanel extends LitElement { ]); if (viewsResult) this._savedViews = (viewsResult as { views: SavedView[] }).views || []; if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects; + // Battery Fleet availability (Battery Notes present + not yet set up). + this.hass.connection + .sendMessagePromise<{ available: boolean; configured: boolean }>({ + type: "maintenance_supporter/battery_fleet/overview", + }) + .then((ov) => { + this._batteryFleetSetupAvailable = !!ov.available && !ov.configured; + }) + .catch(() => { + this._batteryFleetSetupAvailable = false; + }); if (statsResult) this._stats = statsResult as StatisticsResponse; if (budgetResult) this._budget = budgetResult as BudgetStatus; if (groupsResult) this._groups = (groupsResult as { groups: Record }).groups || {}; @@ -852,15 +875,23 @@ export class MaintenanceSupporterPanel extends LitElement { private _showToast(msg: string): void { if (this._toastTimer) clearTimeout(this._toastTimer); this._toastUndo = null; + this._toastActionLabel = ""; this._toastMessage = msg; this._toastTimer = setTimeout(() => { this._toastMessage = ""; this._toastTimer = null; }, 4000); } - /** A toast with an Undo action — used for reversible actions (archive) that - * run immediately instead of behind a confirm dialog. Longer-lived so the - * user has time to react; the undo callback dismisses it. */ + /** A toast with an action button (label defaults to Undo). Used for + * reversible actions (archive) and follow-up shortcuts (configure the + * freshly adopted task). Longer-lived so the user has time to react; the + * callback dismisses it. */ + private _showActionToast(msg: string, label: string, action: () => void): void { + this._showUndoToast(msg, action); + this._toastActionLabel = label; + } + private _showUndoToast(msg: string, undo: () => void): void { if (this._toastTimer) clearTimeout(this._toastTimer); + this._toastActionLabel = ""; this._toastMessage = msg; this._toastUndo = undo; this._toastTimer = setTimeout(() => { @@ -987,9 +1018,57 @@ export class MaintenanceSupporterPanel extends LitElement { ?.open(); } - private _onProblemSensorsAdopted(e: CustomEvent): void { + private async _onProblemSensorsAdopted(e: CustomEvent): Promise { const tasks = e.detail?.tasks_created ?? 0; - this._showToast(t("adopt_problem_done", this._lang).replace("{tasks}", String(tasks))); + const created = (e.detail?.created ?? []) as Array<{ entry_id: string; task_id: string; name: string }>; + await this._loadData(); + const msg = t("adopt_problem_done", this._lang).replace("{tasks}", String(tasks)); + if (created.length > 0) { + // Adopted tasks are fully configurable from day one (responsible user, + // priority, documents) — surface that with a direct path to the first. + this._showActionToast(msg, t("adopt_problem_configure", this._lang), () => { + const ref = created[0]; + const obj = this._objects.find((o) => o.entry_id === ref.entry_id); + const tk = obj?.tasks.find((task) => task.id === ref.task_id); + if (obj && tk) { + this.shadowRoot!.querySelector("maintenance-task-dialog")?.openEdit(ref.entry_id, tk); + } + }); + } else { + this._showToast(msg); + } + } + + // --- Suggested setups (integration signatures, v2.28) --- + + private async _setupBatteryFleet(): Promise { + try { + const res = await this.hass.connection.sendMessagePromise<{ entry_id: string; task_id?: string }>({ + type: "maintenance_supporter/battery_fleet/setup", + }); + this._batteryFleetSetupAvailable = false; + await this._loadData(); + // Jump to the fleet task so the user lands on its battery detail section. + const obj = this._objects.find((o) => o.entry_id === res.entry_id); + const tk = obj?.tasks.find((t2) => t2.id === res.task_id) || obj?.tasks[0]; + if (obj && tk) { + this._showTask(obj.entry_id, tk.id); + } + this._showToast(t("battery_fleet_setup_done", this._lang)); + } catch (e) { + this._showToast(describeWsError(e, this._lang)); + } + } + + private _openSuggestedSetups(): void { + this.shadowRoot! + .querySelector("maintenance-suggested-setups-dialog") + ?.open(); + } + + private _onSetupsAdopted(e: CustomEvent): void { + const tasks = e.detail?.tasks_created ?? 0; + this._showToast(t("setups_done", this._lang).replace("{tasks}", String(tasks))); this._loadData(); } @@ -1720,6 +1799,10 @@ export class MaintenanceSupporterPanel extends LitElement { return `${link.quantity}× ${pt.name}${stock}${loc}`; }) .filter(Boolean); + // #99: editable per-completion parts selection (not on buy tasks — those + // RESTOCK via the qty field instead of consuming). + dlg.parts = tk?.part_ref ? [] : objParts; + dlg.consumesParts = tk?.part_ref ? [] : (tk?.consumes_parts || []); dlg.open(); } @@ -1794,13 +1877,17 @@ export class MaintenanceSupporterPanel extends LitElement { .hass=${this.hass} @problem-sensors-adopted=${(e: CustomEvent) => this._onProblemSensorsAdopted(e)} > + this._onSetupsAdopted(e)} + > ) => this._onSavedViewsChanged(e)} > ${this._toastMessage ? html`
${this._toastMessage} - ${this._toastUndo ? html`` : nothing} + ${this._toastUndo ? html`` : nothing}
` : nothing} ${this._renderPalette()} ${this._renderTemplateGallery()} @@ -2046,10 +2133,39 @@ export class MaintenanceSupporterPanel extends LitElement { (n, o) => n + o.tasks.filter((tk) => tk.archived).length, 0, ); + // Filters actively narrowing the list — shown on the collapsed toggle so + // "why is my list short?" has a visible answer even with filters hidden. + const activeFilterCount = + (this._filterStatus ? 1 : 0) + + (this._filterUser ? 1 : 0) + + (this._filterLabel ? 1 : 0) + + (this._activeViewId ? 1 : 0); + return html` ${this._features.budget ? this._renderBudgetBar() : nothing} -
+ ${this.narrow ? html` +
+ { this._filtersOpen = !this._filtersOpen; }} + > + + ${t("filter_label", L)}${activeFilterCount > 0 ? ` (${activeFilterCount})` : ""} + + ${!isOperator ? html` + { this._actionsMenuOpen = !this._actionsMenuOpen; }} + > + + ${t("add", L)} + + ` : nothing} +
+ ` : nothing} + +
`}_toggleWeekday(e){this._weekdays=this._weekdays.includes(e)?this._weekdays.filter(t=>t!==e):[...this._weekdays,e]}_previewScheduleDict(){if(this._scheduleType==="one_time")return this._dueDate?{kind:"one_time",due_date:this._dueDate}:null;if(tt.includes(this._scheduleType))return{...this._buildSchedule(),...this._recurrenceExtras()};let e=parseInt(this._intervalDays,10);return this._scheduleType==="manual"||!e||e<=0?null:{kind:"interval",every:e,unit:this._intervalUnit,anchor:this._intervalAnchor,...this._recurrenceExtras()}}updated(e){super.updated?.(e);for(let t of e.keys())if(g._PREVIEW_RELEVANT.has(String(t))){this._schedulePreviewRefresh();return}}_schedulePreviewRefresh(){this._previewTimer&&clearTimeout(this._previewTimer),this._previewTimer=setTimeout(()=>{this._fetchSchedulePreview()},300)}async _fetchSchedulePreview(){let e=this._open?this._previewScheduleDict():null;if(!e){this._schedulePreview=[],this._schedulePreviewEnded=!1;return}let t=++this._previewSeq;try{let i=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/schedule/preview",schedule:e,...this._lastPerformed?{last_performed:this._lastPerformed}:{}});if(t!==this._previewSeq)return;this._schedulePreview=i.occurrences||[],this._schedulePreviewEnded=!!i.series_ended}catch{}}_renderSchedulePreview(){if(this._schedulePreview.length===0)return h;let e=this._lang,t=this.scheduleTimeEnabled&&this._scheduleTime?` ${this._scheduleTime}`:"",i=this._schedulePreview.map((d,c)=>{let u=new Date(`${d}T12:00:00`).getDay();return`${ve(u===0?6:u-1,e,"short")} ${F(d,e)}${c===0?t:""}`}).join(" \xB7 "),n=this._scheduleType==="time_based"&&this._intervalAnchor==="completion"?o`
${r("schedule_preview_ontime",e)}
`:h;return o` +
+ ${r("schedule_preview_title",e)}: ${i}${this._schedulePreviewEnded?o` ${r("schedule_preview_ends",e)}`:h} + ${n} +
+ `}_buildSchedule(){let e=i=>{let n=parseInt(this._calOffset,10)||0;return n&&(i.offset=Math.max(-15,Math.min(n,15))),i};if(this._scheduleType==="weekdays")return e({kind:"weekdays",weekdays:[...this._weekdays].sort((i,n)=>i-n)});if(this._scheduleType==="nth_weekday")return e({kind:"nth_weekday",nth:parseInt(this._nth,10),weekday:parseInt(this._nthWeekday,10)});let t={kind:"day_of_month",day:this._domLastDay?-1:parseInt(this._domDay,10)||1};return this._domBusiness&&(t.business=!0),e(t)}_recurrenceExtras(){let e={};if(this._seasonMonths.length&&(e.season_months=[...this._seasonMonths].sort((t,i)=>t-i)),this._endsMode==="count"){let t=parseInt(this._endsCount,10);t>=1&&(e.ends={count:t})}else this._endsMode==="until"&&this._endsUntil&&(e.ends={until:this._endsUntil});return e}_toggleSeasonMonth(e){this._seasonMonths=this._seasonMonths.includes(e)?this._seasonMonths.filter(t=>t!==e):[...this._seasonMonths,e]}_renderRecurrenceExtras(){let e=this._lang;if(!(this._scheduleType==="time_based"||tt.includes(this._scheduleType)))return h;let i=ss(e);return o`
${r("season_window_hint",e)}
@@ -1838,7 +1896,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" .value=${this._endsUntil} @input=${n=>this._endsUntil=n.target.value} >`:h} - `}_renderCalendarFields(){let e=this._lang,t=es(e);if(this._scheduleType==="weekdays")return o` + `}_renderCalendarFields(){let e=this._lang,t=is(e);if(this._scheduleType==="weekdays")return o`
${t.map((i,n)=>o` @@ -1890,7 +1948,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" max="15" .value=${this._calOffset} @input=${t=>this._calOffset=t.target.value} - >`}_renderTriggerLiveHint(){if(this._triggerType==="compound")return h;let e=this._triggerEntityId||this._triggerEntityIds[0];if(!e||!this.hass?.states)return h;let t=this.hass.states[e];if(!t)return h;let i=this._lang,n=t.attributes?.unit_of_measurement,d=typeof n=="string"&&n?` ${n}`:"",c=this._triggerAttribute?t.attributes?.[this._triggerAttribute]:t.state,_=typeof c=="number"?c:parseFloat(String(c)),u=c!=="unknown"&&c!=="unavailable"&&c!=null&&!isNaN(_),f=y=>Number.isInteger(y)?String(y):String(Math.round(y*10)/10),m=[];if(this._triggerType==="threshold"){let y=parseFloat(this._triggerAbove),b=parseFloat(this._triggerBelow);if(isNaN(y)&&isNaN(b))return h;u&&m.push(r("trigger_hint_now",i).replace("{value}",f(_)+d)),isNaN(y)||m.push(r("trigger_hint_above",i).replace("{target}",f(y)+d)),isNaN(b)||m.push(r("trigger_hint_below",i).replace("{target}",f(b)+d))}else if(this._triggerType==="counter"){let y=parseFloat(this._triggerTargetValue);if(isNaN(y))return h;this._triggerDeltaMode?this._taskId?m.push(r("trigger_hint_counter_delta_edit",i).replace("{target}",f(y)+d)):u?m.push(r("trigger_hint_counter_delta",i).replace("{value}",f(_)+d).replace("{due}",f(_+y)+d).replace("{target}",f(y)+d)):m.push(r("trigger_hint_counter_delta_edit",i).replace("{target}",f(y)+d)):(u&&m.push(r("trigger_hint_now",i).replace("{value}",f(_)+d)),m.push(r("trigger_hint_counter_abs",i).replace("{target}",f(y)+d)))}else if(this._triggerType==="runtime"){let y=parseFloat(this._triggerRuntimeHours);if(isNaN(y))return h;m.push(r("trigger_hint_runtime",i).replace("{hours}",f(y))),m.push(r("trigger_hint_state_now",i).replace("{value}",String(t.state)))}else if(this._triggerType==="state_change"){let y=parseInt(this._triggerTargetChanges,10)||1,b=this._triggerToState.trim();m.push((b?r("trigger_hint_state_change_to",i).replace("{state}",b):r("trigger_hint_state_change",i)).replace("{count}",String(y))),m.push(r("trigger_hint_state_now",i).replace("{value}",String(t.state)))}return m.length?o`
${m.join(" ")}
`:h}_renderTriggerTypeFields(){let e=this._lang;return this._triggerType==="threshold"?o` + >`}_renderTriggerLiveHint(){if(this._triggerType==="compound")return h;let e=this._triggerEntityId||this._triggerEntityIds[0];if(!e||!this.hass?.states)return h;let t=this.hass.states[e];if(!t)return h;let i=this._lang,n=t.attributes?.unit_of_measurement,d=typeof n=="string"&&n?` ${n}`:"",c=this._triggerAttribute?t.attributes?.[this._triggerAttribute]:t.state,u=typeof c=="number"?c:parseFloat(String(c)),_=c!=="unknown"&&c!=="unavailable"&&c!=null&&!isNaN(u),f=y=>Number.isInteger(y)?String(y):String(Math.round(y*10)/10),m=[];if(this._triggerType==="threshold"){let y=parseFloat(this._triggerAbove),b=parseFloat(this._triggerBelow);if(isNaN(y)&&isNaN(b))return h;_&&m.push(r("trigger_hint_now",i).replace("{value}",f(u)+d)),isNaN(y)||m.push(r("trigger_hint_above",i).replace("{target}",f(y)+d)),isNaN(b)||m.push(r("trigger_hint_below",i).replace("{target}",f(b)+d))}else if(this._triggerType==="counter"){let y=parseFloat(this._triggerTargetValue);if(isNaN(y))return h;this._triggerDeltaMode?this._taskId?m.push(r("trigger_hint_counter_delta_edit",i).replace("{target}",f(y)+d)):_?m.push(r("trigger_hint_counter_delta",i).replace("{value}",f(u)+d).replace("{due}",f(u+y)+d).replace("{target}",f(y)+d)):m.push(r("trigger_hint_counter_delta_edit",i).replace("{target}",f(y)+d)):(_&&m.push(r("trigger_hint_now",i).replace("{value}",f(u)+d)),m.push(r("trigger_hint_counter_abs",i).replace("{target}",f(y)+d)))}else if(this._triggerType==="runtime"){let y=parseFloat(this._triggerRuntimeHours);if(isNaN(y))return h;m.push(r("trigger_hint_runtime",i).replace("{hours}",f(y))),m.push(r("trigger_hint_state_now",i).replace("{value}",String(t.state)))}else if(this._triggerType==="state_change"){let y=parseInt(this._triggerTargetChanges,10)||1,b=this._triggerToState.trim();m.push((b?r("trigger_hint_state_change_to",i).replace("{state}",b):r("trigger_hint_state_change",i)).replace("{count}",String(y))),m.push(r("trigger_hint_state_now",i).replace("{value}",String(t.state)))}return m.length?o`
${m.join(" ")}
`:h}_renderTriggerTypeFields(){let e=this._lang;return this._triggerType==="threshold"?o` ${r("delta_mode",e)} + ${this._triggerDeltaMode?o` + this._triggerBaselineValue=t.target.value} + > +
+ ${this._taskId?r("baseline_start_help_edit",e):r("baseline_start_help",e)} + ${this._taskId&&this._liveBaselineValue!=null?o`
+ ${r("baseline_current_effective",e).replace("{value}",String(this._liveBaselineValue))} +
`:h} +
+ `:h} `:this._triggerType==="state_change"?o` this._triggerRuntimeHours=t.target.value} > + this._triggerOnStates=t.target.value} + > +
${r("runtime_on_states_help",e)}
`:h}render(){if(!this._open)return o``;let e=this._lang,t=this._taskId?r("edit_task",e):r("new_task",e);return o`
${t}
@@ -1983,7 +2063,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" .value=${this._type} @change=${i=>this._type=i.target.value} > - ${Gi.map(i=>o``)} + ${Ki.map(i=>o``)}
${this._type==="reading"?o` @@ -2011,10 +2091,11 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" ${n!==void 0?o`{let c=parseInt(d.target.value,10);this._consumesParts={...this._consumesParts,[i.id]:Number.isFinite(c)&&c>=1?c:1}}} + @input=${d=>{let c=parseFloat(d.target.value);this._consumesParts={...this._consumesParts,[i.id]:Number.isFinite(c)&&c>=.01?c:1}}} />`:h}
`})} @@ -2045,7 +2126,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" .value=${this._scheduleType} @change=${i=>this._scheduleType=i.target.value} > - ${Ki.map(i=>o``)} + ${Ji.map(i=>o``)}
${this._scheduleType==="time_based"?o` @@ -2086,6 +2167,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" >
`:h} ${this._renderRecurrenceExtras()} + ${this._renderSchedulePreview()}
- `}};g.styles=E` + `}};g._PREVIEW_RELEVANT=new Set(["_open","_scheduleType","_intervalDays","_intervalUnit","_intervalAnchor","_dueDate","_weekdays","_nth","_nthWeekday","_domDay","_domLastDay","_domBusiness","_calOffset","_seasonMonths","_endsMode","_endsCount","_endsUntil","_lastPerformed"]),g.styles=A` .dialog-title { font-size: 18px; font-weight: 500; @@ -2347,6 +2429,11 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" font-size: 12px; color: var(--secondary-text-color); } + .baseline-effective { + margin-top: 2px; + font-weight: 500; + color: var(--primary-text-color); + } /* Live computed trigger hint — reads the bound sensor and explains what happens next. Info-accented so it reads as guidance, not an error. */ .trigger-live-hint { @@ -2483,7 +2570,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" font-size: 14px; cursor: pointer; } - `,l([v({attribute:!1})],g.prototype,"hass",2),l([v({type:Boolean,attribute:"checklists-enabled"})],g.prototype,"checklistsEnabled",2),l([v({type:Boolean,attribute:"schedule-time-enabled"})],g.prototype,"scheduleTimeEnabled",2),l([v({type:Boolean,attribute:"completion-actions-enabled"})],g.prototype,"completionActionsEnabled",2),l([v({type:Number,attribute:"default-warning-days"})],g.prototype,"defaultWarningDays",2),l([p()],g.prototype,"parts",2),l([p()],g.prototype,"_open",2),l([p()],g.prototype,"_loading",2),l([p()],g.prototype,"_error",2),l([p()],g.prototype,"_entryId",2),l([p()],g.prototype,"_taskId",2),l([p()],g.prototype,"_objectChoices",2),l([p()],g.prototype,"_name",2),l([p()],g.prototype,"_type",2),l([p()],g.prototype,"_scheduleType",2),l([p()],g.prototype,"_intervalDays",2),l([p()],g.prototype,"_intervalUnit",2),l([p()],g.prototype,"_dueDate",2),l([p()],g.prototype,"_warningDays",2),l([p()],g.prototype,"_earliestCompletionDays",2),l([p()],g.prototype,"_intervalAnchor",2),l([p()],g.prototype,"_weekdays",2),l([p()],g.prototype,"_nth",2),l([p()],g.prototype,"_nthWeekday",2),l([p()],g.prototype,"_domDay",2),l([p()],g.prototype,"_domLastDay",2),l([p()],g.prototype,"_domBusiness",2),l([p()],g.prototype,"_calOffset",2),l([p()],g.prototype,"_seasonMonths",2),l([p()],g.prototype,"_endsMode",2),l([p()],g.prototype,"_endsCount",2),l([p()],g.prototype,"_endsUntil",2),l([p()],g.prototype,"_notes",2),l([p()],g.prototype,"_documentationUrl",2),l([p()],g.prototype,"_customIcon",2),l([p()],g.prototype,"_priority",2),l([p()],g.prototype,"_labels",2),l([p()],g.prototype,"_enabled",2),l([p()],g.prototype,"_triggerEntityId",2),l([p()],g.prototype,"_triggerEntityIds",2),l([p()],g.prototype,"_triggerEntityLogic",2),l([p()],g.prototype,"_triggerAttribute",2),l([p()],g.prototype,"_triggerType",2),l([p()],g.prototype,"_triggerAbove",2),l([p()],g.prototype,"_triggerBelow",2),l([p()],g.prototype,"_triggerForMinutes",2),l([p()],g.prototype,"_triggerTargetValue",2),l([p()],g.prototype,"_triggerDeltaMode",2),l([p()],g.prototype,"_autoCompleteOnRecovery",2),l([p()],g.prototype,"_triggerFromState",2),l([p()],g.prototype,"_triggerToState",2),l([p()],g.prototype,"_triggerTargetChanges",2),l([p()],g.prototype,"_triggerRuntimeHours",2),l([p()],g.prototype,"_compoundLogic",2),l([p()],g.prototype,"_compoundConditions",2),l([p()],g.prototype,"_suggestedAttributes",2),l([p()],g.prototype,"_availableAttributes",2),l([p()],g.prototype,"_entityDomain",2),l([p()],g.prototype,"_lastPerformed",2),l([p()],g.prototype,"_nfcTagId",2),l([p()],g.prototype,"_readingUnit",2),l([p()],g.prototype,"_consumesParts",2),l([p()],g.prototype,"_partsLoadFailed",2),l([p()],g.prototype,"_availableTags",2),l([p()],g.prototype,"_responsibleUserId",2),l([p()],g.prototype,"_assigneePool",2),l([p()],g.prototype,"_rotationStrategy",2),l([p()],g.prototype,"_availableUsers",2),l([p()],g.prototype,"_checklistText",2),l([p()],g.prototype,"_scheduleTime",2),l([p()],g.prototype,"_actionService",2),l([p()],g.prototype,"_actionTargetEntity",2),l([p()],g.prototype,"_actionData",2),l([p()],g.prototype,"_actionDataJsonFallback",2),l([p()],g.prototype,"_actionTesting",2),l([p()],g.prototype,"_actionTestResult",2),l([p()],g.prototype,"_actionTestError",2),l([p()],g.prototype,"_qcNotes",2),l([p()],g.prototype,"_qcCost",2),l([p()],g.prototype,"_qcDuration",2),l([p()],g.prototype,"_qcFeedback",2),l([p()],g.prototype,"_environmentalEntity",2),l([p()],g.prototype,"_environmentalAttribute",2);customElements.get("maintenance-task-dialog")||customElements.define("maintenance-task-dialog",g)});var O,Kt=x(()=>{"use strict";C();N();I();te();O=class extends w{constructor(){super(...arguments);this._open=!1;this._saving=!1;this._error="";this._draft=null;this._originalSnapshot=null}get _lang(){return this.hass?.language||"en"}openEdit(e){this._draft={...e},this._originalSnapshot={...e},this._error="",this._open=!0}close(){this._open=!1,this._error="",this._draft=null,this._originalSnapshot=null}_set(e,t){this._draft&&(this._draft={...this._draft,[e]:t})}async _save(){if(!(!this._draft||!this._originalSnapshot)){this._saving=!0,this._error="";try{let e={type:"maintenance_supporter/task/history/update",entry_id:this._draft.entry_id,task_id:this._draft.task_id,original_timestamp:this._originalSnapshot.original_timestamp};if(this._draft.timestamp!==this._originalSnapshot.timestamp&&(e.timestamp=this._draft.timestamp),this._draft.notes!==this._originalSnapshot.notes&&(e.notes=this._draft.notes),this._draft.cost!==this._originalSnapshot.cost&&(e.cost=this._draft.cost),this._draft.duration!==this._originalSnapshot.duration&&(e.duration=this._draft.duration),this._draft.completed_by!==this._originalSnapshot.completed_by&&(e.completed_by=this._draft.completed_by),Object.keys(e).filter(i=>!["type","entry_id","task_id","original_timestamp"].includes(i)).length===0){this.close();return}await this.hass.connection.sendMessagePromise(e),this.dispatchEvent(new CustomEvent("history-entry-saved",{detail:{entry_id:this._draft.entry_id,task_id:this._draft.task_id,new_timestamp:this._draft.timestamp},bubbles:!0,composed:!0})),this.close()}catch(e){this._error=L(e,this._lang)}finally{this._saving=!1}}}render(){if(!this._open||!this._draft)return h;let e=this._lang,t=this._draft;return o` + `,l([v({attribute:!1})],g.prototype,"hass",2),l([v({type:Boolean,attribute:"checklists-enabled"})],g.prototype,"checklistsEnabled",2),l([v({type:Boolean,attribute:"schedule-time-enabled"})],g.prototype,"scheduleTimeEnabled",2),l([v({type:Boolean,attribute:"completion-actions-enabled"})],g.prototype,"completionActionsEnabled",2),l([v({type:Number,attribute:"default-warning-days"})],g.prototype,"defaultWarningDays",2),l([p()],g.prototype,"parts",2),l([p()],g.prototype,"_open",2),l([p()],g.prototype,"_loading",2),l([p()],g.prototype,"_error",2),l([p()],g.prototype,"_entryId",2),l([p()],g.prototype,"_taskId",2),l([p()],g.prototype,"_objectChoices",2),l([p()],g.prototype,"_name",2),l([p()],g.prototype,"_type",2),l([p()],g.prototype,"_scheduleType",2),l([p()],g.prototype,"_intervalDays",2),l([p()],g.prototype,"_intervalUnit",2),l([p()],g.prototype,"_dueDate",2),l([p()],g.prototype,"_warningDays",2),l([p()],g.prototype,"_earliestCompletionDays",2),l([p()],g.prototype,"_intervalAnchor",2),l([p()],g.prototype,"_weekdays",2),l([p()],g.prototype,"_nth",2),l([p()],g.prototype,"_nthWeekday",2),l([p()],g.prototype,"_domDay",2),l([p()],g.prototype,"_domLastDay",2),l([p()],g.prototype,"_domBusiness",2),l([p()],g.prototype,"_calOffset",2),l([p()],g.prototype,"_seasonMonths",2),l([p()],g.prototype,"_endsMode",2),l([p()],g.prototype,"_endsCount",2),l([p()],g.prototype,"_endsUntil",2),l([p()],g.prototype,"_schedulePreview",2),l([p()],g.prototype,"_schedulePreviewEnded",2),l([p()],g.prototype,"_notes",2),l([p()],g.prototype,"_documentationUrl",2),l([p()],g.prototype,"_customIcon",2),l([p()],g.prototype,"_priority",2),l([p()],g.prototype,"_labels",2),l([p()],g.prototype,"_enabled",2),l([p()],g.prototype,"_triggerEntityId",2),l([p()],g.prototype,"_triggerEntityIds",2),l([p()],g.prototype,"_triggerEntityLogic",2),l([p()],g.prototype,"_triggerAttribute",2),l([p()],g.prototype,"_triggerType",2),l([p()],g.prototype,"_triggerAbove",2),l([p()],g.prototype,"_triggerBelow",2),l([p()],g.prototype,"_triggerForMinutes",2),l([p()],g.prototype,"_triggerTargetValue",2),l([p()],g.prototype,"_triggerDeltaMode",2),l([p()],g.prototype,"_triggerBaselineValue",2),l([p()],g.prototype,"_liveBaselineValue",2),l([p()],g.prototype,"_autoCompleteOnRecovery",2),l([p()],g.prototype,"_triggerFromState",2),l([p()],g.prototype,"_triggerToState",2),l([p()],g.prototype,"_triggerTargetChanges",2),l([p()],g.prototype,"_triggerRuntimeHours",2),l([p()],g.prototype,"_triggerOnStates",2),l([p()],g.prototype,"_compoundLogic",2),l([p()],g.prototype,"_compoundConditions",2),l([p()],g.prototype,"_suggestedAttributes",2),l([p()],g.prototype,"_availableAttributes",2),l([p()],g.prototype,"_entityDomain",2),l([p()],g.prototype,"_lastPerformed",2),l([p()],g.prototype,"_nfcTagId",2),l([p()],g.prototype,"_readingUnit",2),l([p()],g.prototype,"_consumesParts",2),l([p()],g.prototype,"_partsLoadFailed",2),l([p()],g.prototype,"_availableTags",2),l([p()],g.prototype,"_responsibleUserId",2),l([p()],g.prototype,"_assigneePool",2),l([p()],g.prototype,"_rotationStrategy",2),l([p()],g.prototype,"_availableUsers",2),l([p()],g.prototype,"_checklistText",2),l([p()],g.prototype,"_scheduleTime",2),l([p()],g.prototype,"_actionService",2),l([p()],g.prototype,"_actionTargetEntity",2),l([p()],g.prototype,"_actionData",2),l([p()],g.prototype,"_actionDataJsonFallback",2),l([p()],g.prototype,"_actionTesting",2),l([p()],g.prototype,"_actionTestResult",2),l([p()],g.prototype,"_actionTestError",2),l([p()],g.prototype,"_qcNotes",2),l([p()],g.prototype,"_qcCost",2),l([p()],g.prototype,"_qcDuration",2),l([p()],g.prototype,"_qcFeedback",2),l([p()],g.prototype,"_environmentalEntity",2),l([p()],g.prototype,"_environmentalAttribute",2);it=g;customElements.get("maintenance-task-dialog")||customElements.define("maintenance-task-dialog",it)});var V,Jt=x(()=>{"use strict";C();N();I();te();V=class extends k{constructor(){super(...arguments);this._open=!1;this._saving=!1;this._error="";this._draft=null;this._originalSnapshot=null}get _lang(){return this.hass?.language||"en"}openEdit(e){this._draft={...e},this._originalSnapshot={...e},this._error="",this._open=!0}close(){this._open=!1,this._error="",this._draft=null,this._originalSnapshot=null}_set(e,t){this._draft&&(this._draft={...this._draft,[e]:t})}async _save(){if(!(!this._draft||!this._originalSnapshot)){this._saving=!0,this._error="";try{let e={type:"maintenance_supporter/task/history/update",entry_id:this._draft.entry_id,task_id:this._draft.task_id,original_timestamp:this._originalSnapshot.original_timestamp};if(this._draft.timestamp!==this._originalSnapshot.timestamp&&(e.timestamp=this._draft.timestamp),this._draft.notes!==this._originalSnapshot.notes&&(e.notes=this._draft.notes),this._draft.cost!==this._originalSnapshot.cost&&(e.cost=this._draft.cost),this._draft.duration!==this._originalSnapshot.duration&&(e.duration=this._draft.duration),this._draft.completed_by!==this._originalSnapshot.completed_by&&(e.completed_by=this._draft.completed_by),Object.keys(e).filter(i=>!["type","entry_id","task_id","original_timestamp"].includes(i)).length===0){this.close();return}await this.hass.connection.sendMessagePromise(e),this.dispatchEvent(new CustomEvent("history-entry-saved",{detail:{entry_id:this._draft.entry_id,task_id:this._draft.task_id,new_timestamp:this._draft.timestamp},bubbles:!0,composed:!0})),this.close()}catch(e){this._error=L(e,this._lang)}finally{this._saving=!1}}}render(){if(!this._open||!this._draft)return h;let e=this._lang,t=this._draft;return o`
- `}};O.styles=E` + `}};V.styles=A` :host { display: contents; } .backdrop { position: fixed; inset: 0; @@ -2590,7 +2677,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" background: rgba(211,47,47,0.1); border-radius: 6px; } - `,l([v({attribute:!1})],O.prototype,"hass",2),l([p()],O.prototype,"_open",2),l([p()],O.prototype,"_saving",2),l([p()],O.prototype,"_error",2),l([p()],O.prototype,"_draft",2);customElements.get("maintenance-history-edit-dialog")||customElements.define("maintenance-history-edit-dialog",O)});function oe(a){return a.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function Jt(a){return!a.startsWith("data:image/svg+xml,")&&!a.startsWith("data:image/png;base64,")?"":oe(a)}function is(a){return a.replace(/[/\\:*?"<>|#%]+/g,"").replace(/\s+/g,"-").toLowerCase().substring(0,100)}var P,Zt=x(()=>{"use strict";C();N();I();P=class extends w{constructor(){super(...arguments);this.lang="en";this._open=!1;this._loading=!1;this._error="";this._viewResult=null;this._completeResult=null;this._urlMode="companion";this._entryId="";this._taskId=null;this._objectName="";this._taskName="";this._generateSeq=0}openForObject(e,t){this._entryId=e,this._taskId=null,this._objectName=t,this._taskName="",this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._open=!0,this._generate()}openForTask(e,t,i,n){this._entryId=e,this._taskId=t,this._objectName=i,this._taskName=n,this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._open=!0,this._generate()}async _generate(){let e=++this._generateSeq;this._loading=!0,this._error="",this._viewResult=null,this._completeResult=null;try{let t={type:"maintenance_supporter/qr/generate",entry_id:this._entryId,url_mode:this._urlMode};this._taskId&&(t.task_id=this._taskId);let i=[this.hass.connection.sendMessagePromise({...t,action:"view"})];this._taskId&&i.push(this.hass.connection.sendMessagePromise({...t,action:"complete"}));let n=await Promise.all(i);if(e!==this._generateSeq)return;this._viewResult=n[0],n.length>1&&(this._completeResult=n[1])}catch(t){if(e!==this._generateSeq)return;let i=t?.code,n=t?.message;this._error=i==="no_url"||typeof n=="string"&&n.includes("No Home Assistant URL")?r("qr_error_no_url",this.lang):r("qr_error",this.lang)}finally{e===this._generateSeq&&(this._loading=!1)}}_setUrlMode(e){this._urlMode!==e&&(this._urlMode=e,this._generate())}_print(){if(!this._viewResult)return;let e=this._viewResult,t=e.label.task_name?`${e.label.object_name} \u2014 ${e.label.task_name}`:e.label.object_name,i=[e.label.manufacturer,e.label.model].filter(Boolean).join(" "),n=window.open("","_blank","width=600,height=500");if(!n)return;let d=this.lang||"en",c=oe(t),_=oe(i),u=!!this._completeResult,f=oe(r("qr_action_view",d)),m=oe(r("qr_action_complete",d));n.document.write(` + `,l([v({attribute:!1})],V.prototype,"hass",2),l([p()],V.prototype,"_open",2),l([p()],V.prototype,"_saving",2),l([p()],V.prototype,"_error",2),l([p()],V.prototype,"_draft",2);customElements.get("maintenance-history-edit-dialog")||customElements.define("maintenance-history-edit-dialog",V)});function oe(a){return a.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function Zt(a){return!a.startsWith("data:image/svg+xml,")&&!a.startsWith("data:image/png;base64,")?"":oe(a)}function rs(a){return a.replace(/[/\\:*?"<>|#%]+/g,"").replace(/\s+/g,"-").toLowerCase().substring(0,100)}var H,Qt=x(()=>{"use strict";C();N();I();H=class extends k{constructor(){super(...arguments);this.lang="en";this._open=!1;this._loading=!1;this._error="";this._viewResult=null;this._completeResult=null;this._urlMode="companion";this._entryId="";this._taskId=null;this._objectName="";this._taskName="";this._generateSeq=0}openForObject(e,t){this._entryId=e,this._taskId=null,this._objectName=t,this._taskName="",this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._open=!0,this._generate()}openForTask(e,t,i,n){this._entryId=e,this._taskId=t,this._objectName=i,this._taskName=n,this._urlMode="companion",this._error="",this._viewResult=null,this._completeResult=null,this._open=!0,this._generate()}async _generate(){let e=++this._generateSeq;this._loading=!0,this._error="",this._viewResult=null,this._completeResult=null;try{let t={type:"maintenance_supporter/qr/generate",entry_id:this._entryId,url_mode:this._urlMode};this._taskId&&(t.task_id=this._taskId);let i=[this.hass.connection.sendMessagePromise({...t,action:"view"})];this._taskId&&i.push(this.hass.connection.sendMessagePromise({...t,action:"complete"}));let n=await Promise.all(i);if(e!==this._generateSeq)return;this._viewResult=n[0],n.length>1&&(this._completeResult=n[1])}catch(t){if(e!==this._generateSeq)return;let i=t?.code,n=t?.message;this._error=i==="no_url"||typeof n=="string"&&n.includes("No Home Assistant URL")?r("qr_error_no_url",this.lang):r("qr_error",this.lang)}finally{e===this._generateSeq&&(this._loading=!1)}}_setUrlMode(e){this._urlMode!==e&&(this._urlMode=e,this._generate())}_print(){if(!this._viewResult)return;let e=this._viewResult,t=e.label.task_name?`${e.label.object_name} \u2014 ${e.label.task_name}`:e.label.object_name,i=[e.label.manufacturer,e.label.model].filter(Boolean).join(" "),n=window.open("","_blank","width=600,height=500");if(!n)return;let d=this.lang||"en",c=oe(t),u=oe(i),_=!!this._completeResult,f=oe(r("qr_action_view",d)),m=oe(r("qr_action_complete",d));n.document.write(` ${c}

${c}

-${_?`
${_}
`:""} +${u?`
${u}
`:""}
- QR Info + QR Info
${f}
- ${u?`
- QR Complete + ${_?`
+ QR Complete
${m}
`:""}
${oe(this._viewResult.url)}