From 016af2e013e52e6f03e4b816d08dfd81ad02c888 Mon Sep 17 00:00:00 2001 From: Home Assistant Version Control Date: Thu, 23 Jul 2026 19:02:12 +0000 Subject: [PATCH] 348 files --- .HA_VERSION | 2 +- .gitignore | 38 +- .ha_run.lock | 2 +- .storage/lovelace.alarm_section | 205 + .storage/lovelace.dashboard_home | 1057 ++++ .storage/lovelace.dashboard_music | 126 + .storage/lovelace.dashboard_temp | 538 ++ .storage/lovelace.in_meeting | 145 + .storage/lovelace.kobo_dashboard | 424 ++ .storage/lovelace.map | 12 + .storage/lovelace.morning_hub | 577 ++ .storage/lovelace.morning_ipad | 345 ++ .storage/lovelace.network_cellphone | 141 + .storage/lovelace_dashboards | 90 + .storage/lovelace_resources | 304 + AGENTS.md | 38 + automations.yaml | 4955 +++++++++-------- custom_components/ai_agent_ha/__init__.py | 426 -- .../__pycache__/__init__.cpython-314.pyc | Bin 21599 -> 0 bytes .../__pycache__/agent.cpython-314.pyc | Bin 194966 -> 0 bytes .../__pycache__/config_flow.cpython-314.pyc | Bin 27970 -> 0 bytes .../__pycache__/const.cpython-314.pyc | Bin 1065 -> 0 bytes custom_components/ai_agent_ha/agent.py | 4415 --------------- custom_components/ai_agent_ha/config_flow.py | 917 --- custom_components/ai_agent_ha/const.py | 40 - .../ai_agent_ha/dashboard_templates.py | 390 -- custom_components/ai_agent_ha/frontend.yaml | 8 - .../ai_agent_ha/frontend/ai_agent_ha-panel.js | 1479 ----- custom_components/ai_agent_ha/manifest.json | 24 - custom_components/ai_agent_ha/services.yaml | 98 - custom_components/ai_agent_ha/strings.json | 91 - .../ai_agent_ha/translations/ca.json | 81 - .../ai_agent_ha/translations/de.json | 86 - .../ai_agent_ha/translations/en.json | 91 - .../ai_agent_ha/translations/en.txt | 81 - .../ai_agent_ha/translations/es.json | 81 - .../__pycache__/__init__.cpython-314.pyc | Bin 19438 -> 19438 bytes .../__pycache__/button.cpython-314.pyc | Bin 5073 -> 5073 bytes .../__pycache__/camera.cpython-314.pyc | Bin 50461 -> 50461 bytes .../__pycache__/config_flow.cpython-314.pyc | Bin 33689 -> 40786 bytes .../__pycache__/const.cpython-314.pyc | Bin 5261 -> 5958 bytes .../__pycache__/coordinator.cpython-314.pyc | Bin 94965 -> 99484 bytes .../google_scraper.cpython-314.pyc | Bin 22822 -> 22822 bytes .../image_processing.cpython-314.pyc | Bin 15148 -> 15148 bytes .../__pycache__/number.cpython-314.pyc | Bin 13314 -> 13314 bytes .../__pycache__/playlist.cpython-314.pyc | Bin 8212 -> 8212 bytes .../__pycache__/select.cpython-314.pyc | Bin 19468 -> 19468 bytes .../__pycache__/sensor.cpython-314.pyc | Bin 10113 -> 10113 bytes .../__pycache__/store.cpython-314.pyc | Bin 2724 -> 2724 bytes .../__pycache__/switch.cpython-314.pyc | Bin 4658 -> 4658 bytes .../__pycache__/text.cpython-314.pyc | Bin 5175 -> 5175 bytes .../album_slideshow/config_flow.py | 158 + custom_components/album_slideshow/const.py | 28 + .../album_slideshow/coordinator.py | 93 + .../album_slideshow/manifest.json | 2 +- .../album_slideshow/strings.json | 24 +- .../album_slideshow/translations/en.json | 24 +- .../www/album-slideshow-card.js | 2 +- .../__pycache__/__init__.cpython-314.pyc | Bin 146302 -> 146302 bytes .../alarm_control_panel.cpython-314.pyc | Bin 13527 -> 13527 bytes .../__pycache__/alexa_entity.cpython-314.pyc | Bin 30140 -> 30140 bytes .../__pycache__/alexa_media.cpython-314.pyc | Bin 2423 -> 2423 bytes .../__pycache__/binary_sensor.cpython-314.pyc | Bin 6358 -> 0 bytes .../__pycache__/config_flow.cpython-314.pyc | Bin 61096 -> 61185 bytes .../__pycache__/const.cpython-314.pyc | Bin 15833 -> 15833 bytes .../__pycache__/coordinator.cpython-314.pyc | Bin 3898 -> 3898 bytes .../__pycache__/diagnostics.cpython-314.pyc | Bin 19726 -> 19726 bytes .../__pycache__/exceptions.cpython-314.pyc | Bin 2153 -> 2153 bytes .../__pycache__/helpers.cpython-314.pyc | Bin 26249 -> 26249 bytes .../__pycache__/light.cpython-314.pyc | Bin 26680 -> 26680 bytes .../__pycache__/media_player.cpython-314.pyc | Bin 98446 -> 98446 bytes .../__pycache__/metrics.cpython-314.pyc | Bin 11491 -> 11491 bytes .../__pycache__/notify.cpython-314.pyc | Bin 16079 -> 16079 bytes .../__pycache__/runtime_data.cpython-314.pyc | Bin 8622 -> 8622 bytes .../__pycache__/sensor.cpython-314.pyc | Bin 58833 -> 58834 bytes .../__pycache__/services.cpython-314.pyc | Bin 19665 -> 19665 bytes .../__pycache__/switch.cpython-314.pyc | Bin 23815 -> 23815 bytes custom_components/alexa_media/config_flow.py | 3 +- custom_components/alexa_media/manifest.json | 2 +- custom_components/alexa_media/sensor.py | 2 +- .../alexa_media/translations/fr.json | 32 +- custom_components/ha_mcp_tools/__init__.py | 3 +- .../__pycache__/__init__.cpython-314.pyc | Bin 140430 -> 140500 bytes .../__pycache__/config_flow.cpython-314.pyc | Bin 27610 -> 27610 bytes .../__pycache__/const.cpython-314.pyc | Bin 8093 -> 8127 bytes .../__pycache__/coordinator.cpython-314.pyc | Bin 6816 -> 6816 bytes .../embedded_entry.cpython-314.pyc | Bin 18046 -> 18046 bytes .../embedded_server.cpython-314.pyc | Bin 81752 -> 85129 bytes .../embedded_setup.cpython-314.pyc | Bin 40502 -> 40502 bytes .../__pycache__/hacs_nudge.cpython-314.pyc | Bin 6897 -> 6897 bytes .../install_source_check.cpython-314.pyc | Bin 5816 -> 5816 bytes .../__pycache__/llm_api.cpython-314.pyc | Bin 33839 -> 33839 bytes .../__pycache__/mcp_webhook.cpython-314.pyc | Bin 38155 -> 38521 bytes .../oauth_autoapprove.cpython-314.pyc | Bin 16547 -> 17364 bytes .../__pycache__/oauth_legacy.cpython-314.pyc | Bin 42932 -> 43191 bytes .../__pycache__/ui_panel.cpython-314.pyc | Bin 35899 -> 35899 bytes .../__pycache__/update.cpython-314.pyc | Bin 16862 -> 16862 bytes .../__pycache__/websocket_api.cpython-314.pyc | Bin 298752 -> 298752 bytes .../__pycache__/yaml_rt.cpython-314.pyc | Bin 13176 -> 13176 bytes custom_components/ha_mcp_tools/const.py | 15 +- .../ha_mcp_tools/embedded_server.py | 66 +- custom_components/ha_mcp_tools/manifest.json | 2 +- custom_components/ha_mcp_tools/mcp_webhook.py | 32 +- .../ha_mcp_tools/oauth_autoapprove.py | 17 + .../ha_mcp_tools/oauth_legacy.py | 6 +- custom_components/ha_mcp_tools/services.yaml | 20 +- custom_components/ha_washdata/__init__.py | 15 + custom_components/ha_washdata/analysis.py | 15 +- custom_components/ha_washdata/const.py | 103 + .../ha_washdata/cycle_detector.py | 526 +- custom_components/ha_washdata/frontend.py | 20 +- custom_components/ha_washdata/learning.py | 59 +- custom_components/ha_washdata/manager.py | 223 +- custom_components/ha_washdata/manifest.json | 2 +- custom_components/ha_washdata/ml/engine.py | 37 + .../ha_washdata/ml/training_task.py | 4 +- custom_components/ha_washdata/playground.py | 31 +- .../ha_washdata/profile_store.py | 175 +- .../ha_washdata/setup_advisor.py | 13 +- .../ha_washdata/suggestion_engine.py | 14 +- .../ha_washdata/task_registry.py | 56 +- .../ha_washdata/translations/panel/bg.json | 9 - .../ha_washdata/translations/panel/bs.json | 9 - .../ha_washdata/translations/panel/cs.json | 9 - .../ha_washdata/translations/panel/da.json | 9 - .../ha_washdata/translations/panel/de.json | 9 - .../ha_washdata/translations/panel/el.json | 9 - .../ha_washdata/translations/panel/en.json | 14 +- .../ha_washdata/translations/panel/es.json | 9 - .../ha_washdata/translations/panel/et.json | 9 - .../ha_washdata/translations/panel/fi.json | 9 - .../ha_washdata/translations/panel/fr.json | 9 - .../ha_washdata/translations/panel/hr.json | 9 - .../ha_washdata/translations/panel/hu.json | 9 - .../ha_washdata/translations/panel/is.json | 9 - .../ha_washdata/translations/panel/it.json | 9 - .../ha_washdata/translations/panel/ja.json | 9 - .../ha_washdata/translations/panel/ko.json | 9 - .../ha_washdata/translations/panel/lt.json | 9 - .../ha_washdata/translations/panel/lv.json | 9 - .../ha_washdata/translations/panel/mk.json | 9 - .../ha_washdata/translations/panel/nb.json | 9 - .../ha_washdata/translations/panel/nl.json | 9 - .../ha_washdata/translations/panel/pl.json | 9 - .../ha_washdata/translations/panel/pt-BR.json | 9 - .../ha_washdata/translations/panel/pt.json | 9 - .../ha_washdata/translations/panel/ro.json | 9 - .../ha_washdata/translations/panel/ru.json | 9 - .../ha_washdata/translations/panel/sk.json | 9 - .../ha_washdata/translations/panel/sl.json | 9 - .../ha_washdata/translations/panel/sq.json | 9 - .../translations/panel/sr-Latn.json | 9 - .../ha_washdata/translations/panel/sv.json | 9 - .../ha_washdata/translations/panel/tr.json | 9 - .../ha_washdata/translations/panel/uk.json | 9 - .../translations/panel/zh-Hans.json | 9 - custom_components/ha_washdata/ws_api.py | 146 +- .../ha_washdata/www/ha-washdata-panel.js | 95 +- .../__pycache__/__init__.cpython-314.pyc | Bin 77024 -> 77024 bytes .../__pycache__/binary_sensor.cpython-314.pyc | Bin 9157 -> 9157 bytes .../__pycache__/button.cpython-314.pyc | Bin 7122 -> 7122 bytes .../__pycache__/calendar.cpython-314.pyc | Bin 25950 -> 25950 bytes .../__pycache__/condition.cpython-314.pyc | Bin 2192 -> 2192 bytes .../__pycache__/config_flow.cpython-314.pyc | Bin 61883 -> 61883 bytes .../config_flow_helpers.cpython-314.pyc | Bin 16585 -> 16585 bytes ...config_flow_options_global.cpython-314.pyc | Bin 41821 -> 41821 bytes .../config_flow_trigger.cpython-314.pyc | Bin 48388 -> 48388 bytes .../__pycache__/const.cpython-314.pyc | Bin 18610 -> 18610 bytes .../__pycache__/coordinator.cpython-314.pyc | Bin 65077 -> 65077 bytes .../__pycache__/diagnostics.cpython-314.pyc | Bin 10295 -> 10295 bytes .../__pycache__/intent.cpython-314.pyc | Bin 35535 -> 35535 bytes .../__pycache__/logbook.cpython-314.pyc | Bin 12851 -> 12851 bytes .../__pycache__/panel.cpython-314.pyc | Bin 5559 -> 5559 bytes .../__pycache__/parts_runtime.cpython-314.pyc | Bin 15495 -> 15495 bytes .../__pycache__/repairs.cpython-314.pyc | Bin 36507 -> 36507 bytes .../__pycache__/sensor.cpython-314.pyc | Bin 46209 -> 46209 bytes .../__pycache__/storage.cpython-314.pyc | Bin 25007 -> 25007 bytes .../__pycache__/templates.cpython-314.pyc | Bin 24487 -> 24487 bytes .../templates_i18n.cpython-314.pyc | Bin 237339 -> 237339 bytes .../__pycache__/todo.cpython-314.pyc | Bin 9384 -> 9384 bytes .../__pycache__/trigger.cpython-314.pyc | Bin 2954 -> 2954 bytes .../__pycache__/views.cpython-314.pyc | Bin 21476 -> 21476 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 365 -> 365 bytes .../__pycache__/entity_base.cpython-314.pyc | Bin 5235 -> 5235 bytes .../summary_coordinator.cpython-314.pyc | Bin 7694 -> 7694 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 8303 -> 8303 bytes .../__pycache__/base_trigger.cpython-314.pyc | Bin 14559 -> 14559 bytes .../__pycache__/compound.cpython-314.pyc | Bin 16033 -> 16033 bytes .../__pycache__/counter.cpython-314.pyc | Bin 8254 -> 8254 bytes .../__pycache__/runtime.cpython-314.pyc | Bin 17984 -> 17984 bytes .../__pycache__/state_change.cpython-314.pyc | Bin 11346 -> 11346 bytes .../__pycache__/threshold.cpython-314.pyc | Bin 9628 -> 9628 bytes .../__tests__/task-detail-renderer.test.ts | 39 +- .../__tests__/task-detail-view.test.ts | 10 +- .../frontend-src/maintenance-panel.ts | 62 +- .../frontend-src/renderers/task-detail.ts | 36 +- .../__pycache__/__init__.cpython-314.pyc | Bin 3742 -> 3742 bytes .../frontend/maintenance-panel.js | 138 +- .../__pycache__/__init__.cpython-314.pyc | Bin 285 -> 285 bytes .../action_listener.cpython-314.pyc | Bin 6226 -> 6226 bytes .../__pycache__/aggregate.cpython-314.pyc | Bin 5113 -> 5113 bytes .../__pycache__/battery_fleet.cpython-314.pyc | Bin 17870 -> 17870 bytes .../battery_fleet_setup.cpython-314.pyc | Bin 16331 -> 16331 bytes .../helpers/__pycache__/dates.cpython-314.pyc | Bin 9812 -> 9812 bytes .../__pycache__/documents.cpython-314.pyc | Bin 32721 -> 32721 bytes .../entity_analyzer.cpython-314.pyc | Bin 9598 -> 9598 bytes .../global_options.cpython-314.pyc | Bin 4374 -> 4374 bytes .../helpers/__pycache__/i18n.cpython-314.pyc | Bin 1337 -> 1337 bytes .../integration_signatures.cpython-314.pyc | Bin 833 -> 833 bytes .../notification_manager.cpython-314.pyc | Bin 62232 -> 62232 bytes .../notify_targets.cpython-314.pyc | Bin 2701 -> 2701 bytes .../helpers/__pycache__/parts.cpython-314.pyc | Bin 24174 -> 24174 bytes .../helpers/__pycache__/pause.cpython-314.pyc | Bin 4217 -> 4217 bytes .../__pycache__/permissions.cpython-314.pyc | Bin 5960 -> 5960 bytes .../problem_sensors.cpython-314.pyc | Bin 14384 -> 14384 bytes .../__pycache__/qr_generator.cpython-314.pyc | Bin 8279 -> 8279 bytes .../__pycache__/qrcodegen.cpython-314.pyc | Bin 50344 -> 50344 bytes .../__pycache__/retention.cpython-314.pyc | Bin 10544 -> 10544 bytes .../__pycache__/sanitize.cpython-314.pyc | Bin 13579 -> 13579 bytes .../__pycache__/saved_views.cpython-314.pyc | Bin 8446 -> 8446 bytes .../__pycache__/schedule.cpython-314.pyc | Bin 29428 -> 29428 bytes .../settings_registry.cpython-314.pyc | Bin 7988 -> 7988 bytes .../__pycache__/status.cpython-314.pyc | Bin 2481 -> 2481 bytes .../__pycache__/task_fields.cpython-314.pyc | Bin 2021 -> 2021 bytes .../threshold_calculator.cpython-314.pyc | Bin 6238 -> 6238 bytes .../__pycache__/vacation.cpython-314.pyc | Bin 16497 -> 16497 bytes .../__pycache__/workday.cpython-314.pyc | Bin 9006 -> 9006 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 833 -> 833 bytes .../__pycache__/_discovery.cpython-314.pyc | Bin 8646 -> 8646 bytes .../__pycache__/_model.cpython-314.pyc | Bin 14767 -> 14767 bytes .../__pycache__/_registry.cpython-314.pyc | Bin 1196 -> 1196 bytes .../__pycache__/air.cpython-314.pyc | Bin 8533 -> 8533 bytes .../__pycache__/cars.cpython-314.pyc | Bin 8669 -> 8669 bytes .../__pycache__/garden.cpython-314.pyc | Bin 5419 -> 5419 bytes .../__pycache__/heating.cpython-314.pyc | Bin 7697 -> 7697 bytes .../__pycache__/home_it.cpython-314.pyc | Bin 1517 -> 1517 bytes .../__pycache__/kitchen.cpython-314.pyc | Bin 8631 -> 8631 bytes .../__pycache__/locks.cpython-314.pyc | Bin 4828 -> 4828 bytes .../__pycache__/personal.cpython-314.pyc | Bin 1339 -> 1339 bytes .../__pycache__/pets.cpython-314.pyc | Bin 2216 -> 2216 bytes .../__pycache__/printers.cpython-314.pyc | Bin 4766 -> 4766 bytes .../__pycache__/transports.cpython-314.pyc | Bin 3545 -> 3545 bytes .../__pycache__/vacuums.cpython-314.pyc | Bin 7798 -> 7798 bytes .../__pycache__/wallboxes.cpython-314.pyc | Bin 2319 -> 2319 bytes .../__pycache__/xiaomi.cpython-314.pyc | Bin 2016 -> 2016 bytes .../maintenance_supporter/manifest.json | 2 +- .../__pycache__/__init__.cpython-314.pyc | Bin 529 -> 529 bytes .../maintenance_object.cpython-314.pyc | Bin 5037 -> 5037 bytes .../maintenance_task.cpython-314.pyc | Bin 27703 -> 27703 bytes .../maintenance_type.cpython-314.pyc | Bin 3085 -> 3085 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 27231 -> 27231 bytes .../__pycache__/analysis.cpython-314.pyc | Bin 13069 -> 13069 bytes .../__pycache__/battery_fleet.cpython-314.pyc | Bin 4831 -> 4831 bytes .../__pycache__/dashboard.cpython-314.pyc | Bin 29719 -> 29719 bytes .../__pycache__/documents.cpython-314.pyc | Bin 13273 -> 13273 bytes .../__pycache__/groups.cpython-314.pyc | Bin 8318 -> 8318 bytes .../integration_setups.cpython-314.pyc | Bin 9252 -> 9252 bytes .../websocket/__pycache__/io.cpython-314.pyc | Bin 37215 -> 37215 bytes .../__pycache__/objects.cpython-314.pyc | Bin 48712 -> 48712 bytes .../__pycache__/parts.cpython-314.pyc | Bin 14394 -> 14394 bytes .../problem_sensors.cpython-314.pyc | Bin 8302 -> 8302 bytes .../__pycache__/saved_views.cpython-314.pyc | Bin 6010 -> 6010 bytes .../__pycache__/tags.cpython-314.pyc | Bin 2930 -> 2930 bytes .../__pycache__/tasks.cpython-314.pyc | Bin 1755 -> 1755 bytes .../__pycache__/tasks_actions.cpython-314.pyc | Bin 17656 -> 17656 bytes .../__pycache__/tasks_crud.cpython-314.pyc | Bin 36273 -> 36273 bytes .../__pycache__/tasks_history.cpython-314.pyc | Bin 6517 -> 6517 bytes .../tasks_lifecycle.cpython-314.pyc | Bin 9553 -> 9553 bytes .../__pycache__/tasks_persist.cpython-314.pyc | Bin 8772 -> 8772 bytes .../tasks_validation.cpython-314.pyc | Bin 10194 -> 10194 bytes .../__pycache__/users.cpython-314.pyc | Bin 7626 -> 7626 bytes .../__pycache__/vacation.cpython-314.pyc | Bin 11145 -> 11145 bytes custom_components/taskmate/__init__.py | 71 +- .../__pycache__/__init__.cpython-314.pyc | Bin 93659 -> 97850 bytes .../__pycache__/binary_sensor.cpython-314.pyc | Bin 5642 -> 5642 bytes .../__pycache__/button.cpython-314.pyc | Bin 14932 -> 14966 bytes .../__pycache__/calendar.cpython-314.pyc | Bin 13685 -> 13685 bytes .../__pycache__/config_flow.cpython-314.pyc | Bin 2852 -> 2852 bytes .../__pycache__/const.cpython-314.pyc | Bin 23665 -> 24181 bytes .../coord_assignments.cpython-314.pyc | Bin 31708 -> 35771 bytes .../__pycache__/coord_avatars.cpython-314.pyc | Bin 8254 -> 8254 bytes .../__pycache__/coord_badges.cpython-314.pyc | Bin 17532 -> 17532 bytes .../coord_calendar.cpython-314.pyc | Bin 23069 -> 23069 bytes .../coord_challenges.cpython-314.pyc | Bin 12678 -> 12678 bytes .../__pycache__/coord_chores.cpython-314.pyc | Bin 81413 -> 86720 bytes .../coord_mandatory.cpython-314.pyc | Bin 25601 -> 25601 bytes .../coord_notifications.cpython-314.pyc | Bin 38321 -> 42930 bytes .../__pycache__/coord_points.cpython-314.pyc | Bin 58994 -> 59211 bytes .../__pycache__/coord_quests.cpython-314.pyc | Bin 11086 -> 11086 bytes .../__pycache__/coord_rewards.cpython-314.pyc | Bin 34521 -> 34640 bytes .../coord_templates.cpython-314.pyc | Bin 9310 -> 16866 bytes .../__pycache__/coord_timed.cpython-314.pyc | Bin 13506 -> 13506 bytes .../__pycache__/coordinator.cpython-314.pyc | Bin 55327 -> 56508 bytes .../__pycache__/frontend.cpython-314.pyc | Bin 9642 -> 9669 bytes .../__pycache__/http_calendar.cpython-314.pyc | Bin 5114 -> 5114 bytes .../__pycache__/http_photos.cpython-314.pyc | Bin 9264 -> 9264 bytes .../taskmate/__pycache__/ics.cpython-314.pyc | Bin 8068 -> 8068 bytes .../__pycache__/intents.cpython-314.pyc | Bin 7216 -> 7216 bytes .../__pycache__/models.cpython-314.pyc | Bin 62228 -> 68854 bytes .../__pycache__/number.cpython-314.pyc | Bin 5571 -> 5571 bytes .../__pycache__/panel.cpython-314.pyc | Bin 3883 -> 3883 bytes .../__pycache__/photos.cpython-314.pyc | Bin 10788 -> 10791 bytes .../__pycache__/select.cpython-314.pyc | Bin 5013 -> 5025 bytes .../__pycache__/sensor.cpython-314.pyc | Bin 75347 -> 76843 bytes .../__pycache__/storage.cpython-314.pyc | Bin 113108 -> 116820 bytes .../__pycache__/templates.cpython-314.pyc | Bin 5810 -> 5906 bytes .../taskmate/__pycache__/todo.cpython-314.pyc | Bin 6024 -> 6024 bytes .../__pycache__/websocket.cpython-314.pyc | Bin 128791 -> 144985 bytes custom_components/taskmate/button.py | 5 +- custom_components/taskmate/const.py | 4 + .../taskmate/coord_assignments.py | 107 +- custom_components/taskmate/coord_chores.py | 109 +- .../taskmate/coord_notifications.py | 103 +- custom_components/taskmate/coord_points.py | 5 +- custom_components/taskmate/coord_rewards.py | 3 + custom_components/taskmate/coord_templates.py | 136 +- custom_components/taskmate/coordinator.py | 35 + custom_components/taskmate/frontend.py | 1 + custom_components/taskmate/manifest.json | 2 +- custom_components/taskmate/models.py | 139 + custom_components/taskmate/photos.py | 9 +- custom_components/taskmate/select.py | 2 +- custom_components/taskmate/sensor.py | 48 +- custom_components/taskmate/services.yaml | 69 + custom_components/taskmate/storage.py | 38 + custom_components/taskmate/templates.py | 4 +- custom_components/taskmate/websocket.py | 252 +- .../taskmate/www/locales/de.json | 176 +- .../taskmate/www/locales/en-GB.json | 176 +- .../taskmate/www/locales/en.json | 176 +- .../taskmate/www/locales/fr.json | 176 +- .../taskmate/www/locales/nb.json | 176 +- .../taskmate/www/locales/nn.json | 176 +- .../taskmate/www/locales/pt-BR.json | 176 +- .../taskmate/www/locales/pt.json | 176 +- .../taskmate/www/taskmate-child-card.js | 330 +- .../taskmate/www/taskmate-design.js | 32 +- .../taskmate/www/taskmate-family-goal-card.js | 97 +- .../taskmate/www/taskmate-leaderboard-card.js | 4 +- .../taskmate/www/taskmate-overview-card.js | 4 +- .../taskmate/www/taskmate-panel.js | 870 ++- .../www/taskmate-parent-dashboard-card.js | 11 +- .../www/taskmate-photo-gallery-card.js | 92 +- .../taskmate/www/taskmate-rewards-card.js | 2 +- watchman_report.txt | 36 +- .../multiple-entity-row.js | 2 +- .../multiple-entity-row.js.gz | Bin 19518 -> 19872 bytes zigbee2mqtt/coordinator_backup.json | 48 +- 348 files changed, 12368 insertions(+), 11689 deletions(-) create mode 100644 .storage/lovelace.alarm_section create mode 100644 .storage/lovelace.dashboard_home create mode 100644 .storage/lovelace.dashboard_music create mode 100644 .storage/lovelace.dashboard_temp create mode 100644 .storage/lovelace.in_meeting create mode 100644 .storage/lovelace.kobo_dashboard create mode 100644 .storage/lovelace.map create mode 100644 .storage/lovelace.morning_hub create mode 100644 .storage/lovelace.morning_ipad create mode 100644 .storage/lovelace.network_cellphone create mode 100644 .storage/lovelace_dashboards create mode 100644 .storage/lovelace_resources delete mode 100644 custom_components/ai_agent_ha/__init__.py delete mode 100644 custom_components/ai_agent_ha/__pycache__/__init__.cpython-314.pyc delete mode 100644 custom_components/ai_agent_ha/__pycache__/agent.cpython-314.pyc delete mode 100644 custom_components/ai_agent_ha/__pycache__/config_flow.cpython-314.pyc delete mode 100644 custom_components/ai_agent_ha/__pycache__/const.cpython-314.pyc delete mode 100644 custom_components/ai_agent_ha/agent.py delete mode 100644 custom_components/ai_agent_ha/config_flow.py delete mode 100644 custom_components/ai_agent_ha/const.py delete mode 100644 custom_components/ai_agent_ha/dashboard_templates.py delete mode 100644 custom_components/ai_agent_ha/frontend.yaml delete mode 100644 custom_components/ai_agent_ha/frontend/ai_agent_ha-panel.js delete mode 100644 custom_components/ai_agent_ha/manifest.json delete mode 100644 custom_components/ai_agent_ha/services.yaml delete mode 100644 custom_components/ai_agent_ha/strings.json delete mode 100644 custom_components/ai_agent_ha/translations/ca.json delete mode 100644 custom_components/ai_agent_ha/translations/de.json delete mode 100644 custom_components/ai_agent_ha/translations/en.json delete mode 100644 custom_components/ai_agent_ha/translations/en.txt delete mode 100644 custom_components/ai_agent_ha/translations/es.json delete mode 100644 custom_components/alexa_media/__pycache__/binary_sensor.cpython-314.pyc diff --git a/.HA_VERSION b/.HA_VERSION index 2398eb2..8717288 100644 --- a/.HA_VERSION +++ b/.HA_VERSION @@ -1 +1 @@ -2026.7.2 \ No newline at end of file +2026.7.3 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8ba8e25..f7a2f56 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,26 @@ -# Home Assistant Core Exclusions -*.db -*.db-shm -*.db-wal -*.log -.storage/ +# Only track specific file types +# Ignore everything by default +* + +# === Home Assistant Version Control Managed (do not edit below) === +# Tracked file types +!*.yaml +!**/.??*.yaml +!*.yml +!**/.??*.yml + +# Track specific .storage configuration files +!.storage/lovelace +!.storage/lovelace_dashboards +!.storage/lovelace_resources +!.storage/lovelace.* +# === End Home Assistant Version Control Managed === + +# Allow directory traversal +!*/ + +# Re-ignore macOS metadata files (even if they match allowed extensions) +._* + +# Excluded files secrets.yaml -*icon*.png -*state.json - - -# Custom integrations and private tokens -.google_nest_prompt_info.json -nest_protect_cookies.json diff --git a/.ha_run.lock b/.ha_run.lock index 7073214..4746d81 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784644362.8406641} \ No newline at end of file +{"pid": 71, "version": 1, "ha_version": "2026.7.3", "start_ts": 1784830467.7871833} \ No newline at end of file diff --git a/.storage/lovelace.alarm_section b/.storage/lovelace.alarm_section new file mode 100644 index 0000000..83b4a79 --- /dev/null +++ b/.storage/lovelace.alarm_section @@ -0,0 +1,205 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace.alarm_section", + "data": { + "config": { + "views": [ + { + "type": "sections", + "sections": [ + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading_style": "title", + "heading": "Sensors" + }, + { + "type": "entities", + "entities": [ + { + "entity": "binary_sensor.entryway" + }, + { + "entity": "binary_sensor.basement" + }, + { + "entity": "binary_sensor.office_occupancy" + }, + { + "entity": "binary_sensor.downstairs_bathroom_occupancy" + }, + { + "entity": "binary_sensor.night_light_occupancy" + }, + { + "entity": "binary_sensor.front_door" + }, + { + "entity": "binary_sensor.patio_door" + }, + { + "entity": "binary_sensor.office_door_contact" + }, + { + "entity": "binary_sensor.garage_door_contact" + }, + { + "entity": "binary_sensor.bedroom_window" + }, + { + "entity": "binary_sensor.seraphine_window" + }, + { + "entity": "binary_sensor.bedroom_window" + }, + { + "entity": "binary_sensor.side_window_1" + }, + { + "entity": "binary_sensor.side_window_2" + }, + { + "entity": "binary_sensor.swimming_pool_window" + }, + { + "entity": "input_boolean.blink_backyard_camera_motion" + }, + { + "entity": "input_boolean.blink_front_camera_motion" + }, + { + "entity": "input_boolean.blink_shed_camera_motion" + }, + { + "entity": "input_boolean.blink_inside_shed_camera_motion" + } + ] + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading_style": "title", + "heading": "Cameras" + }, + { + "square": false, + "type": "grid", + "cards": [ + { + "type": "tile", + "entity": "camera.front_door_front_door", + "show_entity_picture": true + }, + { + "type": "tile", + "entity": "camera.entryway_entryway_camera", + "show_entity_picture": true, + "vertical": false, + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "camera.living_room_living_room_camera", + "show_entity_picture": true, + "vertical": false, + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "camera.hallway_hallway_camera", + "show_entity_picture": true, + "vertical": false, + "features_position": "bottom" + } + ], + "columns": 2 + }, + { + "type": "history-graph", + "entities": [ + { + "entity": "input_boolean.blink_backyard_camera_motion" + }, + { + "entity": "input_boolean.blink_shed_camera_motion" + }, + { + "entity": "input_boolean.blink_inside_shed_camera_motion" + }, + { + "entity": "input_boolean.blink_front_camera_motion" + }, + { + "entity": "binary_sensor.office_occupancy" + }, + { + "entity": "binary_sensor.downstairs_bathroom_occupancy" + }, + { + "entity": "binary_sensor.night_light_occupancy" + }, + { + "entity": "binary_sensor.basement" + }, + { + "entity": "binary_sensor.entryway" + }, + { + "entity": "binary_sensor.garage_door_contact" + } + ], + "title": "Motion Senors", + "hours_to_show": 6, + "logarithmic_scale": false, + "expand_legend": false + }, + { + "type": "history-graph", + "entities": [ + { + "entity": "sensor.night_light_illuminance" + }, + { + "entity": "light.night_light" + } + ], + "hours_to_show": 24 + }, + { + "type": "custom:mushroom-entity-card", + "entity": "input_boolean.office_motion_override", + "name": "Office Motion Override", + "icon": "mdi:home-account", + "icon_color": "green", + "secondary_info": "state", + "tap_action": { + "action": "toggle" + }, + "hold_action": { + "action": "none" + } + }, + { + "type": "tile", + "entity": "alarm_control_panel.blainville_alarm", + "features": [ + { + "type": "alarm-modes" + } + ] + } + ] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/.storage/lovelace.dashboard_home b/.storage/lovelace.dashboard_home new file mode 100644 index 0000000..a4efead --- /dev/null +++ b/.storage/lovelace.dashboard_home @@ -0,0 +1,1057 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace.dashboard_home", + "data": { + "config": { + "views": [ + { + "type": "sections", + "sections": [ + { + "type": "grid", + "cards": [ + { + "type": "thermostat", + "entity": "climate.living_room_living_room" + }, + { + "type": "heading", + "heading_style": "title", + "heading": "Temperature" + }, + { + "type": "tile", + "entity": "climate.basement" + }, + { + "type": "tile", + "entity": "sensor.basement_heater_power_draw" + }, + { + "type": "tile", + "entity": "sensor.hilo_rate_current", + "name": "Hydro Current Rate", + "vertical": false, + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.water_heater_power_draw" + }, + { + "type": "tile", + "entity": "sensor.water_heater_temperature", + "features": [ + { + "type": "trend-graph" + } + ], + "grid_options": { + "columns": "full", + "rows": "auto" + } + }, + { + "type": "tile", + "grid_options": { + "columns": "full", + "rows": "auto" + }, + "entity": "sensor.seatemperatures_sandbanks_today", + "vertical": false, + "features": [ + { + "type": "trend-graph" + } + ], + "features_position": "bottom" + }, + { + "type": "heading", + "icon": "mdi:traffic-light", + "heading": "Traffic", + "heading_style": "title" + }, + { + "type": "history-graph", + "grid_options": { + "rows": "auto" + }, + "entities": [ + { + "entity": "sensor.google_travel_time_work", + "name": { + "type": "entity" + } + }, + { + "entity": "sensor.google_travel_time_parents", + "name": { + "type": "entity" + } + } + ], + "hours_to_show": 48, + "show_names": true, + "logarithmic_scale": false, + "expand_legend": true + }, + { + "type": "heading", + "heading": "HVAC", + "heading_style": "title" + }, + { + "type": "custom:statistics-graph-chart-card", + "show_tooltip_total": false, + "points_per_hour": 15, + "tooltip_match_axis": false, + "tooltip_order": "default", + "height": 300, + "x_grid_style": "dashed", + "y_grid_style": "dashed", + "tooltip_sync": true, + "zoom_sync": true, + "scroll_sync": true, + "entities": [ + { + "entity": "sensor.living_room_living_room_humidity", + "name": "Living Room Humidity" + }, + { + "entity": "sensor.living_room_living_room_temperature", + "name": "Living Room" + }, + { + "entity": "sensor.seraphine_room_nest_temperature_sensor_seraphine_room_temperature", + "name": "Seraphine" + }, + { + "entity": "sensor.bedroom_nest_temperature_sensor_bedroom_temperature", + "name": "Bedroom" + }, + { + "entity": "sensor.basement_temperature", + "name": "Basement" + }, + { + "entity": "sensor.leo_room_nest_temperature_sensor_leo_room_temperature", + "name": "Leonardo" + } + ], + "annotations": [], + "title": "HVAC", + "hide_arrows": false, + "hide_legend_values": false, + "hide_chart_in_legend": true, + "grid_options": { + "rows": "auto" + }, + "state_font_size": "5px", + "label_font_size": "5px" + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading": "Office Automation", + "heading_style": "title" + }, + { + "type": "custom:mushroom-entity-card", + "entity": "input_boolean.away_mode", + "name": "Office/Away Mode", + "icon": "mdi:home-account", + "icon_color": "green", + "secondary_info": "state", + "tap_action": { + "action": "toggle" + }, + "hold_action": { + "action": "none" + } + }, + { + "type": "tile", + "entity": "light.night_light" + }, + { + "type": "tile", + "entity": "input_boolean.meeting_lamp_override" + }, + { + "type": "heading", + "heading": "Motion", + "heading_style": "title" + }, + { + "square": false, + "type": "grid", + "cards": [ + { + "type": "tile", + "entity": "camera.front_door_front_door", + "show_entity_picture": true + }, + { + "type": "tile", + "entity": "camera.entryway_entryway_camera", + "show_entity_picture": true, + "vertical": false, + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "camera.living_room_living_room_camera", + "show_entity_picture": true, + "vertical": false, + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "camera.hallway_hallway_camera", + "show_entity_picture": true, + "vertical": false, + "features_position": "bottom" + } + ], + "columns": 2 + }, + { + "type": "heading", + "heading": "Calendar", + "heading_style": "title" + }, + { + "entities": [ + "calendar.family_calendar" + ], + "show_countdown": true, + "show_progress_bar": true, + "weather": { + "position": "date", + "date": { + "show_conditions": true, + "show_high_temp": true, + "show_low_temp": true, + "show_uv_index": false, + "uv_index_threshold": 0, + "icon_size": "14px", + "font_size": "12px", + "color": "var(--primary-text-color)" + }, + "event": { + "show_conditions": true, + "show_temp": true, + "show_uv_index": false, + "uv_index_threshold": 0, + "icon_size": "14px", + "font_size": "12px", + "color": "var(--primary-text-color)" + }, + "entity": "weather.forecast_home" + }, + "tap_action": { + "action": "expand" + }, + "type": "custom:calendar-card-pro" + }, + { + "type": "heading", + "heading": "Smart Plugs", + "heading_style": "title" + }, + { + "type": "grid", + "cards": [ + { + "type": "tile", + "entity": "switch.freezer", + "features": [ + { + "type": "toggle" + } + ] + }, + { + "type": "tile", + "entity": "switch.fridge", + "vertical": false, + "features": [ + { + "type": "toggle" + } + ], + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "switch.washer", + "vertical": false, + "features_position": "bottom", + "features": [ + { + "type": "toggle" + } + ] + }, + { + "type": "tile", + "entity": "switch.coffee_maker", + "vertical": false, + "features_position": "bottom", + "features": [ + { + "type": "toggle" + } + ] + }, + { + "type": "tile", + "entity": "switch.computer", + "vertical": false, + "features_position": "bottom", + "features": [ + { + "type": "toggle" + } + ] + }, + { + "type": "tile", + "entity": "switch.microwave", + "vertical": false, + "features_position": "bottom", + "features": [ + { + "type": "toggle" + } + ] + }, + { + "type": "tile", + "entity": "switch.ninja", + "vertical": false, + "features_position": "bottom", + "features": [ + { + "type": "toggle" + } + ] + }, + { + "type": "tile", + "entity": "switch.general", + "vertical": false, + "features_position": "bottom", + "features": [ + { + "type": "toggle" + } + ] + }, + { + "type": "tile", + "entity": "light.playroom_light", + "vertical": false, + "features_position": "bottom", + "features": [ + { + "type": "light-brightness" + } + ] + }, + { + "type": "tile", + "entity": "light.office_lamp", + "vertical": false, + "features_position": "bottom", + "features": [ + { + "type": "light-brightness" + } + ] + }, + { + "type": "tile", + "entity": "light.night_light", + "show_entity_picture": false, + "hide_state": false, + "vertical": false, + "features": [ + { + "type": "light-brightness" + } + ], + "features_position": "bottom" + } + ] + }, + { + "type": "tile", + "grid_options": { + "columns": 6, + "rows": 1 + }, + "entity": "automation.tablet_dim_screen_at_night", + "icon": "mdi:tablet", + "hide_state": true, + "vertical": false, + "tap_action": { + "action": "perform-action", + "target": { + "entity_id": "automation.tablet_dim_screen_at_night" + }, + "perform_action": "automation.trigger", + "data": { + "skip_condition": true + } + }, + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.night_light_illuminance" + }, + { + "type": "heading", + "heading": "Power Usage", + "heading_style": "title" + }, + { + "type": "tile", + "entity": "sensor.freezer_power", + "vertical": false, + "features": [ + { + "type": "trend-graph" + } + ], + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.fridge_power", + "show_entity_picture": false, + "vertical": false, + "features": [ + { + "type": "trend-graph" + } + ], + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.washer_power", + "vertical": false, + "features": [ + { + "type": "trend-graph" + } + ], + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.coffee_maker_power", + "vertical": false, + "features": [ + { + "type": "trend-graph" + } + ], + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.computer_power", + "vertical": false, + "features": [ + { + "type": "trend-graph" + } + ], + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.microwave_power", + "vertical": false, + "features": [ + { + "type": "trend-graph" + } + ], + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.ninja_power", + "vertical": false, + "features": [ + { + "type": "trend-graph" + } + ], + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.general_power", + "vertical": false, + "features": [ + { + "type": "trend-graph" + } + ], + "features_position": "bottom" + }, + { + "type": "heading", + "heading": "Dishwasher", + "heading_style": "title" + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "gauge", + "entity": "counter.dishwasher_wash_cycles", + "name": "Dishwasher Filter Life", + "min": 0, + "max": 50, + "needle": true, + "severity": { + "green": 0, + "yellow": 10, + "red": 30 + } + }, + { + "type": "conditional", + "conditions": [ + { + "condition": "numeric_state", + "above": 30, + "entity": "counter.dishwasher_wash_cycles" + } + ], + "card": { + "type": "tile", + "entity": "counter.dishwasher_wash_cycles", + "name": "Reset Filter Counter", + "icon": "mdi:filter-check", + "color": "red", + "tap_action": { + "action": "perform-action", + "perform_action": "counter.set_value", + "data": { + "value": 0, + "entity_id": "counter.dishwasher_wash_cycles" + }, + "confirmation": { + "text": "Are you sure you want to reset the filter cycle counter?" + } + } + } + } + ], + "grid_options": { + "columns": 6, + "rows": "auto" + } + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "tile", + "entity": "binary_sensor.150633095332665_door", + "name": "Dishwasher Door", + "vertical": false, + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.150633095332665_progress", + "name": "Power", + "vertical": false, + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.150633095332665_mode", + "name": "Status", + "vertical": false, + "features_position": "bottom" + } + ], + "grid_options": { + "columns": 6, + "rows": "auto" + } + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading_style": "title", + "heading": "Network" + }, + { + "type": "entities", + "title": "Network Status", + "entities": [ + { + "entity": "sensor.gt_ax11000_connected_devices", + "name": "Connected Clients", + "icon": "mdi:devices" + }, + { + "entity": "sensor.gt_ax11000_wan_download_speed", + "name": "WAN Download Speed", + "icon": "mdi:download-network" + }, + { + "entity": "sensor.gt_ax11000_wan_upload_speed", + "name": "WAN Upload Speed", + "icon": "mdi:upload-network" + }, + { + "entity": "sensor.gt_ax11000_wired_download_speed", + "name": "Wired D/L" + }, + { + "entity": "sensor.gt_ax11000_wired_upload_speed", + "name": "Wired U/L" + } + ] + }, + { + "type": "statistics-graph", + "entities": [ + { + "entity": "sensor.gt_ax11000_wan_download_speed", + "name": "Download" + }, + { + "entity": "sensor.gt_ax11000_wan_upload_speed", + "name": "Upload" + } + ], + "days_to_show": 1, + "period": "5minute", + "chart_type": "line", + "stat_types": [ + "max" + ], + "expand_legend": false + }, + { + "type": "heading", + "icon": "mdi:fridge", + "heading": "Temperatures", + "heading_style": "title" + }, + { + "type": "history-graph", + "entities": [ + { + "entity": "sensor.blink_front_temperature_clean", + "name": "Front" + }, + { + "entity": "sensor.blink_backyard_shed_temperature_clean", + "name": "Shed" + }, + { + "entity": "sensor.blink_backyard_tree_temperature_clean", + "name": "Garage" + }, + { + "entity": "sensor.blink_backyard_temperature_clean", + "name": "Backyard" + }, + { + "entity": "sensor.pool_temperature" + } + ], + "hours_to_show": 24 + }, + { + "type": "statistics-graph", + "entities": [ + "sensor.pool_temperature" + ], + "days_to_show": 30, + "period": "day", + "chart_type": "line", + "stat_types": [ + "mean", + "min", + "max" + ], + "hide_legend": true + }, + { + "type": "entities", + "entities": [ + { + "entity": "sensor.nest_cooling_time" + }, + { + "entity": "sensor.nest_heating_time" + }, + { + "entity": "sensor.hvac_total_energy" + } + ] + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading_style": "title", + "heading": "Power" + }, + { + "type": "custom:power-flow-card-plus", + "title": "Heavy Appliance Energy Distribution", + "entities": { + "grid": { + "entity": "sensor.meter00_power", + "name": "Grid", + "icon": "mdi:transmission-tower" + }, + "home": { + "name": "Home", + "icon": "mdi:home" + }, + "individual": [ + { + "entity": "sensor.pool_pump_estimated_power_draw", + "name": "Pool Pump", + "icon": "mdi:pool", + "color": "#f1c40f", + "display_zero": true, + "calculate_flow_rate": true, + "decimals": 2 + }, + { + "entity": "sensor.basement_heater_power_draw", + "name": "Hilo Basement", + "icon": "mdi:radiator", + "color": "#e91e63", + "display_zero": true, + "calculate_flow_rate": true, + "decimals": 2 + }, + { + "entity": "sensor.hvac_real_time_power_draw", + "name": "HVAC System", + "icon": "mdi:air-conditioner", + "color": "#2980b9", + "display_zero": true, + "calculate_flow_rate": true, + "decimals": 2 + }, + { + "entity": "sensor.water_heater_power_draw", + "name": "Water Heater", + "icon": "mdi:water-boiler", + "color": "#16a085", + "calculate_flow_rate": true, + "display_zero": true, + "decimals": 2 + } + ] + }, + "clickable_entities": true, + "display_zero_lines": true, + "use_new_features": true, + "w_to_kw": false, + "decimals": 1, + "grid_options": { + "columns": 15, + "rows": "auto" + } + }, + { + "type": "custom:power-flow-card-plus", + "title": "Household Appliances", + "entities": { + "grid": { + "entity": "sensor.meter00_power", + "name": "Grid", + "icon": "mdi:transmission-tower" + }, + "home": { + "name": "Home", + "icon": "mdi:home" + }, + "individual": [ + { + "entity": "sensor.washer_power", + "icon": "mdi:washing-machine", + "calculate_flow_rate": true, + "decimals": 2, + "use_metadata": false, + "name": "Washer", + "display_zero": true + }, + { + "entity": "sensor.freezer_power", + "name": "Freezer", + "icon": "mdi:fridge-industrial", + "calculate_flow_rate": true, + "decimals": 2, + "display_zero": true + }, + { + "entity": "sensor.fridge_power", + "name": "Fridge", + "calculate_flow_rate": true, + "decimals": 2, + "icon": "mdi:fridge", + "display_zero": true + }, + { + "entity": "sensor.general_power", + "name": "General Plug", + "calculate_flow_rate": true, + "decimals": 2, + "icon": "mdi:power-plug", + "display_zero": true + } + ] + }, + "clickable_entities": true, + "display_zero_lines": true, + "use_new_flow_rate_model": true, + "w_to_kw": true, + "base_decimals": 1, + "kilo_decimals": 1, + "min_flow_rate": 0.75, + "max_flow_rate": 6, + "max_expected_power": 2000, + "min_expected_power": 0.01, + "kilo_threshold": 1000, + "transparency_zero_lines": 0, + "sort_individual_devices": false, + "grid_options": { + "columns": 15, + "rows": "auto" + } + }, + { + "type": "custom:power-flow-card-plus", + "title": "Other Appliances", + "entities": { + "grid": { + "entity": "sensor.meter00_power", + "name": "Grid", + "icon": "mdi:transmission-tower" + }, + "home": { + "name": "Home", + "icon": "mdi:home" + }, + "individual": [ + { + "entity": "sensor.computer_power", + "icon": "mdi:laptop", + "calculate_flow_rate": true, + "decimals": 2, + "use_metadata": false, + "name": "Computer", + "display_zero": true + }, + { + "entity": "sensor.microwave_power", + "icon": "mdi:microwave", + "calculate_flow_rate": true, + "decimals": 2, + "use_metadata": false, + "name": "Microwave", + "display_zero": true + }, + { + "entity": "sensor.ninja_power", + "icon": "mdi:microwave", + "calculate_flow_rate": true, + "decimals": 2, + "use_metadata": false, + "name": "Ninja Oven", + "display_zero": true + }, + { + "entity": "sensor.coffee_maker_power", + "name": "Coffee Maker", + "calculate_flow_rate": true, + "decimals": 2, + "icon": "mdi:coffee-maker", + "display_zero": true + } + ] + }, + "clickable_entities": true, + "display_zero_lines": true, + "use_new_flow_rate_model": true, + "w_to_kw": true, + "base_decimals": 1, + "kilo_decimals": 1, + "min_flow_rate": 0.75, + "max_flow_rate": 6, + "max_expected_power": 2000, + "min_expected_power": 0.01, + "kilo_threshold": 1000, + "transparency_zero_lines": 0, + "sort_individual_devices": false, + "grid_options": { + "columns": 15, + "rows": "auto" + } + } + ], + "column_span": 2 + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading": "Pool", + "heading_style": "title" + }, + { + "show_name": true, + "show_icon": true, + "type": "button", + "entity": "input_boolean.pool_pump_in_use" + } + ] + } + ], + "badges": [ + { + "type": "custom:mushroom-fan-card", + "entity": "sensor.nest_fan_mode_status", + "name": "Nest Fan", + "icon": "mdi:fan", + "icon_animation": true, + "fill_container": false, + "tap_action": { + "action": "perform-action", + "perform_action": "script.toggle_nest_fan" + }, + "primary_info": "none", + "secondary_info": "none" + }, + { + "type": "custom:mushroom-template-card", + "entity": "weather.forecast_home", + "primary": "{{ states('weather.forecast_home') | capitalize }}", + "secondary": "{{ state_attr('weather.forecast_home', 'temperature') }}°C", + "icon": "{% set condition = states('weather.forecast_home') %} {% set mapper = {\n 'partlycloudy': 'partly-cloudy',\n 'clear-night': 'night',\n 'exceptional': 'alert',\n 'windy-variant': 'windy'\n} %} {% set suffix = mapper.get(condition, condition) %} mdi:weather-{{ suffix }}", + "icon_color": "orange" + }, + { + "type": "custom:mushroom-template-card", + "primary": "⬇️ {{ states('sensor.gt_ax11000_wan_download_speed') | float(0) | round(1) }} Mbps", + "secondary": "⬆️ {{ states('sensor.gt_ax11000_wan_upload_speed') | float(0) | round(1) }} Mbps", + "color": "blue" + }, + { + "type": "entity", + "show_name": true, + "show_state": true, + "show_icon": true, + "entity": "sensor.pool_temperature", + "name": "Pool", + "icon": "mdi:pool" + }, + { + "type": "entity", + "show_name": true, + "show_state": true, + "show_icon": true, + "entity": "sensor.google_travel_time_work", + "name": "Work", + "icon": "mdi:car" + }, + { + "type": "entity", + "show_name": true, + "show_state": true, + "show_icon": true, + "entity": "sensor.google_travel_time_parents", + "name": "Parents", + "icon": "mdi:car" + }, + { + "type": "entity", + "show_name": true, + "show_state": true, + "show_icon": true, + "entity": "binary_sensor.garage_door_contact", + "icon": "mdi:garage", + "name": "Garage" + }, + { + "type": "entity", + "show_name": true, + "show_state": true, + "show_icon": true, + "entity": "input_boolean.in_a_meeting", + "name": "Meeting" + }, + { + "type": "entity", + "show_name": true, + "show_state": true, + "show_icon": true, + "entity": "light.night_light" + } + ], + "max_columns": 4, + "title": "Main", + "cards": [] + }, + { + "type": "sections", + "max_columns": 4, + "title": "Calendar Overview", + "path": "calendar-popup", + "subview": true, + "sections": [ + { + "type": "grid", + "cards": [ + { + "initial_view": "listWeek", + "type": "calendar", + "entities": [ + "calendar.family_calendar", + "calendar.franco_pellicciotti_gmail_com", + "calendar.fs_release_schedule" + ] + } + ] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/.storage/lovelace.dashboard_music b/.storage/lovelace.dashboard_music new file mode 100644 index 0000000..d7feab4 --- /dev/null +++ b/.storage/lovelace.dashboard_music @@ -0,0 +1,126 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace.dashboard_music", + "data": { + "config": { + "views": [ + { + "type": "sections", + "sections": [ + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading": "Amazon Devices", + "heading_style": "title" + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "entities", + "title": "Alexa Music Router", + "show_header_toggle": false, + "entities": [ + { + "entity": "input_select.alexa_playlist", + "name": "Select Playlist" + }, + { + "entity": "input_select.alexa_target_device", + "name": "Target Speaker" + }, + { + "entity": "input_number.alexa_master_volume", + "name": "Speaker Volume" + } + ] + }, + { + "type": "grid", + "columns": 2, + "square": false, + "cards": [ + { + "type": "button", + "name": "Play Music", + "icon": "mdi:play", + "tap_action": { + "action": "call-service", + "service": "script.dynamic_alexa_spotify_player" + } + }, + { + "type": "button", + "name": "Stop Music", + "icon": "mdi:stop", + "tap_action": { + "action": "call-service", + "service": "script.stop_alexa_music" + } + } + ] + } + ] + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading": "Office Speaker", + "heading_style": "title" + }, + { + "type": "custom:mass-player-card", + "entities": [ + "media_player.office_speaker_2", + "media_player.francos_macbook_pro" + ], + "panel": true, + "sync_player_across_dashboard": true, + "media_browser": { + "recommendations": {} + } + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "custom:my-music-library-card", + "entity": "media_player.office_speaker_2", + "grid_options": { + "columns": 12, + "rows": "auto" + } + } + ], + "column_span": 1 + }, + { + "type": "grid", + "cards": [ + { + "type": "iframe", + "url": "/d5369777_music_assistant", + "aspect_ratio": "75%", + "grid_options": { + "columns": "full", + "rows": 8 + } + } + ], + "column_span": 2 + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/.storage/lovelace.dashboard_temp b/.storage/lovelace.dashboard_temp new file mode 100644 index 0000000..9392fd9 --- /dev/null +++ b/.storage/lovelace.dashboard_temp @@ -0,0 +1,538 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace.dashboard_temp", + "data": { + "config": { + "views": [ + { + "type": "sections", + "sections": [ + { + "type": "grid", + "cards": [ + { + "type": "custom:uptime-card", + "entity": "sensor.nas_status", + "name": "NAS Availability", + "icon": "mdi:nas", + "ok": [ + "up", + "Up", + "online" + ], + "ko": [ + "down", + "Down", + "offline" + ], + "alias": { + "ok": "Up", + "ko": "Down" + }, + "show_state": true, + "bar": { + "amount": 30 + }, + "title_adaptive_color": true, + "status_adaptive_color": false, + "icon_adaptive_color": true + }, + { + "type": "custom:uptime-card", + "entity": "sensor.homeassistant_status", + "name": "NAS Availability", + "icon": "mdi:nas", + "ok": [ + "up", + "Up", + "online" + ], + "ko": [ + "down", + "Down", + "offline" + ], + "alias": { + "ok": "Up", + "ko": "Down" + }, + "show_state": true, + "bar": { + "amount": 30 + }, + "title_adaptive_color": true, + "status_adaptive_color": false, + "icon_adaptive_color": true + }, + { + "type": "custom:uptime-card", + "entity": "sensor.ollama_status", + "name": "NAS Availability", + "icon": "mdi:nas", + "ok": [ + "up", + "Up", + "online" + ], + "ko": [ + "down", + "Down", + "offline" + ], + "alias": { + "ok": "Up", + "ko": "Down" + }, + "show_state": true, + "bar": { + "amount": 30 + }, + "title_adaptive_color": true, + "status_adaptive_color": false, + "icon_adaptive_color": true + }, + { + "type": "custom:uptime-card", + "entity": "sensor.next_cloud_status", + "name": "NAS Availability", + "icon": "mdi:nas", + "ok": [ + "up", + "Up", + "online" + ], + "ko": [ + "down", + "Down", + "offline" + ], + "alias": { + "ok": "Up", + "ko": "Down" + }, + "show_state": true, + "bar": { + "amount": 30 + }, + "title_adaptive_color": true, + "status_adaptive_color": false, + "icon_adaptive_color": true + }, + { + "type": "sensor", + "entity": "sensor.nas_response_time", + "name": "NAS Latency", + "graph": "line", + "hours_to_show": 24 + }, + { + "type": "sensor", + "entity": "sensor.homeassistant_response_time", + "name": "HA Latency", + "graph": "line", + "hours_to_show": 24 + }, + { + "graph": "line", + "type": "sensor", + "entity": "sensor.ollama_response_time", + "hours_to_show": 24, + "detail": 1 + }, + { + "graph": "line", + "type": "sensor", + "entity": "sensor.next_cloud_response_time", + "hours_to_show": 24, + "detail": 1 + }, + { + "graph": "line", + "type": "sensor", + "entity": "sensor.homeassistant_certificate_expiry", + "hours_to_show": 24, + "detail": 1 + }, + { + "graph": "line", + "type": "sensor", + "entity": "sensor.next_cloud_certificate_expiry", + "hours_to_show": 24, + "detail": 1 + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading": "HVAC Stats", + "heading_style": "title" + }, + { + "type": "grid", + "square": false, + "columns": 1, + "cards": [ + { + "type": "statistics-graph", + "title": "Daily Breakdown (Last 7 Days)", + "chart_type": "bar", + "period": "day", + "days_to_show": 7, + "stat_types": [ + "change" + ], + "entities": [ + "sensor.nest_cooling_time_stats", + "sensor.nest_heating_time_stats" + ] + }, + { + "type": "statistics-graph", + "title": "Monthly Trend (Last 6 Months)", + "chart_type": "line", + "period": "month", + "days_to_show": 180, + "stat_types": [ + "max", + "change" + ], + "entities": [ + "sensor.nest_cooling_monthly", + "sensor.nest_heating_monthly" + ] + }, + { + "type": "statistics-graph", + "title": "Yearly Comparison", + "chart_type": "bar", + "period": "month", + "days_to_show": 365, + "stat_types": [ + "max", + "change" + ], + "entities": [ + "sensor.nest_cooling_yearly", + "sensor.nest_heating_yearly" + ] + } + ] + }, + { + "type": "statistics-graph", + "chart_type": "bar", + "period": "day", + "days_to_show": 7, + "stat_types": [ + "change" + ], + "entities": [ + { + "entity": "sensor.nest_cooling_weekly", + "name": "Cooling" + }, + { + "entity": "sensor.nest_heating_weekly", + "name": "Heating" + } + ], + "title": "Weekly HVAC Usage Breakdown" + }, + { + "type": "heading", + "heading": "Dishwasher" + }, + { + "type": "tile", + "entity": "binary_sensor.150633095332665_door", + "icon": "mdi:door-open", + "vertical": false, + "features_position": "bottom" + }, + { + "type": "tile", + "entity": "sensor.150633095332665_status" + }, + { + "type": "tile", + "entity": "sensor.150633095332665_progress" + }, + { + "type": "tile", + "entity": "binary_sensor.150633095332665_rinse_aid" + }, + { + "type": "tile", + "entity": "sensor.150633095332665_temperature" + }, + { + "type": "tile", + "entity": "sensor.150633095332665_mode" + }, + { + "type": "tile", + "entity": "sensor.150633095332665_bright" + }, + { + "type": "tile", + "entity": "sensor.150633095332665_error_code" + }, + { + "type": "tile", + "entity": "sensor.150633095332665_softwater" + }, + { + "type": "tile", + "entity": "sensor.150633095332665_time_remaining" + }, + { + "type": "tile", + "entity": "binary_sensor.150633095332665_salt" + }, + { + "type": "tile", + "entity": "counter.dishwasher_wash_cycles" + }, + { + "type": "tile", + "entity": "counter.dishwasher_total_wash_cycles" + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading": "New section" + }, + { + "type": "custom:album-slideshow-card", + "entity": "camera.album_slideshow_google_screensaver" + }, + { + "type": "entities", + "title": "Network Status", + "entities": [ + { + "entity": "sensor.gt_ax11000_connected_devices", + "name": "Connected Clients", + "icon": "mdi:devices" + }, + { + "entity": "sensor.gt_ax11000_wan_download_speed", + "name": "Download Speed", + "icon": "mdi:download-network" + }, + { + "entity": "sensor.gt_ax11000_wan_upload_speed", + "name": "Upload Speed", + "icon": "mdi:upload-network" + }, + { + "entity": "sensor.gt_ax11000_wired_download_speed", + "name": "Wired D/L" + }, + { + "entity": "sensor.gt_ax11000_wired_upload_speed", + "name": "Wired U/L" + } + ] + }, + { + "type": "custom:mini-graph-card", + "title": "Network Traffic (24h)", + "entities": [ + { + "entity": "sensor.gt_ax11000_wan_download_speed", + "name": "Download", + "color": "#2196F3" + }, + { + "entity": "sensor.gt_ax11000_wan_upload_speed", + "name": "Upload", + "color": "#4CAF50" + } + ], + "hours_to_show": 6, + "points_per_hour": 4, + "line_width": 4, + "fill": "fade", + "show": { + "graph": "line", + "fill": true, + "state": false, + "name": false, + "extrema": false, + "average": false + } + }, + { + "type": "picture-glance", + "title": "Backyard Shed", + "camera_image": "camera.backyard_shed", + "camera_view": "live", + "entities": [], + "tap_action": { + "action": "call-service", + "service": "camera.turn_on", + "target": { + "entity_id": "camera.backyard_shed" + } + } + }, + { + "type": "statistics-graph", + "entities": [ + "sensor.gt_ax11000_wan_download_speed", + "sensor.gt_ax11000_wan_upload_speed" + ], + "days_to_show": 1, + "period": "hour", + "chart_type": "line", + "stat_types": [ + "mean" + ] + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading": "New section" + }, + { + "type": "custom:treemap-card", + "entities": [ + "sensor.*power" + ], + "exclude": [ + "*meter00*" + ] + }, + { + "type": "custom:auto-entities", + "card": { + "type": "grid", + "columns": 2, + "square": false + }, + "filter": { + "include": [ + { + "domain": "light", + "options": { + "type": "custom:mushroom-light-card", + "use_light_color": true, + "show_brightness_control": true, + "show_color_temp_control": true, + "collapsible_controls": true, + "tap_action": { + "action": "toggle" + } + } + } + ] + } + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading": "New section" + }, + { + "type": "custom:simple-thermostat", + "entity": "climate.living_room_living_room" + }, + { + "type": "custom:simple-thermostat", + "entity": "climate.vt_basement" + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading": "Blink Backyard Tree" + }, + { + "type": "tile", + "entity": "camera.blink_backyard_tree", + "show_entity_picture": true + }, + { + "type": "tile", + "entity": "binary_sensor.blink_backyard_tree_motion" + }, + { + "type": "tile", + "entity": "sensor.blink_backyard_tree_temperature" + } + ] + } + ], + "max_columns": 4, + "title": "Main", + "cards": [] + }, + { + "type": "panel", + "path": "", + "cards": [ + { + "type": "custom:climate-scheduler-card" + } + ], + "title": "Climate" + }, + { + "type": "sections", + "max_columns": 4, + "title": "TaskMate", + "path": "test", + "sections": [ + { + "type": "grid", + "cards": [ + { + "type": "custom:taskmate-overview-card", + "entity": "sensor.taskmate_overview", + "title": "TaskMate", + "child": "Seraphine Skyler" + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "heading", + "heading": "Overview", + "heading_style": "title" + }, + { + "type": "custom:taskmate-calendar-card", + "entity": "sensor.taskmate_overview", + "title": "Task Calendar" + } + ] + } + ], + "cards": [] + } + ] + } + } +} \ No newline at end of file diff --git a/.storage/lovelace.in_meeting b/.storage/lovelace.in_meeting new file mode 100644 index 0000000..7127a5d --- /dev/null +++ b/.storage/lovelace.in_meeting @@ -0,0 +1,145 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace.in_meeting", + "data": { + "config": { + "wallpanel": { + "enabled": true, + "hide_toolbar": false, + "hide_sidebar": true, + "fullscreen": false, + "idle_time": 0 + }, + "views": [ + { + "title": "Main", + "path": "main", + "type": "panel", + "cards": [ + { + "type": "grid", + "columns": 2, + "square": false, + "cards": [ + { + "type": "vertical-stack", + "cards": [ + { + "show_name": true, + "show_icon": true, + "type": "button", + "entity": "input_boolean.in_a_meeting", + "name": "OFFICE MEETING STATUS", + "show_state": true, + "icon": "mdi:door-open", + "icon_height": "200px" + }, + { + "type": "conditional", + "conditions": [ + { + "condition": "state", + "entity": "input_boolean.in_a_meeting", + "state": "off" + } + ], + "card": { + "show_name": true, + "show_icon": false, + "type": "button", + "entity": "input_boolean.in_a_meeting", + "name": "🟢 AVAILABLE - COME IN", + "show_state": false, + "icon_height": "100px" + } + }, + { + "type": "conditional", + "conditions": [ + { + "condition": "state", + "entity": "input_boolean.in_a_meeting", + "state": "on" + } + ], + "card": { + "type": "button", + "entity": "input_boolean.in_a_meeting", + "name": "🔴 DO NOT ENTER - IN A MEETING", + "show_state": false, + "show_icon": false + } + }, + { + "type": "entities", + "entities": [ + { + "entity": "input_boolean.in_a_meeting", + "name": "Manual Desk Toggle", + "icon": "mdi:power" + } + ] + } + ] + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "markdown", + "title": "Desk Companion", + "content": "### System Status This dashboard displays your live office configuration. Outside of working hours, the system will seamlessly shift into motion-activated night light mode.\n" + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "markdown", + "content": "### Live Hardware Feed (Outside Door)" + }, + { + "type": "tile", + "entity": "light.night_light", + "name": "Door Light Color State", + "state_content": "state", + "state_color": true + }, + { + "type": "entities", + "show_header_toggle": false, + "state_color": true, + "entities": [ + { + "entity": "binary_sensor.night_light_occupancy", + "name": "Night Light Motion Sensor", + "icon": "mdi:motion-sensor" + } + ] + }, + { + "type": "custom:mushroom-light-card", + "entity": "light.bedroom_lights", + "use_light_color": true, + "tap_action": { + "action": "none" + }, + "hold_action": { + "action": "none" + }, + "double_tap_action": { + "action": "none" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/.storage/lovelace.kobo_dashboard b/.storage/lovelace.kobo_dashboard new file mode 100644 index 0000000..f29458d --- /dev/null +++ b/.storage/lovelace.kobo_dashboard @@ -0,0 +1,424 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace.kobo_dashboard", + "data": { + "config": { + "wallpanel": { + "enabled": true, + "hide_toolbar": false, + "hide_sidebar": true, + "fullscreen": false, + "idle_time": 0 + }, + "views": [ + { + "type": "sections", + "sections": [ + { + "type": "grid", + "cards": [ + { + "type": "weather-forecast", + "entity": "weather.forecast_home", + "forecast_type": "hourly" + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "grid", + "square": false, + "columns": 2, + "cards": [ + { + "type": "entity", + "entity": "sensor.living_room_living_room_temperature", + "name": "Living Room Temp" + }, + { + "type": "entity", + "entity": "sensor.basement_temperature", + "name": "Basement Temp" + } + ] + } + ] + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "grid", + "square": false, + "columns": 2, + "cards": [ + { + "type": "entity", + "entity": "sensor.pool_temperature", + "name": "Pool Temperature" + }, + { + "type": "entity", + "entity": "sensor.seatemperatures_sandbanks_today", + "name": "Sandbanks" + } + ] + } + ] + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "grid", + "square": false, + "columns": 2, + "cards": [ + { + "type": "entity", + "entity": "input_boolean.in_a_meeting", + "name": "In Meeting", + "icon": "mdi:office-building", + "state_color": true + }, + { + "type": "custom:button-card", + "entity": "light.night_light", + "name": "Night Light", + "show_icon": false, + "show_state": false, + "show_label": true, + "label": "[[[\n if (states['light.night_light'].state === 'off') return 'Off';\n const rgb = states['light.night_light'].attributes.rgb_color;\n if (!rgb) return states['light.night_light'].state;\n if (rgb[0] > rgb[1] && rgb[0] > rgb[2]) return 'Red';\n if (rgb[1] > rgb[0] && rgb[1] > rgb[2]) return 'Green';\n return 'Other Color';\n]]]\n", + "styles": { + "label": [ + { + "font-weight": "bold" + }, + { + "color": "[[[\n if (states['light.night_light'].state === 'off') return '#888888';\n const rgb = states['light.night_light'].attributes.rgb_color;\n if (!rgb) return 'var(--primary-text-color)';\n if (rgb[0] > rgb[1] && rgb[0] > rgb[2]) return '#ff4d4d';\n if (rgb[1] > rgb[0] && rgb[1] > rgb[2]) return '#4dff4d';\n return 'var(--primary-text-color)';\n]]]\n" + } + ] + } + } + ] + } + ] + } + ] + }, + { + "type": "grid", + "cards": [ + { + "entities": [ + { + "entity": "calendar.family_calendar", + "show_time": true, + "show_description": true, + "split_multiday_events": false + } + ], + "weather": { + "position": "date", + "date": { + "show_conditions": true, + "show_high_temp": true, + "show_low_temp": false, + "show_uv_index": false, + "uv_index_threshold": 0, + "icon_size": "14px", + "font_size": "12px", + "color": "var(--primary-text-color)" + }, + "event": { + "show_conditions": true, + "show_temp": true, + "show_uv_index": false, + "uv_index_threshold": 0, + "icon_size": "14px", + "font_size": "12px", + "color": "var(--primary-text-color)" + }, + "entity": "weather.forecast_home" + }, + "type": "custom:calendar-card-pro" + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "gauge", + "entity": "counter.dishwasher_wash_cycles", + "name": "Dishwasher Filter Life", + "min": 0, + "max": 50, + "needle": true, + "severity": { + "green": 0, + "yellow": 10, + "red": 30 + } + } + ], + "grid_options": { + "columns": 6, + "rows": "auto" + } + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "custom:button-card", + "entity": "binary_sensor.150633095332665_door", + "name": "Dishwasher Door", + "show_state": true, + "show_icon": false, + "custom_fields": { + "custom_icon": "[[[\n let iconName = entity.state === 'on' ? 'mdi:door-open' : 'mdi:door-closed';\n let iconColor = entity.state === 'on' ? 'var(--paper-item-icon-active-color, #fdd835)' : 'var(--paper-item-icon-color, #44739e)';\n return ``;\n]]]\n" + }, + "styles": { + "grid": [ + { + "grid-template-areas": "\"custom_icon s\" \"n n\"" + }, + { + "grid-template-columns": "auto 1fr" + }, + { + "grid-template-rows": "auto auto" + } + ], + "card": [ + { + "padding": "12px" + }, + { + "border-radius": "12px" + }, + { + "height": "82px" + } + ], + "custom_fields": { + "custom_icon": [ + { + "justify-self": "start" + }, + { + "align-self": "center" + }, + { + "margin-right": "8px" + }, + { + "display": "flex" + } + ] + }, + "state": [ + { + "font-size": "28px" + }, + { + "font-weight": "bold" + }, + { + "justify-self": "start" + }, + { + "align-self": "center" + }, + { + "text-transform": "capitalize" + } + ], + "name": [ + { + "justify-self": "start" + }, + { + "font-size": "14px" + }, + { + "color": "var(--secondary-text-color)" + }, + { + "margin-top": "4px" + } + ] + } + }, + { + "type": "custom:button-card", + "entity": "sensor.150633095332665_temperature", + "name": "Dishwasher Temp", + "show_state": true, + "show_icon": false, + "custom_fields": { + "custom_icon": "[[[\n let temp = parseFloat(entity.state);\n let iconColor = temp > 40 ? '#ef5350' : 'var(--paper-item-icon-color, #44739e)';\n return ``;\n]]]\n" + }, + "styles": { + "grid": [ + { + "grid-template-areas": "\"custom_icon s\" \"n n\"" + }, + { + "grid-template-columns": "auto 1fr" + }, + { + "grid-template-rows": "auto auto" + } + ], + "card": [ + { + "padding": "12px" + }, + { + "border-radius": "12px" + }, + { + "height": "82px" + } + ], + "custom_fields": { + "custom_icon": [ + { + "justify-self": "start" + }, + { + "align-self": "center" + }, + { + "margin-right": "8px" + }, + { + "display": "flex" + } + ] + }, + "state": [ + { + "font-size": "28px" + }, + { + "font-weight": "bold" + }, + { + "justify-self": "start" + }, + { + "align-self": "center" + }, + { + "text-transform": "capitalize" + } + ], + "name": [ + { + "justify-self": "start" + }, + { + "font-size": "14px" + }, + { + "color": "var(--secondary-text-color)" + }, + { + "margin-top": "4px" + } + ] + } + } + ], + "grid_options": { + "columns": 6, + "rows": "auto" + } + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "conditional", + "conditions": [ + { + "condition": "state", + "entity": "input_boolean.in_a_meeting", + "state": "off" + } + ], + "card": { + "show_name": true, + "show_icon": false, + "type": "button", + "entity": "input_boolean.in_a_meeting", + "name": "✅ AVAILABLE - COME IN", + "show_state": false + } + }, + { + "type": "conditional", + "conditions": [ + { + "condition": "state", + "entity": "input_boolean.in_a_meeting", + "state": "on" + } + ], + "card": { + "show_name": true, + "show_icon": false, + "type": "button", + "entity": "input_boolean.in_a_meeting", + "name": "✋ DO NOT ENTER - IN A MEETING", + "show_state": false + } + } + ] + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "grid", + "square": false, + "columns": 2, + "cards": [ + { + "type": "entity", + "entity": "sensor.google_travel_time_work", + "name": "Work" + }, + { + "type": "entity", + "entity": "sensor.google_travel_time_parents", + "name": "Parents" + } + ] + } + ] + } + ] + }, + { + "type": "grid", + "cards": [ + { + "show_state": false, + "show_name": false, + "camera_view": "auto", + "fit_mode": "cover", + "type": "picture-entity", + "entity": "camera.album_slideshow_google_screensaver", + "camera_image": "camera.album_slideshow_google_screensaver" + } + ] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/.storage/lovelace.map b/.storage/lovelace.map new file mode 100644 index 0000000..693208b --- /dev/null +++ b/.storage/lovelace.map @@ -0,0 +1,12 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace.map", + "data": { + "config": { + "strategy": { + "type": "map" + } + } + } +} \ No newline at end of file diff --git a/.storage/lovelace.morning_hub b/.storage/lovelace.morning_hub new file mode 100644 index 0000000..766a749 --- /dev/null +++ b/.storage/lovelace.morning_hub @@ -0,0 +1,577 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace.morning_hub", + "data": { + "config": { + "title": "Morning Hub", + "views": [ + { + "title": "Morning", + "path": "morning", + "icon": "mdi:weather-sunset-up", + "type": "sections", + "max_columns": 3, + "sections": [ + { + "cards": [ + { + "type": "heading", + "heading": "Overview" + }, + { + "type": "custom:clock-weather-card", + "entity": "weather.pirateweather" + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "custom:button-card", + "name": "Commute", + "icon": "mdi:car", + "styles": { + "card": [ + { + "border-radius": "16px 16px 0px 0px" + }, + { + "padding": "12px 16px" + }, + { + "border-bottom": "1px solid #f1f2f6" + } + ], + "grid": [ + { + "grid-template-areas": "\"i n\"" + }, + { + "grid-template-columns": "30px 1fr" + } + ], + "name": [ + { + "font-weight": "bold" + }, + { + "font-size": "16px" + } + ], + "icon": [ + { + "color": "#1e90ff" + } + ] + } + }, + { + "type": "custom:button-card", + "entity": "sensor.google_travel_time_work", + "show_name": true, + "show_label": true, + "name": "Google Maps", + "label": "via Autoroute 13 S", + "styles": { + "card": [ + { + "border-radius": "0px" + }, + { + "padding": "12px 16px" + }, + { + "border-bottom": "1px solid #f1f2f6" + } + ], + "grid": [ + { + "grid-template-areas": "\"i n stat b\"" + }, + { + "grid-template-columns": "40px 1fr auto auto" + } + ], + "icon": [ + { + "color": "#4285F4" + } + ], + "name": [ + { + "font-weight": "bold" + }, + { + "justify-self": "start" + } + ], + "label": [ + { + "font-size": "12px" + }, + { + "color": "gray" + }, + { + "justify-self": "start" + } + ] + }, + "custom_fields": { + "stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]", + "b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `${txt}`;\n]]]\n" + } + }, + { + "type": "custom:button-card", + "entity": "sensor.waze_to_work_travel_time", + "show_name": true, + "show_label": true, + "name": "Waze", + "label": "via Autoroute 13 S", + "styles": { + "card": [ + { + "border-radius": "0px" + }, + { + "padding": "12px 16px" + }, + { + "border-bottom": "1px solid #f1f2f6" + } + ], + "grid": [ + { + "grid-template-areas": "\"i n stat b\"" + }, + { + "grid-template-columns": "40px 1fr auto auto" + } + ], + "icon": [ + { + "color": "#33ccff" + } + ], + "name": [ + { + "font-weight": "bold" + }, + { + "justify-self": "start" + } + ], + "label": [ + { + "font-size": "12px" + }, + { + "color": "gray" + }, + { + "justify-self": "start" + } + ] + }, + "custom_fields": { + "stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]", + "b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `${txt}`;\n]]]\n" + } + }, + { + "type": "custom:button-card", + "entity": "sensor.google_travel_time_work", + "show_name": true, + "show_label": true, + "name": "[[[ \n var mins = Math.round(parseFloat(entity.state));\n var status = mins < 35 ? 'Traffic is flowing normally' : (mins < 45 ? 'Minor traffic delays' : 'Heavy traffic alerts');\n return `${status} • ${mins} min`;\n]]]\n", + "label": "Typical commute: ~25 min", + "icon": "[[[ return parseFloat(entity.state) < 35 ? 'mdi:check-circle' : (parseFloat(entity.state) < 45 ? 'mdi:alert-circle' : 'mdi:alert-octagon'); ]]]", + "styles": { + "card": [ + { + "border-radius": "0px 0px 16px 16px" + }, + { + "padding": "12px 16px" + } + ], + "grid": [ + { + "grid-template-areas": "\"i n\"" + }, + { + "grid-template-columns": "40px 1fr" + } + ], + "icon": [ + { + "color": "[[[ return parseFloat(entity.state) < 35 ? '#2ecc71' : (parseFloat(entity.state) < 45 ? '#e67e22' : '#e74c3c'); ]]]" + } + ], + "name": [ + { + "font-weight": "500" + }, + { + "justify-self": "start" + } + ], + "label": [ + { + "font-size": "12px" + }, + { + "color": "gray" + }, + { + "justify-self": "start" + } + ] + } + } + ] + }, + { + "type": "heading", + "heading": "Control" + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "custom:button-card", + "name": "Quick Actions", + "icon": "mdi:rocket-launch", + "styles": { + "card": [ + { + "background": "none" + }, + { + "box-shadow": "none" + }, + { + "padding": "4px 0px" + } + ], + "grid": [ + { + "grid-template-areas": "\"i n\"" + }, + { + "grid-template-columns": "30px 1fr" + } + ], + "name": [ + { + "font-weight": "bold" + }, + { + "font-size": "16px" + } + ], + "icon": [ + { + "color": "#9b59b6" + } + ] + } + }, + { + "type": "grid", + "square": false, + "columns": 1, + "cards": [ + { + "type": "custom:button-card", + "name": "Open Waze", + "icon": "mdi:waze", + "tap_action": { + "action": "url", + "url_path": "https://waze.com/ul" + }, + "styles": { + "card": [ + { + "background-color": "#3498db" + }, + { + "border-radius": "16px" + }, + { + "color": "white" + }, + { + "padding": "18px 16px" + } + ], + "grid": [ + { + "grid-template-areas": "\"i n\"" + }, + { + "grid-template-columns": "40px 1fr" + }, + { + "justify-items": "start" + } + ], + "name": [ + { + "font-weight": "bold" + }, + { + "font-size": "16px" + } + ], + "icon": [ + { + "width": "26px" + } + ] + } + }, + { + "type": "custom:button-card", + "name": "Open Google Maps", + "icon": "mdi:google-maps", + "tap_action": { + "action": "url", + "url_path": "https://maps.google.com" + }, + "styles": { + "card": [ + { + "background-color": "#2ecc71" + }, + { + "border-radius": "16px" + }, + { + "color": "white" + }, + { + "padding": "18px 16px" + } + ], + "grid": [ + { + "grid-template-areas": "\"i n\"" + }, + { + "grid-template-columns": "40px 1fr" + }, + { + "justify-items": "start" + } + ], + "name": [ + { + "font-weight": "bold" + }, + { + "font-size": "16px" + } + ], + "icon": [ + { + "width": "26px" + } + ] + } + } + ] + } + ] + } + ] + }, + { + "cards": [ + { + "type": "heading", + "heading": "Schedule" + }, + { + "title": "Today's Calendar", + "entities": [ + "calendar.family_calendar" + ], + "default_view": "agenda", + "first_day_of_week": 0, + "week_days": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "week_start_hour": 0, + "week_end_hour": 23, + "lock_schedule_hours": false, + "hide_the_past": false, + "past_event_mode": "none", + "disable_swipe_controls": false, + "show_all_events_month": false, + "show_all_details_month": false, + "hide_empty_days": true, + "agenda_compact_events": false, + "shorten_event_times": false, + "time_zone": "", + "display_full_weekday_names": false, + "compact_width": false, + "show_current_time_bar": false, + "show_event_location": false, + "use_short_location": false, + "event_location_font_size": 9, + "background_opacity": 0, + "header_background_opacity": 0, + "event_calendar_friendly_name": false, + "event_title_prefix": "none", + "combine_style": "bars", + "combine_background": "primary", + "event_color_mode": "classic", + "event_neutral_background": "#F8F3E9", + "event_tint_opacity": 80, + "event_color_bar_width": 18, + "day_badges": [], + "day_badge_layout_week": "inline", + "hide_calendars": true, + "hide_header": true, + "hide_year": false, + "hide_controls": false, + "hide_navigation_buttons": false, + "hide_add_event_button": false, + "hide_view_selector": false, + "hide_dark_mode_toggle": false, + "show_dashboard_nav_button": false, + "header_dashboard_path": null, + "header_weather_sensor": "", + "calendar_person_entities": {}, + "default_hidden_calendars": [], + "color_scheme": "auto", + "enable_event_management": true, + "event_modal_size": "medium", + "type": "custom:daylight-calendar-card", + "rolling_days": 7, + "compact_height": true, + "card_mod": { + "style": "ha-card {\n border-radius: 18px !important;\n padding: 16px !important;\n background-color: transparent !important;\n box-shadow: none !important;\n border: none !important;\n}\n.daylight-header-title {\n font-weight: bold !important;\n font-size: 18px !important;\n color: var(--primary-text-color) !important;\n}\n.daylight-event-wrapper {\n background: #ffffff !important;\n border: 1px solid #e1e4e8 !important;\n border-radius: 14px !important;\n margin-bottom: 12px !important;\n padding: 14px 16px !important;\n box-shadow: 0 4px 12px rgba(0,0,0,0.02) !important;\n border-left: 5px solid var(--daylight-event-color, #2196f3) !important;\n}\n.daylight-event-title {\n font-weight: bold !important;\n font-size: 15px !important;\n color: #2c3e50 !important;\n}\n.daylight-event-time {\n font-size: 12px !important;\n color: #95a5a6 !important;\n margin-bottom: 2px !important;\n}\n" + }, + "hide_calendar_names": false, + "grid_options": { + "rows": 5, + "columns": 12 + } + }, + { + "type": "statistics-graph", + "entities": [ + "sensor.google_travel_time_work" + ], + "days_to_show": 1, + "period": "hour", + "chart_type": "line", + "stat_types": [ + "mean" + ] + }, + { + "type": "statistics-graph", + "entities": [ + "sensor.waze_to_work_travel_time_waze_to_parents" + ], + "days_to_show": 1, + "period": "hour", + "chart_type": "line", + "stat_types": [ + "mean" + ] + } + ] + }, + { + "type": "grid", + "cards": [ + { + "type": "custom:button-card", + "name": "[[[\n const hour = new Date().getHours();\n if (hour >= 5 && hour < 12) {\n return \"Good Morning, Franco!\";\n } else if (hour >= 12 && hour < 17) {\n return \"Good Afternoon, Franco!\";\n } else {\n return \"Good Evening, Franco!\";\n }\n]]]", + "label": "[[[ const lnow = new Date(); return lnow.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }); ]]]", + "show_label": true, + "styles": { + "card": [ + { + "background": "[[[\n const hour = new Date().getHours();\n \n if (hour >= 5 && hour < 11) {\n // Sunrise: Soft orange to warm pink dawn gradient\n return 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #feada6 100%)';\n } else if (hour >= 11 && hour < 16) {\n // Mid-day: Bright, vibrant clear sky blue gradient\n return 'linear-gradient(135deg, #2980b9 0%, #6dd5fa 50%, #ffffff 100%)';\n } else if (hour >= 16 && hour < 21) {\n // Sunset: Deep twilight purple to vibrant orange gradient\n return 'linear-gradient(135deg, #111827 0%, #701a75 40%, #f59e0b 100%)';\n } else {\n // Night: Deep cosmic midnight blue and space gray gradient\n return 'linear-gradient(135deg, #0f172a 0%, #1e293b 60%, #334155 100%)';\n }\n]]]" + }, + { + "background-size": "cover" + }, + { + "background-position": "center" + }, + { + "height": "110px" + }, + { + "border-radius": "18px" + }, + { + "padding": "16px" + } + ], + "name": [ + { + "justify-self": "start" + }, + { + "font-weight": "bold" + }, + { + "font-size": "22px" + }, + { + "color": "white" + }, + { + "text-shadow": "0px 2px 4px rgba(0,0,0,0.2)" + } + ], + "label": [ + { + "justify-self": "start" + }, + { + "font-size": "14px" + }, + { + "color": "white" + }, + { + "opacity": "0.9" + }, + { + "text-shadow": "0px 1px 3px rgba(0,0,0,0.2)" + } + ] + } + }, + { + "show_state": false, + "show_name": false, + "camera_view": "auto", + "fit_mode": "cover", + "type": "picture-entity", + "entity": "camera.album_slideshow_google_screensaver", + "camera_image": "camera.album_slideshow_google_screensaver" + } + ] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/.storage/lovelace.morning_ipad b/.storage/lovelace.morning_ipad new file mode 100644 index 0000000..de66b75 --- /dev/null +++ b/.storage/lovelace.morning_ipad @@ -0,0 +1,345 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace.morning_ipad", + "data": { + "config": { + "wallpanel": { + "enabled": true, + "hide_toolbar": false, + "hide_sidebar": true, + "fullscreen": false, + "idle_time": 0 + }, + "title": "Morning Hub", + "views": [ + { + "title": "Morning", + "path": "morning", + "icon": "mdi:weather-sunset-up", + "type": "sections", + "max_columns": 3, + "sections": [ + { + "cards": [ + { + "type": "heading", + "heading": "Overview" + }, + { + "type": "custom:clock-weather-card", + "entity": "weather.pirateweather" + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "custom:button-card", + "name": "Commute", + "icon": "mdi:car", + "styles": { + "card": [ + { + "border-radius": "16px 16px 0px 0px" + }, + { + "padding": "12px 16px" + }, + { + "border-bottom": "1px solid #f1f2f6" + } + ], + "grid": [ + { + "grid-template-areas": "\"i n\"" + }, + { + "grid-template-columns": "30px 1fr" + } + ], + "name": [ + { + "font-weight": "bold" + }, + { + "font-size": "16px" + } + ], + "icon": [ + { + "color": "#1e90ff" + } + ] + } + }, + { + "type": "custom:button-card", + "entity": "sensor.google_travel_time_work", + "show_name": true, + "show_label": true, + "name": "Google Maps", + "label": "via Autoroute 13 S", + "styles": { + "card": [ + { + "border-radius": "0px" + }, + { + "padding": "12px 16px" + }, + { + "border-bottom": "1px solid #f1f2f6" + } + ], + "grid": [ + { + "grid-template-areas": "\"i n stat b\"" + }, + { + "grid-template-columns": "40px 1fr auto auto" + } + ], + "icon": [ + { + "color": "#4285F4" + } + ], + "name": [ + { + "font-weight": "bold" + }, + { + "justify-self": "start" + } + ], + "label": [ + { + "font-size": "12px" + }, + { + "color": "gray" + }, + { + "justify-self": "start" + } + ] + }, + "custom_fields": { + "stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]", + "b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `${txt}`;\n]]]\n" + } + }, + { + "type": "custom:button-card", + "entity": "sensor.waze_to_work_travel_time", + "show_name": true, + "show_label": true, + "name": "Waze", + "label": "via Autoroute 13 S", + "styles": { + "card": [ + { + "border-radius": "0px" + }, + { + "padding": "12px 16px" + }, + { + "border-bottom": "1px solid #f1f2f6" + } + ], + "grid": [ + { + "grid-template-areas": "\"i n stat b\"" + }, + { + "grid-template-columns": "40px 1fr auto auto" + } + ], + "icon": [ + { + "color": "#33ccff" + } + ], + "name": [ + { + "font-weight": "bold" + }, + { + "justify-self": "start" + } + ], + "label": [ + { + "font-size": "12px" + }, + { + "color": "gray" + }, + { + "justify-self": "start" + } + ] + }, + "custom_fields": { + "stat": "[[[ return Math.round(parseFloat(entity.state)) + ' min'; ]]]", + "b": "[[[\n var val = parseFloat(entity.state);\n var txt = val < 35 ? 'Good' : (val < 45 ? 'Fair' : 'Heavy');\n var bg = val < 35 ? '#e6f4ea' : (val < 45 ? '#ffe0b2' : '#fce8e6');\n var clr = val < 35 ? '#137333' : (val < 45 ? '#b06000' : '#c5221f');\n return `${txt}`;\n]]]\n" + } + }, + { + "type": "custom:button-card", + "entity": "sensor.google_travel_time_work", + "show_name": true, + "show_label": true, + "name": "[[[ \n var mins = Math.round(parseFloat(entity.state));\n var status = mins < 35 ? 'Traffic normally' : (mins < 45 ? 'Minor traffic delays' : 'Heavy traffic alerts');\n return `${status} • ${mins} min`;\n]]]\n", + "label": "Typical commute: ~25 min", + "icon": "[[[ return parseFloat(entity.state) < 35 ? 'mdi:check-circle' : (parseFloat(entity.state) < 45 ? 'mdi:alert-circle' : 'mdi:alert-octagon'); ]]]", + "styles": { + "card": [ + { + "border-radius": "0px 0px 16px 16px" + }, + { + "padding": "12px 16px" + } + ], + "grid": [ + { + "grid-template-areas": "\"i n\"" + }, + { + "grid-template-columns": "40px 1fr" + } + ], + "icon": [ + { + "color": "[[[ return parseFloat(entity.state) < 35 ? '#2ecc71' : (parseFloat(entity.state) < 45 ? '#e67e22' : '#e74c3c'); ]]]" + } + ], + "name": [ + { + "font-weight": "500" + }, + { + "justify-self": "start" + } + ], + "label": [ + { + "font-size": "12px" + }, + { + "color": "gray" + }, + { + "justify-self": "start" + } + ] + } + } + ] + }, + { + "type": "heading", + "heading": "Control" + } + ] + }, + { + "cards": [ + { + "type": "heading", + "heading": "Schedule" + }, + { + "title": "Today's Calendar", + "entities": [ + "calendar.family_calendar" + ], + "default_view": "agenda", + "first_day_of_week": 0, + "week_days": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "week_start_hour": 0, + "week_end_hour": 23, + "lock_schedule_hours": false, + "hide_the_past": false, + "past_event_mode": "none", + "disable_swipe_controls": false, + "show_all_events_month": false, + "show_all_details_month": false, + "hide_empty_days": true, + "agenda_compact_events": false, + "shorten_event_times": false, + "time_zone": "", + "display_full_weekday_names": false, + "compact_width": false, + "show_current_time_bar": false, + "show_event_location": false, + "use_short_location": false, + "event_location_font_size": 9, + "background_opacity": 0, + "header_background_opacity": 0, + "event_calendar_friendly_name": false, + "event_title_prefix": "none", + "combine_style": "bars", + "combine_background": "primary", + "event_color_mode": "classic", + "event_neutral_background": "#F8F3E9", + "event_tint_opacity": 80, + "event_color_bar_width": 18, + "day_badges": [], + "day_badge_layout_week": "inline", + "hide_calendars": true, + "hide_header": true, + "hide_year": false, + "hide_controls": false, + "hide_navigation_buttons": false, + "hide_add_event_button": false, + "hide_view_selector": false, + "hide_dark_mode_toggle": false, + "show_dashboard_nav_button": false, + "header_dashboard_path": null, + "header_weather_sensor": "", + "calendar_person_entities": {}, + "default_hidden_calendars": [], + "color_scheme": "auto", + "enable_event_management": true, + "event_modal_size": "medium", + "type": "custom:daylight-calendar-card", + "rolling_days": 7, + "compact_height": true, + "card_mod": { + "style": "ha-card {\n border-radius: 18px !important;\n padding: 16px !important;\n background-color: transparent !important;\n box-shadow: none !important;\n border: none !important;\n}\n.daylight-header-title {\n font-weight: bold !important;\n font-size: 18px !important;\n color: var(--primary-text-color) !important;\n}\n.daylight-event-wrapper {\n background: #ffffff !important;\n border: 1px solid #e1e4e8 !important;\n border-radius: 14px !important;\n margin-bottom: 12px !important;\n padding: 14px 16px !important;\n box-shadow: 0 4px 12px rgba(0,0,0,0.02) !important;\n border-left: 5px solid var(--daylight-event-color, #2196f3) !important;\n}\n.daylight-event-title {\n font-weight: bold !important;\n font-size: 15px !important;\n color: #2c3e50 !important;\n}\n.daylight-event-time {\n font-size: 12px !important;\n color: #95a5a6 !important;\n margin-bottom: 2px !important;\n}\n" + }, + "hide_calendar_names": false + } + ] + }, + { + "type": "grid", + "cards": [ + { + "show_state": false, + "show_name": false, + "camera_view": "auto", + "fit_mode": "cover", + "type": "picture-entity", + "entity": "camera.album_slideshow_google_screensaver", + "camera_image": "camera.album_slideshow_google_screensaver" + } + ] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/.storage/lovelace.network_cellphone b/.storage/lovelace.network_cellphone new file mode 100644 index 0000000..caa51bc --- /dev/null +++ b/.storage/lovelace.network_cellphone @@ -0,0 +1,141 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace.network_cellphone", + "data": { + "config": { + "views": [ + { + "type": "sections", + "sections": [ + { + "type": "grid", + "cards": [ + { + "type": "heading", + "icon": "mdi:fridge", + "heading": "Network Status", + "heading_style": "title" + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "entities", + "show_header_toggle": false, + "state_color": true, + "entities": [ + { + "entity": "sensor.gt_ax11000_connected_devices", + "name": "Active Clients", + "icon": "mdi:laptop" + }, + { + "entity": "sensor.gt_ax11000_wan_download_speed", + "name": "Internet Download", + "icon": "mdi:arrow-down" + }, + { + "entity": "sensor.gt_ax11000_wan_upload_speed", + "name": "Internet Upload", + "icon": "mdi:arrow-up" + }, + { + "entity": "sensor.gt_ax11000_wired_download_speed", + "name": "Wired D/L", + "icon": "mdi:lan-connect" + }, + { + "entity": "sensor.gt_ax11000_wired_upload_speed", + "name": "Wired U/L", + "icon": "mdi:lan-pending" + } + ], + "card_mod": { + "style": "ha-card {\n background: #0d0e12 !important;\n border-left: 1px solid #1a1d26 !important;\n border-right: 1px solid #1a1d26 !important;\n border-top: none !important;\n border-bottom: none !important;\n border-radius: 0px !important;\n box-shadow: none !important;\n --primary-text-color: #f1f5f9;\n --secondary-text-color: #94a3b8;\n --paper-item-icon-color: #3b82f6;\n}\n.card-content {\n padding: 16px 20px 4px 20px !important;\n}\n" + } + } + ] + }, + { + "type": "custom:layout-card", + "layout_type": "grid", + "grid_options": { + "columns": "full", + "rows": 3 + }, + "layout": { + "grid-template-columns": "3fr 1fr", + "grid-gap": "12px" + }, + "cards": [ + { + "type": "custom:mini-graph-card", + "entities": [ + { + "entity": "sensor.gt_ax11000_wan_download_speed", + "name": "Download", + "color": null + }, + { + "entity": "sensor.gt_ax11000_wan_upload_speed", + "name": "Upload", + "color": null + } + ], + "hours_to_show": 6, + "points_per_hour": 6, + "line_width": 4, + "show": { + "name": false, + "icon": false, + "state": true, + "legend": true, + "labels": false + }, + "card_mod": { + "style": "ha-card {\n background: #0d0e12 !important;\n border: 1px solid #1a1d26 !important;\n border-radius: 12px !important;\n padding: 12px !important;\n height: 100% !important;\n box-sizing: border-box;\n}\n.header {\n padding: 0 !important;\n}\n.states {\n font-size: 14px !important;\n font-weight: 600;\n}\n.states--secondary {\n font-size: 12px !important;\n}\n" + } + }, + { + "type": "vertical-stack", + "cards": [ + { + "type": "sensor", + "entity": "sensor.gt_ax11000_temperature_cpu", + "name": "CPU Temp", + "graph": "none", + "card_mod": { + "style": "ha-card {\n background: #0d0e12 !important;\n border: 1px solid #1a1d26 !important;\n border-radius: 12px !important;\n padding: 8px 12px !important;\n box-sizing: border-box;\n margin-bottom: 4px !important;\n}\n.name {\n font-size: 12px !important;\n color: #808080 !important;\n}\n.value {\n font-size: 16px !important;\n font-weight: 600 !important;\n}\n" + } + } + ] + } + ] + }, + { + "type": "gauge", + "entity": "sensor.gt_ax11000_cpu", + "grid_options": { + "columns": 6, + "rows": "auto" + }, + "name": "CPU Usage" + }, + { + "type": "gauge", + "entity": "sensor.gt_ax11000_ram", + "grid_options": { + "columns": 6, + "rows": "auto" + }, + "name": "RAM Ussage" + } + ] + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/.storage/lovelace_dashboards b/.storage/lovelace_dashboards new file mode 100644 index 0000000..2ff34c6 --- /dev/null +++ b/.storage/lovelace_dashboards @@ -0,0 +1,90 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace_dashboards", + "data": { + "items": [ + { + "id": "map", + "icon": "mdi:map", + "title": "Map", + "url_path": "map", + "require_admin": false, + "show_in_sidebar": false, + "mode": "storage" + }, + { + "id": "dashboard_home", + "show_in_sidebar": true, + "title": "Home", + "require_admin": false, + "mode": "storage", + "url_path": "dashboard-home" + }, + { + "id": "alarm_section", + "show_in_sidebar": true, + "title": "Alarm Section", + "require_admin": false, + "mode": "storage", + "url_path": "alarm-section" + }, + { + "id": "dashboard_temp", + "show_in_sidebar": true, + "title": "Stats", + "require_admin": false, + "mode": "storage", + "url_path": "dashboard-temp" + }, + { + "id": "kobo_dashboard", + "show_in_sidebar": false, + "title": "Kobo Dashboard", + "require_admin": false, + "mode": "storage", + "url_path": "kobo-dashboard" + }, + { + "id": "morning_hub", + "show_in_sidebar": true, + "title": "Morning Hub", + "require_admin": false, + "mode": "storage", + "url_path": "morning-hub" + }, + { + "id": "morning_ipad", + "show_in_sidebar": false, + "title": "Morning iPad", + "require_admin": false, + "mode": "storage", + "url_path": "morning-ipad" + }, + { + "id": "network_cellphone", + "show_in_sidebar": true, + "title": "Network CellPhone", + "require_admin": false, + "mode": "storage", + "url_path": "network-cellphone" + }, + { + "id": "in_meeting", + "show_in_sidebar": true, + "title": "In Meeting", + "require_admin": false, + "mode": "storage", + "url_path": "in-meeting" + }, + { + "id": "dashboard_music", + "show_in_sidebar": true, + "title": "Music", + "require_admin": false, + "mode": "storage", + "url_path": "dashboard-music" + } + ] + } +} \ No newline at end of file diff --git a/.storage/lovelace_resources b/.storage/lovelace_resources new file mode 100644 index 0000000..8a3c36d --- /dev/null +++ b/.storage/lovelace_resources @@ -0,0 +1,304 @@ +{ + "version": 1, + "minor_version": 1, + "key": "lovelace_resources", + "data": { + "items": [ + { + "id": "cc55565add58413bbcadba9f9cf8caba", + "url": "/hacsfiles/lovelace-mushroom/mushroom.js?hacstag=444350375511", + "type": "module" + }, + { + "id": "387073c1f6fe4a658c6ca7e185b1134c", + "url": "/hacsfiles/sunsynk-power-flow-card/sunsynk-power-flow-card.js?hacstag=613588535733", + "type": "module" + }, + { + "id": "52ed5bcf36d24b36a1f1b3584ef7b64c", + "url": "/hacsfiles/power-flow-card-plus/power-flow-card-plus.js?hacstag=618081815037", + "type": "module" + }, + { + "id": "6bde3dd4cdd0478d9e77c0d68d093cfd", + "url": "/hacsfiles/energy-sankey/energy-sankey.js?hacstag=875798863104", + "type": "module" + }, + { + "id": "ee7be1b18ae84060a5d515640f6e5739", + "url": "/seatemperatures_frontend/sea-temperatures-card.js?v=3.1.0", + "type": "module" + }, + { + "id": "53837b51a371459b95ffc0989ce615fb", + "url": "/hacsfiles/calendar-card-pro/calendar-card-pro.js?hacstag=939311749320", + "type": "module" + }, + { + "id": "c86cd6c3d8eb451ebde1a41ad5a1dd33", + "url": "/hacsfiles/lovelace-auto-entities/auto-entities.js?hacstag=1677445841161", + "type": "module" + }, + { + "id": "0b28e2d01d0d4033b2f0a339cc7d86c1", + "url": "/ha_washdata/ha-washdata-card.js?v=1781361396", + "type": "module" + }, + { + "id": "8ce0dbd60e7641be90f2e08594d1db2e", + "url": "/hacsfiles/HA-Firemote/HA-Firemote.js?hacstag=536329656419", + "type": "module" + }, + { + "id": "6ad5d0d7a4c9435a85476e49d22d4df4", + "url": "/hacsfiles/versatile-thermostat-ui-card/versatile-thermostat-ui-card.js?hacstag=714354847320", + "type": "module" + }, + { + "id": "0476eb038c9a47a7b654a078b824a9d1", + "url": "/hacsfiles/scheduler-card/scheduler-card.js?hacstag=2862701574018", + "type": "module" + }, + { + "id": "5182b0b7bbc94cd7956a7eaf1389ebd1", + "url": "/hacsfiles/lovelace-thermostat-pro-timeline/thermostat-pro-timeline.js?hacstag=1072526491300", + "type": "module" + }, + { + "id": "193c54e0fb0947dabbae7a889f0b5f46", + "url": "/local/thermostat-pro-timeline.js?v=56675817588421", + "type": "module" + }, + { + "id": "5447aa1e0b934b57b58b98751c3c3350", + "url": "/hacsfiles/homie-dashboard/homie-dashboard.js?hacstag=1221720415400", + "type": "module" + }, + { + "id": "9a535448bf5645889b43fbdf481bb225", + "url": " /local/community/homie-dashboard/homie-dashboard.js", + "type": "module" + }, + { + "id": "3996b960ca1a49cd81de517c5e1b658a", + "url": "/hacsfiles/sensor-bar-card-plus/sensor-bar-card-plus.js?hacstag=1183027678163", + "type": "module" + }, + { + "id": "88b90f2b1d6b45f0b44bee786ee1a525", + "url": "/taskmate/taskmate-attr-resolver.js?v=5.0.1", + "type": "module" + }, + { + "id": "0796c92931dd458e81dc9c2677248274", + "url": "/taskmate/taskmate-localize.js?v=5.0.1", + "type": "module" + }, + { + "id": "d2e281920ac7409583eafff1f1db69bd", + "url": "/taskmate/taskmate-design.js?v=5.0.1", + "type": "module" + }, + { + "id": "9eae9632f0734480be5edd784b030673", + "url": "/taskmate/taskmate-badges-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "5df49cd8a4eb420497b5df22cb31362c", + "url": "/taskmate/taskmate-child-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "b44c87d0d74f468cb96c5eabc340f9ee", + "url": "/taskmate/taskmate-rewards-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "787a96f9c30548708165a48457c99feb", + "url": "/taskmate/taskmate-approvals-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "84031597de424ad68e7c2bc66d06b72e", + "url": "/taskmate/taskmate-points-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "02613225844746b699bfbfac9642eca2", + "url": "/taskmate/taskmate-reorder-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "7a06a07e17ba4b31b712bc91b77360b4", + "url": "/taskmate/taskmate-overview-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "08b42b53b33b4fa88db932455cf8d5aa", + "url": "/taskmate/taskmate-activity-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "15907eff51784acdba88ff694b574509", + "url": "/taskmate/taskmate-streak-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "1bd6bb5c0a6d4f0d916e3d82cb7fac39", + "url": "/taskmate/taskmate-weekly-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "289ce5943f5a4972a593697db4c96ece", + "url": "/taskmate/taskmate-graph-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "d2904acee4c74a398dcc7b996c23e7e7", + "url": "/taskmate/taskmate-reward-progress-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "46d378736fd54a26be253f2dfb27202e", + "url": "/taskmate/taskmate-leaderboard-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "db11ae36457842fa826c7991e1d9c6f4", + "url": "/taskmate/taskmate-parent-dashboard-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "ce85f301b59a412e9abfe5ffd51cb9a2", + "url": "/taskmate/taskmate-penalties-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "1959e1629de2422daa8adaf87069c288", + "url": "/taskmate/taskmate-bonuses-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "2548524b269c4959bc7e0e967df943af", + "url": "/taskmate/taskmate-points-display-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "36ada49368db4187a283fbbbef45454f", + "url": "/taskmate/taskmate-calendar-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "67c32687acd2497b90030ef141f4b6db", + "url": "/taskmate/taskmate-photo-gallery-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "6f0c8348a91f4b74a9b846ab3f62621f", + "url": "/taskmate/taskmate-family-goal-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "e92ef0caff41454e9f49ea966ed99e41", + "url": "/hacsfiles/lovelace-multiple-entity-row/multiple-entity-row.js?hacstag=178921037471", + "type": "module" + }, + { + "id": "4d1a17aa96b74064af713fc5e9334862", + "url": "/hacsfiles/ha-treemap-card/treemap-card.js?hacstag=11141174680152", + "type": "module" + }, + { + "id": "d3c3e144a91247ce8ac524814737d7f8", + "url": "/hacsfiles/lovelace-wallpanel/wallpanel.js?hacstag=3095064164651", + "type": "module" + }, + { + "id": "0922e4ba1c7c4f11969d0257689af5a3", + "url": "/hacsfiles/button-card/button-card.js?hacstag=146194325701", + "type": "module" + }, + { + "id": "e9bc54c181334248b39e0f8874998041", + "url": "/hacsfiles/atomic-calendar-revive/atomic-calendar-revive.js?hacstag=2465497471031", + "type": "module" + }, + { + "id": "c9b6518d876d437a9671194eb1919026", + "url": "/hacsfiles/daylight-calendar-card/skylight-calendar-card.js?hacstag=1143954763470", + "type": "module" + }, + { + "id": "5612169a8e664ae5bef0aa3803a076db", + "url": "/hacsfiles/platinum-weather-card/platinum-weather-card.js?hacstag=488086721105", + "type": "module" + }, + { + "id": "449ff43140654e4bbe2c7b2d361dfacc", + "url": "/hacsfiles/clock-weather-card/clock-weather-card.js?hacstag=522634019294", + "type": "module" + }, + { + "id": "dab9cfff6e834886bcf4d5c7c9968c9e", + "url": "/webrtc/webrtc-camera.js?v=v3.6.1", + "type": "module" + }, + { + "id": "94255369635043dfbbf598581e56569d", + "url": "/hacsfiles/apexcharts-card/apexcharts-card.js?hacstag=331701152223", + "type": "module" + }, + { + "id": "1251007d3e5a44a7bbced052f41b5f9c", + "url": "/hacsfiles/mini-graph-card/mini-graph-card-bundle.js?hacstag=1512800620130", + "type": "module" + }, + { + "id": "0893e5cfeaf1486f9c44cef25048230a", + "url": "/hacsfiles/lovelace-layout-card/layout-card.js?hacstag=156434866247", + "type": "module" + }, + { + "id": "8713ea9c008e4270bb28085eed5be959", + "url": "/hacsfiles/Statistics-Graph-Chart-Card/statistics-graph-chart-card.js?hacstag=1034932856330", + "type": "module" + }, + { + "id": "6c3f7d87683d4473b5dc8fe8177535f6", + "url": "/hacsfiles/simple-thermostat/simple-thermostat.js?hacstag=1230152807410", + "type": "module" + }, + { + "id": "9a17b155e3fb42949f2b2f64e24c42d5", + "url": "/hacsfiles/mass-player-card/mass-player-card.js?hacstag=1043277514280", + "type": "module" + }, + { + "id": "e635fcf579d24ceb97758cc125ed5664", + "url": "/my_music_library/my-music-library-card.js?v=3.10.4", + "type": "module" + }, + { + "id": "15981f60b53f4d5886272ea59de3861b", + "url": "/hacsfiles/uptime-card/uptime-card.js?hacstag=3505098670160", + "type": "module" + }, + { + "id": "a3fc4486bc2c4edab1f38af295b4ef3f", + "url": "/album_slideshow_static/album-slideshow-card.js?v=1.5.0", + "type": "module" + }, + { + "id": "73dbd2de03de4530b451a55bfe6c46cf", + "url": "/taskmate/taskmate-routine-card.js?v=5.0.1", + "type": "module" + }, + { + "id": "a2f2c6ca7b354458a8d4910d453f0684", + "url": "/climate_scheduler/static/climate-scheduler-card.js?v=1.15.1", + "type": "module" + } + ] + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 55482c3..fb8ddac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,44 @@ You are working directly within a Home Assistant installation. Your working directory is `/homeassistant`, which is the live Home Assistant configuration directory. +## Repo-Specific Facts + +### Version and Structure +- **HA Version**: 2026.7.3 (see `.HA_VERSION`) +- **Config entry point**: `configuration.yaml` — flat config, no `!include_dir` for automations (all in `automations.yaml`) +- **No packages directory** — all automations live in a single `automations.yaml` (~2960 lines) +- **Prettier** is configured (`.prettierrc.yaml`) and auto-formats YAML on save; it enforces 2-space indent and double quotes but NOT block-style enforcement or boolean normalization + +### Blink Camera Dual-Entity Setup (CRITICAL) +Blink cameras are connected via **two Homebridge plugins** plus the native HA Blink integration. This creates **two sets of camera entities per physical camera**: + +| Physical Camera | HomeKit (Homebridge) | BlinkCameras (Homebridge) | Native HA Blink | +|----------------|---------------------|--------------------------|-----------------| +| Front | `camera.front` | `camera.blink_front` | — | +| Backyard | `camera.backyard` | `camera.blink_backyard` | — | +| Shed | `camera.backyard_shed` | `camera.blink_backyard_shed` | — | +| Tree | `camera.backyard_tree` | `camera.blink_backyard_tree` | — | +| Garage Side | — | `camera.blink_garage_side` | — | +| Thermopump | — | `camera.blink_thermopump` | — | + +**Refresh switches** (`switch.*_refresh_snapshot`) come from the BlinkCameras plugin. When writing automations that take snapshots, **match the camera entity to the plugin that owns the refresh switch** — using a HomeKit camera with a BlinkCameras refresh switch produces stale images. Use `camera.blink_*` entities for snapshot automation. + +The `camera.front` → `switch.front_refresh_snapshot` pairing is the known working combination. + +### Critical Device Rules +- **`switch.dragon_egg`** (living room smart plug) — **NEVER turn off after 10 PM**. Always check time before any service call targeting this entity. + +### Custom Integrations (33 installed) +Key ones: `hacs`, `alexa_media`, `hilo`, `powercalc`, `versatile_thermostat`, `webrtc`, `nest_protect`, `midea_ac_lan`, `spook`, `watchman`, `alarmo`, `cync_lights`, `wyzeapi`, `asusrouter`, `trueNas`. + +### Zigbee2MQTT +Config at `zigbee2mqtt/configuration.yaml`. Use `zigporter` for device management (cascade renames, mesh visualization). `hab` can list devices but does NOT cascade references. + +### Reload vs Restart +- After editing `automations.yaml`: call `automation.reload` service (no restart needed) +- After editing `configuration.yaml`: requires HA restart +- After editing `scripts.yaml`: call `script.reload` service + ## CRITICAL: User Consent and Scope Rules You MUST follow these rules strictly: diff --git a/automations.yaml b/automations.yaml index 19fdf76..176d4cb 100644 --- a/automations.yaml +++ b/automations.yaml @@ -1,63 +1,63 @@ -- id: '1780125759253' - alias: 'Tablet: Dim Screen at Night' +- id: "1780125759253" + alias: "Tablet: Dim Screen at Night" description: Drops tablet brightness to zero at night triggers: - - at: 01:00:00 - trigger: time + - at: 01:00:00 + trigger: time actions: - - action: notify.mobile_app_sm_t387w - data: - message: command_screen_brightness_level + - action: notify.mobile_app_sm_t387w data: - command: 0 -- id: '1780125803824' - alias: 'Tablet: Restore Screen in Morning' + 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 + - at: 07:00:00 + trigger: time actions: - - action: notify.mobile_app_sm_t387w - data: - message: command_screen_brightness_level + - action: notify.mobile_app_sm_t387w data: - command: 180 -- id: '1780441470361' + 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 + - 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: {} + - 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' +- id: "1780509027967" alias: Notify When Ring Console Battery Low - description: '' + description: "" triggers: - - trigger: numeric_state - entity_id: - - sensor.entryway_keypad_battery - below: 25 + - 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 + - data: + message: Keypad Battery Low. Please Charge ASAP + title: Ring Console Battery + action: notify.notify mode: single variables: _cafe_metadata: @@ -72,20 +72,20 @@ y: 45 graph_id: 816af3b7-3eac-47c3-8da3-8797d1774c0c graph_version: 1 -- id: '1780516946807' +- id: "1780516946807" alias: Notify When Front Door Sensor Battery Low - description: '' + description: "" triggers: - - trigger: numeric_state - entity_id: - - sensor.front_door_battery - below: 25 + - 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 + - data: + message: Front Door Sensor Battery Low. Please Change Batteries ASAP. + title: Ring Console Battery + action: notify.notify mode: single variables: _cafe_metadata: @@ -100,51 +100,51 @@ y: 45 graph_id: 816af3b7-3eac-47c3-8da3-8797d1774c0c graph_version: 1 -- id: '1780813227139' - alias: 'Alert: Fridge or Freezer Power Outage' +- 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 + - 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 + - condition: trigger + id: freezer_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: [] + - 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: @@ -171,1758 +171,1759 @@ y: 102 graph_id: 5948b900-b45d-4c2e-ae73-c880acc3a1bf graph_version: 1 -- id: '1780989340205' - alias: 'Kitchen: Coffee Maker Active Notification' +- 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 + - 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 + - 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' +- 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 + - 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 + - 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' +- 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' + - 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: + - condition: state entity_id: light.playroom_light - data: {} - - action: notify.notify - metadata: {} - data: - message: Office Lights Turned Off - title: ⚡️ Office Lights + 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' +- id: "1781102033733" alias: Water Leak Detected - description: '' + description: "" triggers: - - type: moist - device_id: 59bc102f385d7a3524c8f21a5c53592b - entity_id: c268ff8842e905515de2274d09c5ab9e - domain: binary_sensor - metadata: - secondary: false - trigger: device + - 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. + - 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' +- 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 + - 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. + - 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' +- 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 + - 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! + - action: notify.notify + data: + title: Dishwasher Done + message: The dishwasher cycle has completed successfully! mode: single -- id: '1781275801564' - alias: 'Dishwasher: Rinse Aid Low Warning' +- 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 + - 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! + - 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' +- id: "1781278655292" + alias: "Dishwasher: Dishwasher Started Notification" description: Alerts when the dishwasher starts washing triggers: - - entity_id: - - sensor.150633095332665_status - to: - - Running - trigger: state + - entity_id: + - sensor.150633095332665_status + to: + - Running + trigger: state conditions: [] actions: - - action: notify.notify - data: - title: Dishwasher Started - message: The dishwasher cycle has started! + - action: notify.notify + data: + title: Dishwasher Started + message: The dishwasher cycle has started! mode: single -- id: '1781279642928' - alias: 'Dishwasher: Rinse Aid Refilled Notification' +- 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' + - 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! + - action: notify.notify + data: + title: Dishwasher Maintenance + message: The dishwasher rinse aid has been refilled! mode: single -- id: '1781282506359' +- id: "1781282506359" alias: Dishwasher Cycle Tracker - description: '' + description: "" triggers: - - trigger: state - entity_id: - - sensor.150633095332665_progress - to: - - Complete + - 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: {} + - 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' +- 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 + - 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. + - 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' +- 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 + - entity_id: counter.dishwasher_wash_cycles + below: 10 + trigger: numeric_state conditions: [] actions: - - action: persistent_notification.dismiss - data: - notification_id: dishwasher_filter_reminder + - action: persistent_notification.dismiss + data: + notification_id: dishwasher_filter_reminder mode: single -- id: '1781285326960' - alias: 'Tablet: Restore Screen in Morning v2' +- id: "1781285326960" + alias: "Tablet: Restore Screen in Morning v2" description: Wakes the tablet and restores brightness/timeout triggers: - - at: 07:00:00 - trigger: time + - at: 07:00:00 + trigger: time actions: - - action: notify.mobile_app_sm_t387w - data: - message: command_screen_on + - action: notify.mobile_app_sm_t387w data: - command: turn_on - - action: notify.mobile_app_sm_t387w - data: - message: command_screen_off_timeout + message: command_screen_on + data: + command: turn_on + - action: notify.mobile_app_sm_t387w data: - command: 120000 - - action: notify.mobile_app_sm_t387w - data: - message: command_screen_brightness_level + message: command_screen_off_timeout + data: + command: 120000 + - action: notify.mobile_app_sm_t387w data: - command: 180 -- id: '1781320273276' - alias: 'Tablet: Fix Greyed Out Brightness' + 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 + - at: 07:00:00 + trigger: time actions: - - action: notify.mobile_app_sm_t387w - data: - message: command_auto_screen_brightness + - action: notify.mobile_app_sm_t387w data: - command: turn_off - - action: notify.mobile_app_sm_t387w - data: - message: command_screen_brightness_level + message: command_auto_screen_brightness + data: + command: turn_off + - action: notify.mobile_app_sm_t387w data: - command: 180 -- id: '1781392657515' - alias: 'Washing Machine: Cycle Status' + 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 + - 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' + - 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 + - at: 07:00:00 + trigger: time conditions: [] actions: - - action: script.weather_and_comfort_report + - action: script.weather_and_comfort_report mode: single -- id: '1781403940897' - alias: 'Microwave: Cycle Status' +- 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 + - 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' + - 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 + - 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 + - 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.' + 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 + - 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! + - 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' +- id: "1781793117883" alias: Basement Temperature Change - description: '' + description: "" triggers: - - type: temperature - device_id: 3e67afd88a4b4d17552c8f370aaf40b5 - entity_id: 64689573a8b11a83829efcae0022d7c1 - domain: sensor - trigger: device - above: 15 - below: 10 + - 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! + - action: notify.notify + metadata: {} + data: + title: Basement Temperature Change + message: Basement Temperature Changed! mode: single -- id: '1782161891986' - alias: 'Notification: Pool Sensor Low Battery' +- 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 + - 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_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_battery') - }}%. Please change it soon. + - data: + title: Pool Sensor Battery Low + message: The pool temperature sensor battery is currently at {{ states('sensor.pool_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_battery') + }}%. Please change it soon. mode: single - &id001 id: commute_morning_traffic_alert_map - alias: 'Commute: Morning Traffic Alert with 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 + - at: 07:15:00 + trigger: time conditions: - - condition: or - conditions: - - condition: template - value_template: '{{ states(''sensor.google_travel_time_work'') | float(0) > - 35 }}' + - 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." + - action: camera.snapshot + target: + entity_id: camera.camera_commute_map 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)" + 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' +- 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 + - 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. + - action: notify.notify data: - push: - category: map - action_data: - latitude: '45.474668' - longitude: '-73.737158' - shows_traffic: true - shows_scale: true -- id: '1783000087983' + 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 + - 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 + - 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 %}" + - 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' +- 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: + - 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: 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 + 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 + - 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 - data: {} - - delay: - hours: 0 - minutes: 0 - 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.blink_backyard_shed - data: {} - - 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: /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: 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.'' - }} + - 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 + data: {} + - delay: + hours: 0 + minutes: 0 + 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.blink_backyard_shed + data: {} + - 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: /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: 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 }}' + ![Snapshot](/local/{{ snapshot_filename }})" + notification_id: "{{ notification_id }}" mode: queued max: 10 -- id: '1783452320698' +- 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 - to: - - 'on' + - trigger: state + entity_id: + - binary_sensor.backyard + 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.'' - }} + - 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)' + ![Snapshot](/local/blinkTree.jpg)" mode: queued max: 10 -- id: '1783540959739' +- 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: + - 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: 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 + 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 + - 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 }})' + - 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: - 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 }}' + 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' +- 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: + - 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: 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 + 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 + - 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 }})' + - 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: - 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 }}' + 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: '' +- id: "1783628026617" + alias: "Garage Door: Open Notification" + description: "" triggers: - - trigger: state - entity_id: - - binary_sensor.garage_door_contact - to: - - 'on' + - 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 + - action: notify.notify + metadata: {} + data: + message: "Garage Door is Opened " + title: Garage Door mode: single -- id: '1783628088925' - alias: 'Garage Door: Closed Notification' - description: '' +- id: "1783628088925" + alias: "Garage Door: Closed Notification" + description: "" triggers: - - trigger: state - entity_id: - - binary_sensor.garage_door_contact - to: - - 'off' - from: - - 'on' + - 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 + - action: notify.notify + metadata: {} + data: + message: Garage Door Closed + title: Garage Door mode: single -- id: '1783643893067' +- 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 + - 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 + - data: + title: Shed Door Alert + message: Motion detected on the Shed Blink camera! + action: notify.persistent_notification mode: single -- id: '1783647055563' +- 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: + - 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: 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 + 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 + - 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.'' - }} + - 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 }}' + ![Snapshot](/local/{{ snapshot_filename }})" + notification_id: "{{ notification_id }}" mode: queued max: 10 -- id: '1783649304797' +- 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: + - 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: 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 + 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 + - 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.'' - }} + - 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 }}' + ![Snapshot](/local/{{ snapshot_filename }})" + notification_id: "{{ notification_id }}" mode: queued max: 10 -- id: '1783736251030' - alias: 'Office: Teams Webhook Busy Light' +- 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 + - 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.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.night_light - data: - brightness_pct: 20 - rgb_color: - - 255 - - 255 - - 255 - action: light.turn_on + - choose: + - conditions: + - condition: template + value_template: "{{ trigger.json.status in ['busy', 'do not disturb'] + }}" + sequence: + - target: + entity_id: light.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.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).' +- 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' + - 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 + - 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' +- 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 + - 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 + - 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.' +- 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 + - 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 + - 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' +- 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 + - 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 + - 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' +- 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 + - 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 + - target: + entity_id: input_boolean.in_a_meeting + action: input_boolean.toggle mode: single -- id: '1783901121760' +- id: "1783901121760" alias: Ikea BILRESA scroll wheel - Bedroom Switch - description: '' + 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: {} + - 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' + - light.bedroom_lights + scroll_wheel_mode_ch1: "lights: color temp only" +- id: "1783904189553" alias: IKEA Bilresa Scrollwheel - Bedroom Light Control V1 - Atomic - description: '' + 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 + sensor_1: sensor.bedroom_switch_current_switch_position_1 + sensor_2: sensor.bedroom_switch_current_switch_position_2 + sensor_4: sensor.bedroom_switch_current_switch_position_4 + sensor_5: sensor.bedroom_switch_current_switch_position_5 + sensor_7: sensor.bedroom_switch_current_switch_position_7 + sensor_8: sensor.office_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' +- id: "1783905541495" alias: Ikea Bilresa Switch Bedroom Light Control - description: '' + 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: {} + - action: light.toggle + metadata: {} + target: + entity_id: light.bedroom_lights + data: {} scroll_wheel_target_ch1: - - light.bedroom_lights -- id: '1783906561328' + - light.bedroom_lights +- id: "1783906561328" alias: IKEA BILRESA - Light Controller for PLAYROOM TEST2 - description: '' + 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: {} + - action: light.toggle + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: {} input_light_1: device_id: 2f8c5ab2ca0d4be31919fba6df365813 input_mode_1: brightness @@ -1937,90 +1938,55 @@ entity_id: light.bedroom_lights input_sens_3: 10 input_transition_2: 0 -- id: '1783909375854' - alias: 'Office: Playroom Light Toggle - IKEA BILRESA' +- 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 + - 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: {} + - 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: '' +- 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: {} + - action: light.toggle + metadata: {} + target: + entity_id: light.bedroom_lights + data: {} scroll_wheel_target_ch1: - - light.bedroom_lights + - 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 + - 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 + - action: light.toggle + metadata: {} + target: + entity_id: light.bedroom_lights + data: + brightness_pct: 25 dim_turns_lights_on: true -- id: '1783962246983' - alias: 'OFFICE: IKEA BILRESA E2489 Dual Button (Matter) - In Meeting and Away Mode' +- 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: @@ -2030,931 +1996,966 @@ 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: {} + - 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: {} + - 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' +- 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 + - 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' + - 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 + - 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 }}' + - 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: '' +- 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: {} + - action: light.toggle + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: {} scroll_wheel_target_ch1: - - light.playroom_light + - 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 + - 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 + - action: light.toggle + metadata: {} + target: + entity_id: light.office_lamp + data: + color_temp_kelvin: 5564 scroll_wheel_target_ch2: - - light.office_lamp + - light.office_lamp scroll_wheel_mode_ext_ch2: instant double_click_action_ch2: [] - scroll_wheel_mode_ch2: 'lights: dim' + 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 + - 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 + - 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: {} + - 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 + - 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 + - action: light.turn_on + metadata: {} + target: + entity_id: light.playroom_light + data: + brightness_pct: 30 dim_turns_lights_on: true -- id: '1784073759556' - alias: 'Office: Desk Lamp Meeting Alert' +- 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: - - trigger: state - entity_id: input_boolean.in_a_meeting + - trigger: state + entity_id: input_boolean.in_a_meeting conditions: - - condition: state - entity_id: input_boolean.meeting_lamp_override - state: 'off' - - condition: state - entity_id: input_boolean.away_mode - state: 'off' + - condition: state + entity_id: input_boolean.meeting_lamp_override + state: "off" + - condition: state + entity_id: input_boolean.away_mode + 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 - transition: 1 - - delay: - hours: 0 - minutes: 0 - seconds: 2 - milliseconds: 0 - - action: scene.turn_on - target: - entity_id: scene.desk_lamp_before_alert - data: - transition: 1 + - 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 + transition: 1 + - delay: + hours: 0 + minutes: 0 + seconds: 2 + milliseconds: 0 + - action: scene.turn_on + target: + entity_id: scene.desk_lamp_before_alert + data: + transition: 1 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 +- 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.' + 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 + - 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%)' + - 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: state - entity_id: input_boolean.in_a_meeting - state: 'on' + - 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 - - alias: 'Meeting is inactive: Set light to solid Green (20%)' + - 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: state - entity_id: input_boolean.in_a_meeting - state: 'off' + - 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 - 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 + - 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' +- 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 + - 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 - %}" + - 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 - 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 + - 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 + - data: + title: Motion Detected! + message: Motion detected in the Blink Backyard Cameras using HomeBridge. + action: persistent_notification.create mode: single -- id: '1784597653614' +- id: "1784597653614" alias: New automation - description: '' + description: "" triggers: [] conditions: [] actions: - - action: webrtc.create_link - metadata: {} - data: - open_limit: 1 - time_to_live: 60 - entity: camera.blink_backyard_shed + - action: webrtc.create_link + metadata: {} + data: + open_limit: 1 + time_to_live: 60 + entity: camera.blink_backyard_shed mode: single -- id: '1784599010203' +- 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: + - 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: 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 + 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 + - 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: 20 - 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.'' - }} + - action: switch.turn_on + target: + entity_id: switch.backyard_refresh_snapshot + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 20 + milliseconds: 0 + - action: homeassistant.update_entity + target: + entity_id: camera.blink_backyard + enabled: true + data: + entity_id: + - camera.blink_backyard + - 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 }}' + ![Snapshot](/local/{{ snapshot_filename }})" + notification_id: "{{ notification_id }}" mode: queued max: 10 -- id: '1784601034537' +- 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: + - 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: 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: 20 - 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: 20 - 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: + 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: - - 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 + - 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 + - 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: 20 - 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.'' - }} + - action: switch.turn_on + target: + entity_id: switch.front_refresh_snapshot + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 20 + milliseconds: 0 + - action: homeassistant.update_entity + target: + entity_id: camera.front + enabled: true + data: + entity_id: + - camera.front + - 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 }})' - notification_id: '{{ notification_id }}' + ![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 shed's Blink camera detects motion, analyzes + it with AI, and sends a mobile and persistent notification. Structurally mirrors + the working Front automation (same switch/camera device pairing). If the AI description + still doesn't match the physical shed, camera.backyard_shed may be mislabeled + in the HomeKit Controller integration -- check its live feed against camera.garage_side + and camera.backyard_tree to confirm which physical camera is which, then rename + if needed. + 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: 30 + milliseconds: 0 + - action: homeassistant.update_entity + target: + entity_id: camera.blink_backyard_shed + enabled: true + data: + entity_id: + - camera.blink_backyard_shed + - delay: + hours: 0 + minutes: 0 + seconds: 5 + milliseconds: 0 + - 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 + - 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 + response_variable: ai_profile + 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 + - 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 garage motion is detected and analyzes it with + AI. Deliberately uses the 'backyard_tree' Blink camera (switch.backyard_tree_refresh_snapshot + / camera.backyard_tree) as the garage-monitoring camera -- entity names don't + match the physical location, but this pairing is intentional, not a bug. + 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: 30 + milliseconds: 0 + - action: homeassistant.update_entity + target: + entity_id: camera.blink_backyard_tree + enabled: true + data: + entity_id: + - camera.blink_backyard_tree + - delay: + hours: 0 + minutes: 0 + seconds: 5 + milliseconds: 0 + - 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 + - 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 - Garage + 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 - Garage + 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: "1784813583489" + alias: Entryway Lamp - TEMPORARY + description: Turns on the office lamp when motion is detected in the entryway. + triggers: + - trigger: state + entity_id: + - binary_sensor.entryway + to: + - "on" + conditions: [] + actions: + - action: light.turn_on + target: + entity_id: + - light.office_lamp + - delay: + seconds: 1 + - action: notify.notify + data: + title: Entryway Motion + message: Office lamp was turned on. + mode: single diff --git a/custom_components/ai_agent_ha/__init__.py b/custom_components/ai_agent_ha/__init__.py deleted file mode 100644 index c334eae..0000000 --- a/custom_components/ai_agent_ha/__init__.py +++ /dev/null @@ -1,426 +0,0 @@ -"""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 deleted file mode 100644 index 0407091abac7c1c495ab9d5aded306dea5687ad3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21599 zcmeHPdrVx}dB1n=+@ptyhW^r;QY;}O zckbLf55`^$t=f%p1!unV`p)B?dw$>dokvq{j){Wjk9}ue@*kt9U!q5OSec+72{c7r zqu8b$u33 zP9%rW`o3I`HDV>Sq0i>Yi{um9*jM1ON9;h;ys59yQxqv8w7IX?QxYkuq+VnO_bAm& z{#H>hmhFSO;uCSam_K+(rc+%=Q=+CRe~}$Lk{T=1kcJ&Rnk}5SD0zYVGaFO5X(c{V z#pe`Ld~Punv_-0UE6_GV*YJ5j=M%b?F96z3=sLa-=psVb^O_o}u{i1+p73*>eO%`` z|5SwYbaH{Ii2t16iv&Vbu0~oi`=+Ks5#kjGz+dhYXQoEIg8y7VjQ9ob+37$q;tfoB z!@enh5E9siF!ku5FEY^;ni>zBLnboSJrxmVz?40Xy`=}O(i_{49WzC_lQq&lPIjQIA37V&Q4bSjep5=9J20BRpuNp`yX(xQ5C}4I; zCs#@^5|7}IObb(z_G~B=e4E1J$3w!TFCt=*)zWUaoZ3A)Ek;6<-chJhXbO5-bo&Bc zANIL-!sqrv2L>WuuPZzwS-jpvm;1e5p$OP;@Gn+@bCLQLrJ-#fqBGmShCjF3$j|Ls z68H!bQKML60uP?9q4qNYx=|CY8uCZD=`a_`7z1iGUBf~FWNg$3 zR`?NY;E2ewzFsJV9~d2EG>Fg(VWAX4IQSP0;QT9;^b51~KSeEgUT0W(doSW`y?`x{ zt3loa3wG=aMW1d5|}*jqdCZ;K%LfLcuXk9-AC?WWXKg4k2^ysKx6vwB7jao_XJ#n(Px0{#PG5^`=bCN!h zP|^mb#zV>wb3%*KhFF0-#3to>d*!!Y4XMbh>;M9X+6P+m*ZaTHAIoo>>;Aj`zv*Am zKKoE_`TB*gT$rU-^rgSB7Odw}tmW$`zH%a#xBu4k_b+_+!iu*0eH@`UoL=uN>MYsj z7!@4A=rczk0)|)*4t~u8EDk|*Z#1uw-#N4VyQr1JHx5Jm63H-p2L^*LF%TWp85+XL zGXm3?^70!JVPo)hIB~cohF&6={J?N~6^0x1sgpXR4%fkKLrrrAr#1|ZK6m91n_ zRtdI}*Sjg+;HKP~>?xC+@I{S4O?Q(v_8vLbK?k;wdSS~#zO7U zBA)hnW%0AkJ$U&%T7;MxD~0Vy785HLdA^$^KOs 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 diff --git a/custom_components/ai_agent_ha/__pycache__/agent.cpython-314.pyc b/custom_components/ai_agent_ha/__pycache__/agent.cpython-314.pyc deleted file mode 100644 index ed1c8bf3bc450b81d3f0eda82b9ab88907bff8c7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 194966 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%0D{_+XH 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%0D1mfJ(*?EBRH+NjW%s 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

g6ZKEJX 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 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 deleted file mode 100644 index 45e90e0b7198d9fa10f11bdcb155976bb95455ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27970 zcmeHwdvIG?4l1-C@#nmz#9P@@7+Wr<*ai-I-;ZOoltNf5>a^uF|G6zyd5_3>|YO?j-HB9j>I* zSv7z3`_9F^xTHXdvYjT)<5j7eisa+k&AaF>oLMwKxoiIa_~MoVHP#4jIH zk7{BX;#Z7mM|CmXs6M72Esd3q8e#?rry1o~*{CsQ95uyEqvn{IgsH~LM=N3#qn4Ou z)EcvnR>mr8scX{dUcQQjdmVMn)=P8GF*{Sjs2L5TWps?5DP;^y8DnHjjF~BCDi{l6 zWh$8}Pf4sA=xjh|2fAvYs{y)NpmP9S9a9hgEY~WS%4=4SI#vU;4M5unv`s+U474pk z+X}Q!plpMD+acc$psfX37tp$a)&sPiK-&eh-9Xy|biF{=4dpu+jhkXlP0@@F?x*2i z3ilznm%;rE+)Lo@g}aIAbJI*YJe`Fn3*3j{UWuWQPaUI))t6Is6m!f%F~_edr_UF9 zhWA~oWcrWG@;%f@s0V=h#5GN?G#}bTLI)xAB;+y9r!3qB$`(e_KsnFFTZhBTFNYQ! zFE52(b-WyA9o{jAcOkeObBuZ&q2*X`fepk$;bpgz&S?V6%i$P#j84L{evYK|12r28 zMj=Es7g$=l9+m)5(_aoQ1?OU%8)kFn{m)!&a@v{T z?E;Na{KQc>r8(2f!PwlQe<64?v>fu^49^FbqPZF_FdPXk2SWb2@XZKR_xe(hkIyH{ z$8(9GR^`J}ljr^8Q^VeI|J3-ncf#wx!i?w4BzS6iWYRn4KkL0bLPD#=p~F)X)83h} zv*TQx;@s4PcWg2zUs?*>4CLhFY@H$K}|{d??)ExHUdL;mm1B zPX1WneH|(U89qB=VLW`X!JXHu!Bl+q$W?qW~ z<7dR(N~nQzxGw8(EXHDy6CMv4hsAI-cA~4Rx2M+=h=e>a$AU1mg3fr5YlOJRAI~Cq zJ8(0y6m$bidv0}k957QK%e=p{zY`;!ZpXw*H0FSQbG#agEjlppQ8Vd0SFdX#a4X1$ z=5DyUj-PdPb@z|B#+IWoc4aQ+IzQe2NWKt>x%wU%xFK|PyZc-(F9o8Dk90VU0jN2| z_l6%;%@O`{MZ?R>K?rg6xw<>M`#J}@2XfRS<-*cUSFii{BNgG29(VVnl3}P+_@>+K zo+~U!6qR4Q=m)nU^mXbQ6{F!zC3$HxCS~ZD>?JxTzowYhi<5BvnCe=|H8mr#Q2gtJ zzsEGNe#

5*^cmPfmO~@F|E-4?ZRFl`^WBfsw*WsLq+ZA+qp|2Dl~WJgkWErqRIi zJj~F5gInv0u9NP${CkVkOAaoyN%!CucwVP$)T|K8*RO$cP_yt(<}L*_Ur$kXDnifZ zS9gkp15N(IIM3T1|y$r!?MtAsBPS7K4s(1efn< zq2jT{P}H$Vn#H=~wPay-%&|dOD3=%Zk)Loh!8$L?*@T{%;xfao;W~!Rm5kh;3zFcR z5?0U)7e<(zJV;i1Nlwf48hJy!mZO7FtbVg2*X!XTJadF4$SM?GhWAL+1M@wA?;rnS zAW!7ScauBvTCO4xeQkLTR{WSh-zpN7YZK~M7&&)w+CPEvqut=VPVK2=CDt92J7wxj zXgmLAr_}n8l0b;o^sR~Cnn+X)ZatUQ4t;2{{GUBJgg=TJAlVxwTIzQ+{n9rpdZd3O z@0U)_3Bw4nM)>CsQan^d;-iDJIKVz?T3`jB%wG(|Gt!PK{+Xh*RJVi| zg)V@=_6o4mZTfZTxLgf+LN4%p@Zwpk7KFHi`FUs`wJg*V(idSaKK;S+8mJj+y5wMK z6vfD9iQtJb)7r=5>nKrtk;x09j+AfXY#E<^)+G4Myl>jV`wDlywg&2o^ondn>ZVpC z=QR}dDzuXAW#m37qwvW*l1a%W9YvK;HPnhUB8x~F;MC?!93Mx4=U0|P zUs?$wO^rWrJPK_*jU0U|ifdtWv%}$F`!Nf?P^=2+kcPu&F6j>>x(g|zAA|GXU^?xVnN}6M zn(|+2>UND48Dm@0*tRjd)pqyV_KC}hftf`6m6Xw!(fSfvUsh|#Xgx`-C#~(;b+|U- zTdr--Xrgv(^;}wO&sJ7vD!Y@F-KomnjHx%F?cGyQ26LvgAz9jxHCOFeZEvf;qyE@z zd2{@C$JeV{vTK9+~`w|6PB2#P?7tX{z~P#P{Qwt>@M)8+993Hv=h0e{#fk zA7dU$WtCMAq>Ao}J*le9_&}F-U@j5>9Mlm`l|PoR`9>)pwX>YfV*!U0-opA zp^staiPj-%8X^u}3@u)JBGST($+vG+ipUDDrvowaS!5G(9f(oDOqBFLPf_!*X4zrR zPtY@ph;mlN(@(>!J$PL`<`!Ob5tWY~kpzDOQ`;yXU5wr*AJ>8oqS`oSkdI$SwOee1}IU9>z#$*A}jaYTJ}y;yFHGM`%s$!+fN+-8N< zk#EHyEwq938m#H+X<>~l^cKGgMl>^;B55?R(ieyYU!gowLuy)Ba|IGXpn|u7O#Jp~ z!2Z#a7J;$>-e^VpXV%Q;^yyo4b+c&Qg`7#Ph@t{BpNr5k(NPAeR;&sR;#X3wp@6!o6*N~ibjI{q% zXaS6cow}MRUjHIF^#JF31|ZLQ=u=o1)Xz|h@~dAwYB`W=wa}4*hbfHMDfLkuQB3yY;|TM-^7kStlPk+s^wyy;glt zZW86#!P6D)Al<E_nXAt#GaZb`R(!%&6`yRE{ zN61?kWz6^U89h)UAAR(=cj@mPY4n75-?NV1Q+rqk%J#wxI^qi8k$hhK&U8FOk8$$U zk$UX&U)2h-6Bq}c++)R7h|jIZj=n;O#{P3!`8}`}_*BAM`7ZJ*3o%NNsTDRmEg+9S3r>|?5seM z2|6J==%^U$jD)GgHdj+4vT^^cgC8BBspV)yvTXw&Teve-JP$x_wtcyMgI9Ax4d`(V z(aUtk`$c*gUd=&FRYW^OOk1e30fhyapMvuaUNz%pPeG|{B|2_!CUYv5SAL+(%{GCP zQ*!G1B^KC@Vo@@BREhfcC}{UVoA2gS<9Y2oixOb2k_!`ASo}cgzY%;bCnts{(2jE2 zPx2JE4vQ>tF2{6Ub#%fJH+DEUD;ZRq&_bA@!@;T3`H-A~vu3e&tej44#%s;d&L2o} z@>nRg6wJwpR;V#F`Jj99UV3u|wE3XR!3r3&gmwuBwH z?@h-_6m-2EjxNt}(62{hPS%Tg;EaUq!33Bq;fFGq(-32lpXkn@BKhw5fs7r-H0RKH z4ij4EgV8xQL{!rL$WmY~xENjn9bEL0xtOWuk?khZVUnP6@7a;@%Q^kIk@Maw<1_w=sdM;j@cO)C z5C>+%e25g(+Bw}AH!9vp2+Ub5Y6fz0u+4-bx%y&iJkWy^LuY>IOOf97B*MomqAbdz zIr)6>`pQDi$R#DlfFP%|XD1+@?Lndvfg#w77&wj&DsouV59d^zaUif%&>6C07z^(N ziba(aiyGscBD4%zy_nO;PGJ~stjNHzU&7e@D8C#EF3qzM4AJHJi!&EYpS5}Y_O`WiRkz#Ly}6KXJN4G!-BSsbb;s78 zv3Zg<&!%e2ld@feAW)p9G|OrFQ#e+)-)ek>a%UVYv+^ZmYu4W zgt-Njp(MZZFJY_$XJIG@V{|r zPfC?na=E0N29o9zdn&5FIa4>9tQ$?$UCco3%@;}5iQ4Y8se9L2|F-@ueY(Y)vYyRY zM-$f3yVpOeuzfrDdkdgrEw2XJbM!dPXE~HKpUJ05w46;@hcni(gmvsLD@tRBH1Iyj zr%5!ONt(U8bxoPN%gMUSsk$o}+m(d*3J}$Iyi@bNnoVV@u76FRt#!WrwYR>uv7D+s zwWj^pV0lybZC$p)y>WZvl|=u=d#4i@Ur4<0O5(<=iC1nX%&&pcxXiexhE7`V%{25R z8+y{Fo@`go8sCY}C(Z7^G}W$;XBv+s8;@lg`+wQkzdLv#GdPhPoJb8$6)3mOP|PpQ z{U4P#Z0NVz)2$=BgJb!zc%F}h3R@j}l)SOxAtkp~>}jbp^jZ3jEMsj=T3a`)+t%*Q z{$E;80^J#Ug5DRLw4U6x*fJJ((&EnAoj-JLj&8S|N;pq%1rk+fhz8x6taL(;TRjgb zsnxhgNuhOSN^4$Q+1A)Uws&Ogok@G=X8D$FOP8{rU!C~aV0u&gyV^T1rVP#>p4>4v zZ(K~9k3Wz~I*bn$RGFDXzO?y;v|;G|njLfNZgt~&Xw#B7aVcRR&%(pvrZO>bHDP~# zPcE&idMK5bS3Hm^&(g++20r=KjZoUq{}VSP->goXhamSsaqcNY#}AjV=<{iF4@7#5 zd$mTJe)ZB`D`mE3Of5-ZUu(mTz4q;k-?_N{<&?cAYpLEf*R2m`%{AXI*=XH%^g!`# z#=R2C-1>l0ne}^=N~hnIJKq$Hhdd|Hjp9HB;Qx6_*1hbn%kpTtHm{8{_)hjQRa=rFS*?daZ8q#Z*k)0w?e z6{(W>$yIvVct5m)E$3U~zde4(meMwT|0_5m>a+oQs#OnzY*DXjKG9QJ`);Y_PJbE< zOEvYG8h5hBovGVK`Ggyr#~YYSv##n>wK{GUleFxoJJH zVN09)U_Je|4rZyjHc^jjV79UM+Y`Huy<3{iuWmPJ=wbMHO1Pkf2H3s*}>SZYu_)N<@u(<2mAKi~hshV^2ucJSPH{%=LhHIVlyUkYEp}jlgt%KWpb(QE2 zwt)M7Jw5A`z26{3e=`lF@3+*0|7YqG;Qn0RjqY)JwnO&wex&*NAkzH&q!$z4Yb;0i zLjpG{o$55Z7b4)+XHs?`Y9Ffn0 zyun>pXZStyW4mqzSkx7wUAJNy+i~E6UALUi0Zu8=pDCCyCxbRZJVft7Zk=1|$U?!xL$X*NagVu|N#^ltRv& zEknu3MYfDcQ5!?CoGO@-qn8VmD10Bd78Jgqt1P`UnQ(fJ}cad$EG033sH6t4vv#FX>sgwP?o z`(^YkfRnQbTb~2_SlkJ+EI3XR*qo?yOlofOSN6yE0Lqbwx<^z1adtl{|KUwR3eBzN)&yRAo^UQsfsfo^z>5Z>VqN< zlQvw=i#WdrM}a`Z`P(QeN8a%2M=R-f4fKU}*}G+R=->1Aa zaQj)!StS02yJ@sSde0;Q|Gjb={S{L17Z)~O`V7K`NNkYsulHvWI||4AiX2gqhy{=@ zj1-}e@n!y#Py(C+*Zeg6PZvtehxHf;CFP%sP@?63Gsfnmu^F|o z#^H={EMWv~?5Yx8&h`^qt-qMMJ9RIXvdv`5XO1GpSpE@8F%`U+@_9%x{m&-FOo0@0 zMUY}-B1KKsJjJQ7K@Z-J${LVlN`T;Rqt5|Dz2UWty6AVS)aW+S7lvf-Hr0Xur%G?h zsDyg2tQ_5z4shQ$du!nKvu==QT=YFj(`cLYUW)|%P8$7fQt&%zvH|!3J?SjVsr-J} z$^`xXoZ643nia74_;Xsn|4S=@rF=}O-~V!mjmDNj04E7Uw9@aN56_{;2S?Y(rY2d` zH4wz#q>nY%x5~f*?h)c#eRb zK?e;yEDHWCnNHY^B6)CvSE04+5pG0VI z_b71KKjgCq0H$I-d@~SQo`WFaf-MGK^aF&O6u?#t4PYxtOvXrKaz+MlC^`ETAP5b> z#E!c;h)^sH8$B?80cH(>MJ&J{qK?2a1QJ6sfxdxp+s%F-QcgM*?04YK{ysY2MduID z`Pbg@L zTN;2!`@jWo2uI=pDREiGX9VTr3-Z9lE8;-rm+U`&4q&8W$}Cwy3YsN4`bJRRmR5AqT$3hX9fg2B9npNK!o}85RIZ(n6U& ziCCcsvR%n>;6Ji`D&Sw&Quv8V!5&Q_F7<4X!^Cb8YwkFs9q`4)k91@?yDfT}h zS0d=xYBRQuq^%=k>q^?XGPb^?t#9*YdgMyV=Hm?QfKUSENm!r)wRo-Y@KaCOa7)#DR5SSBYf*r^PxF9t^y|1j`e1=9m^q5CHv89z!wezJz8L^u zBw>x(uf7J2LSc2;4`5P2`N%;Bu4&W@9=M9FN{rM)39Fparz}R}lNOG{?XQ=RQUz;) zj2xDPl@HAeyis5QW;6np0P^&i>Inz=6hdjD9rd%-R|FqWU+pFJCH0#rnV=a3K#tTA zEtrM0#00~&1fCBZr_}n?up;VKBm|%dT=At3IgC z$_TW~gn1nbEtB)R--yv?2dNQyqx?y25@_vDr^OP)Z}vRw>59)RW zz=*qXdooYx00dn;|0cq5`${J5CVX<(3RXNXk+!N53O;ZF2n``>kJY#MTEveZ0NDgc zCjq?UZsou=4ZVBtnyx(lbng=gvOF1v?krL_-QUEt7JOIw(sR-DH%qD+uoR-C1O3a!t4LzQVnZy80qx<6Bk$ddCkiTV`=H zOs@UX1N2yHL7NM++=#0oa3Z&|e!|dqXL*AvpJ-_+c*Rk1? z#kl9_Qc$rs!IR(Q5!)>8K*=>zHDfCxKPY*=0S$sutU-v_L1wj+cO8-YeD-OOZw_Aj zk1~n4rT9z`df?N{1X1qAd9loAd&>MO0?!5eQe^>ZTUdd8_Q%)7Bk=6L_4fah`Ox+Z z^MOdZFduGx4)a0KFY#-85&kTVRDZ@9(k@!7OJ-34LoQ!699hcmjQ?No{@;ZhQIPz7 z)lcF4A}jDeasJWe2+`V8bY=TAqYLoRN}pQTy=;*gC1pCu?(NEFpI`7@PO)}lMBsd3 zXZ(5a{fs@LExLXdkt_dkuAfKeBBFbiM06nv4n5ZM;~@fi7LIhHHu1p4Son1kbOmDA zsABxb{IN$kQYjt(2wchdJbaexzmsE-p7^jx!_6D%j{^C`1B-c0cpm*p^v2}WBkp0j z`9*YQ(LvQeL4>j(epB2z$}gagoLG1PeYh`;H|LEtoGr=aI3HSu?}TxO zAh@a$L~2}e!U5Kw>zM9~n6B}#Qw+T39>0YrIrxrq)+ zI_xqyxiY@Hf;_;DVC@J5j~u*YV@SJ#4(_Q7I7k*=k(9H6DDYYM4hzNJMhCYy1%*HR z8a~Fs0kkOq=D4GqU&TOF{IIW+m_@*na>m#^?rz$FO-5@VW#Q;+0r6Pyafa^yfF<-F(ZKQ;J6;DFCi){7Wzn$`z(O`AO^xf%6AYSJ+LD!RnMzNx(vzv|Nmll3j&5B~ReD$TS!;8~+LpAo zZS?=~OLs@N`zBJ>Nq|ynOlz;EH8t;iHEVBKoyc148LKmCb!M#Yq}83Vb|(QfH+WY9 zNWxW}2x$3})Aw4p2QMd1%%lJ6huLKUDYRmn{mOT z&XqM(Ck^L5k@C-g%t5GT$DWcZv3{bZw6WaaRMaX=qAl9XuBF zJYYeyMX{hMa|;~DX>8q)Wm*T4tpl0X)5+G;sfMAo@oe{rHTWi8((K804ua2?G$TT^ zx$~WuzW37Rg;dkYwaILQ>z&izJH6>nHF(#?A=c_x?@L=+v#s4f(*Hoe`L(;RrCOi6 zqXYct9W7u-t%w~3n*Ow_gqLdj%FGs6OEJi&Sn~}Bpa@z8m`_^?b;hN_TdEl zZykpIscb^jsPzQkDy@!0!?CpG*si_#?a8+$6Yb|y_6r&NM8ZCCulu8_+P9j1zXkA$ zl}!lnw0cR3^{=EYh{1$vJP*VBmzIVNZKm}^vh_r!_2e&GPm0l(NV9E4+@-buqso?z z$*mjdjxoSpiX)&Q_C~;5wpIb=(q6@5F2@0FX-BlB9nqGK){U=hjc$8J(zPQy!_Q?K zJN{ZKIY-y8Yd4 zleX?H_1IgNfE%!ZVs&*!!weN_~ zot0afl>OZ5#BOQTx+`7k$vRw{woTo(D`+}1Sh!g^EH zo~&xmRCOk+Ix|(h$*SIT-^F`pQ&m#{`Q#f6xX`UjX~XEBiNTbY04(_=XlSwS-5K?X z;G$#u;G#Fv)^kbi2*M?+)773$U%L8a%5Vy}KtKk*lsNTV+R7xgmw_P)h|wZ_aAd^j zwx;cnUYJTecR5uxvwCS4kqrp^%NUxIhUSc+Eoo@O$!fTeF-#;3Fj=vZcEB?@PG=3a zJN`^{Z=$+)QwEEqLl28!m45XiaJ#{_-j*)y06-#_-+JBpi)llb(9Jt$_m-x}xbJW< zQ)Bd10NM)pfxpxKJsSRXTT=@|RaX9Z2d3MHQie01 zP;{AR51p*Z4QrjQ8m4r0UB>Q8+5v6cowRpn?8lP!W3U9XDf>l09M^UydWP>_16+Ql z@-p-*dV?)LAYU!MWQo7SHE$&v`fJ5(IR>uAh4x{{8r zjN@3+aV+CFk#wBcI(@G@<(OR4A(;28sfLkE^~e*!ylXmOQl7i=VDHbyT?No?%VWnq zp9)Rd)8KP|CDY)*IAD4P(8T+v3{6_+kbAjgh{f^W$5-}K_5uy#X8vs(h(Uv{JE+h z+>a=Fu~GIBJu(OnAD^TbTVx-fI*T!X+1IpKBmJu?Dfs`Ynnr&Oru(Z#8r>Fj*?$YI zW8VVDS#tRC#vdZlAEARXaM9C_U`3|L0Yp517{yH`o&t5>mr3M+ZMVaj&XZ9Yp6NuF zI|GSHoMkL3R^n>%iGCOU@CEN*4lIY`O7dxbcUN4S_i!Y_6CqM?-|vrWxH}=qNy0sG zWw?iT+~ta}!CRr=t3c7kQE+Kpu@&}uI8J^rpf|3={CeQm0!S#F|3sBi zTrc_=0XRz*FZ`@PC-<`gakD^?4{^a4h~icuH2?DhLM19hF_0r4?$oeOEYQe(5{LVh zf_x3lqA;O`ahvcX6rTJ~8DJdua|ZFs$0PYDYmunMKl~!|{08!C!4S7B}RBNE`5X9N0GONb5mL^6L(L zUA=MsmmhlICm-^^{s3{kU7g+C@ydKEjN>vCqAG+aQX3416azv*J>twrj73OM$f~oa z^SD#Sq7foq!TqEK*U4vvpRtIS7sC5R!2&sUcp)6-atG#?|H+DDyr&PQ2>B6b z#!L8z+c3Qf;kSYD^H1bwGU6uk=<4ad?&|FA>cwB!xIr{ZP9=->^_+sRMo*moWsI(z zGC#cNA)_1D3&RLs&Vo;>qiM+5N{&zC;rEwUV3NV%cNUfp_&C{8cwqrPua#57H{4eu zv6b*jl)Z>Cx<&X69#FGzzrmANw(JXoPnNMka;!aP-5()*?J3I2F|V5a(Tl;Q2z*q_ z&3!v5%ofAh!cWuSPdN~c3kQTj1x!v3+kIeXqM*@30H>wFbGx|`_zKoIv<8~|9gHFB zBBIVI=$ZuO68rBliF%lPHw}+PlY{l-6nRdQSHO{AvfO?j^ZP?|`Z1a6B!?h4&F0{d zhQH|l0C!JH)AR?F`@d54e?jU0g0g-{wSGvod`KPtL?Wjp4=8Xx(aUJbuPAUnQS)Jd z8PymwYTGZ>wmYLKb<^ug5TfMT)vH;Vc6AY*a8_nm>&VJV6JU_PNSVRn+_J$vPva!GxNALOw|QB__EWMh!LuFdDthc#FrSvj!& z9+HO(^I$NK0j$PFNPNc#)i+9B$HS#^-F?bfs@Q8LJ|*}Jl)UUdr8Oxq)^H!XO0j2! zYTl>hdIfZyT$@#y_M{ScP*HOAeM+q*1g86xs$2nI)sb5{f-1${P01H%Gc?q&cMBvH zJ9r){&MRp39e1XxCt20Ab^fm@e16DiX?h@GZiY{f&;t)u&&z4`x;s;UGFgA}e^dBG HxZ(c--Dw~) diff --git a/custom_components/ai_agent_ha/__pycache__/const.cpython-314.pyc b/custom_components/ai_agent_ha/__pycache__/const.cpython-314.pyc deleted file mode 100644 index f588cbbfd0018dadea4e6d5c953a50915db6d982..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1065 zcmZWo?P}Uk7(VeMR!#k=T8nJVwJ_EWbc6jIgHuh{(3r?++R-sMq&Br#V~!+8M*GVf z*#+!wWWU_`RoD&4dILN8sI@F$$oss{S2*!aVLb=<^~3!7ZIA-sPcS@OiTL6Els`-X z0u&&D3MfGnC`pqjMN=qE(@3Nu%Fqm2qiZNjvnWS%C{Oc9q7quC>!?5rs7Q+~fGWB` zOQ=lCs6;o=#tzu6-tKjX>r>OEe)E!eO?qWDq0)py%cadj*tA`04Ly_E#BH&>X=5|k z;4715%(0EHts7SOZkb%>VZN~G4a+-@IWjRN*Otp9GPYbUi>6{cotwUeC!WKkp*6DI zhjl4fdt^enSuEhit~@ff2QiR0oyTqVcIjMl$iQ?kiLwLhjVH*6Tv!gP#rXp=8uNEL zccK>Ah#IKGh{42A;A^{CmJU6=GCu_-MtxcCu_?kK*Bp?1+8W=mwGaH{Cyw>e zd&lJh|M|E4wnz$s@Eg3n%Wh7^d2wqh%{O1oKxH9<>dP4@&8yopP`*pm7D*t~I9l7` z^L%S>25R&A>lvudE6spcel{({aHBN?_4)JnGtda(zhbVGm`aNVDAuQW?oog73sk%| GkN*cN9bRAn diff --git a/custom_components/ai_agent_ha/agent.py b/custom_components/ai_agent_ha/agent.py deleted file mode 100644 index bea5e72..0000000 --- a/custom_components/ai_agent_ha/agent.py +++ /dev/null @@ -1,4415 +0,0 @@ -"""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 deleted file mode 100644 index 4207b94..0000000 --- a/custom_components/ai_agent_ha/config_flow.py +++ /dev/null @@ -1,917 +0,0 @@ -"""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 deleted file mode 100644 index ab89f0c..0000000 --- a/custom_components/ai_agent_ha/const.py +++ /dev/null @@ -1,40 +0,0 @@ -"""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 deleted file mode 100644 index 14b2947..0000000 --- a/custom_components/ai_agent_ha/dashboard_templates.py +++ /dev/null @@ -1,390 +0,0 @@ -""" -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 deleted file mode 100644 index bd294c3..0000000 --- a/custom_components/ai_agent_ha/frontend.yaml +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 4fc23f7..0000000 --- a/custom_components/ai_agent_ha/frontend/ai_agent_ha-panel.js +++ /dev/null @@ -1,1479 +0,0 @@ -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 deleted file mode 100644 index a72874e..0000000 --- a/custom_components/ai_agent_ha/manifest.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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 deleted file mode 100644 index 32df89e..0000000 --- a/custom_components/ai_agent_ha/services.yaml +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index 1e1799a..0000000 --- a/custom_components/ai_agent_ha/strings.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "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 deleted file mode 100644 index 3468855..0000000 --- a/custom_components/ai_agent_ha/translations/ca.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "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 deleted file mode 100644 index bfd8cf2..0000000 --- a/custom_components/ai_agent_ha/translations/de.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "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 deleted file mode 100644 index 1e1799a..0000000 --- a/custom_components/ai_agent_ha/translations/en.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "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 deleted file mode 100644 index ab3ce1b..0000000 --- a/custom_components/ai_agent_ha/translations/en.txt +++ /dev/null @@ -1,81 +0,0 @@ -{ - "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 deleted file mode 100644 index dc214bb..0000000 --- a/custom_components/ai_agent_ha/translations/es.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "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/album_slideshow/__pycache__/__init__.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc index 7ffab779783718fd76eff8437a8ebf8ff08f92e8..2f08b75198dff10826fbbea73bd6f10f410c7bb4 100644 GIT binary patch delta 22 ccmaDio$=jtMs96BUM>b8I2M_-k^8YX08rEh(*OVf delta 22 ccmaDio$=jtMs96BUM>b8xKtCrk^8YX08#4({{R30 diff --git a/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc index 28f6562cb097dc2b015de1ebd18acd8d174da59f..c96fa01661dcdb04db0cde8fee167faf8b36fd0a 100644 GIT binary patch delta 20 acmcbpeo>uUn~#@^0SJyoCT-+CCJX>Ko&{n6 delta 20 acmcbpeo>uUn~#@^0SGSD#Bbz2CJX>LwFQm< diff --git a/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc index 2f876cd52bc21867bf3ff3c759cd83a4d9acffbe..570a7e1a2d29a0070e6c31cc24282bd94c3a46d9 100644 GIT binary patch delta 22 ccmbQ+#XPr*nOmEWmx}=ijzuPI6}c8F;Sl*!|$wpE{YGD#{eSR#e7zwl{^F z+MC18?JeP!c4yex-WqOgZwt4zw};!?JHj39o#D>*u5ed-ceuN~C*0HC8}4oI3-@I+ zoXWy*Y767ng@2FNqU^Az#CVWJcrW*erT7JWq=NonruZFLXq=)7&hjnJ;f`jfyQ4)g+?g@pax|}PCE|8;Hx2b*>C`(*cl0>g zobG{+`<+5Yi?h`+)IGS;%~hF}MBi!qZkLlA=<9WK3#K(01MLpZ*&-Md$=FCWyrRd^ zy}ER)?H*@K$579j(oT1`gKKk=9NF0v7uPr3(cLOd!08w}wb6c*a}KJpu7hIA(_txzSiY(Gg=H|hQW2}hY-Oqk`Eu;_sY$%E>u zh?SGUJj!uZ_}Q?6vvNtGE0%R7=t|IG<y`v7^g94w{4#2(+?R)63?ncMA|mqBPA}E zV|{&G6Q$m`!j4O0lKWT!S)MhE{Kc~bkp#^<#k zmmZhQC#5Go*O#H5v6x*0+1iC-$o_d!D&G%pBdoDbVwdtAq)=DJc9S8U zE$1^Pf&U!Td4_yRSHSj9e^2)X5$hnGhBEa*NKW+YDZ^Iw7`bfN3=8)!1_#?g8jb5B zILo{o;qSHoPuQ3Z04Fp{+Rk35I8)(ViV6KYV<~h5qar97e{dN*$uNqW{|cKam&t}E zhFK@;-OLbvXU6NMS(aGMMoVhE{HFUDW-N_iGGR7d8Q>+;$u~_Va>H&oWlksG$#alK zi&B#xFV3;GBGQ$=j?E`WEcKO%W;ppsQld;!xx5JBbT7jHYw-W&jba~bSCQP*=XAvb2PXplz}|y4*Shc2tr`=LOPe1>aGXlBvP~o_&APD}bRoq9%W>2l3VVZY-}tz1bRXY>>84fbL2NF_ z`$FTqlLXVu>3k~|F{~;AjnG$+8mePqM!xGuZq;C1pQvac4aS!&-mZ2BY= zfu20!VA|5yw?%B5T=KTK1m$wJO(}W10_97YY@0@M$%^u21>2^RTvk%KhHW!QF4v-b zC68@0Nv`BuP_Ad&ERri*n$)2DP{|@{pbhxYQ8Z9AQM4f9j#|e*frj~e3;j*fm}!TN z`gEW!R&^v zQ)w` zExe}zMaegFa6k(=8gb-BXq!KWa120D`vapMzk5%>KL#VY07-TekB1z;0|DJFuR$0B z5G1<;0YCpJ>gaO$HiWMus1O_oFCn~)K$rd+D-5F4H?x-#%4R6l#*pE3Q)(ZEDe*gFx%!v;rv_f_yb z40i)B56r_c-Eo@>&UwI9d{wu&b3KHkxXx>D7+u5(uI#JW_fmT z^)FBH7a�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 delta 5055 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 MCZ;{)bHG&p0gDHj{r~^~ diff --git a/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc index 94185bea183f426f92679a141d9b8383ef312b03..9c07ada2318f7d5093b5c5f70e70a70ff82da82d 100644 GIT binary patch delta 1252 zcmZWnO;g)e6n!$T@g%8J2V)!DmQI-_bsz=;2_$g{3YI^JZ8=x|z=mfNyLb=gFa-0s0QYbaLbya1 z=ptPTjp2Q|gv+piE3k+UUeOv_}uF+Mx7P8?Zx{8ls8K1xkhT#D|g@?EftN0Ao za04D850CLVJi$!}V+5X}0PE8jg=ZLp4UE$$jnTNdCPAaP1>W8X=j}oiUxEh#Vkkiz zvyi|XY+)W=V1WXaXqM(^o)$t5+=e6;A%!JK;|_?p3mK&F63YN6(;_X=9lA>?EmJu( zj<0AD6_Bt3S*$`1Ra&7{s+#LHTEV??7;9xecIz~a4JzVm3it-{*epA-1qEzF06XOu zxE~@=7diX|N~ps&P$)tfN+81yyn?A)XnglUyR|C&vPlu?Z50!r1+R1ONo-eeCKIp$(kT%1dzlu zpJbC)8d97GA;LT(`D{pvPg-X6?4#vRa!QCKa;Y!eNgiSX3tXLH29d=~HX)`Z7KjK@ zK9|g1+}lOke)#&IUvWzBzi+a0b?f#yy9~EyHXwo1Yh-5Gs!lR7^X}%*^W*uh{>iQDHhaU&^FMry Bl!E{O delta 542 zcmW-dOK(zf5XJu&3PoetR4qapV~olz<8Qx`vB(FYU`sun$Y9Uq}{r3 z(UABhTp2z=6XU|2ZcO|TZd~Zni<8XnWM+~%llfx2*S#U{#8qX-_qY4UcLEn)pV#eB z>Oh?h;j`}OktU6#F#;x>qu||*O|o%b68~_$ukzP#v)QI zNmg=_mx2_fWVpGBG|R}af^}B0!5XrxBgY2vY)V-wQk9z2r6EmYoLf>RP+$v1wo#%f zEoqBsAG9qk?pR6gT0ZVcnjOioD_QoiNwG{1EtI*B03TWv9vBMUhQ>#z@i8iBqKYll z0MyY!18v-b8FBMal%-y1w-vT|gdIM?E}vqL&tTqFdT&U_2W|Li?61?;wg0c#c^bBH zu6`*0H|78H+BLZJOdBUIr3HT51EstBWn21{5vn89MyQVvu%RFK&Ed5xe&_Jr#HCAX z4=R)G&}g@=^-Wt&M;@7e$L3E@^3}=Vo0mta{%5=VZ$3WwI{9mO@5Z!atY`QB0a`Yc A_5c6? diff --git a/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc index c9034e407ce6d570bae0f70afff3b18c220300f8..effad613db9395a427f7c36de30a255c25fb8bc3 100644 GIT binary patch delta 17011 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

_3y&a`xRYsEW% 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 delta 14007 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 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 263e773d17980d08b526adb1d8a18131249148ec..b10e41048c8ed215aaeb626ed8a566c82416fb74 100644 GIT binary patch delta 20 acmZ2ewx*0*n~#@^0SJyoCT-+av<3h?8U;B3 delta 20 acmZ2ewx*0*n~#@^0SGSD#BbzQv<3h@F$HA+ diff --git a/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc index 429a7ffddf978512a04fd41559d50834cef4275d..9dab212df0453ccafb5bbafd2b994ccbd0fd003c 100644 GIT binary patch delta 20 acmZq5Xv*N$=HumJ0D@zYNgKJp8v_6}iUpSd delta 20 acmZq5Xv*N$=HumJ0D?<3@f*3n8v_6~p#{SL diff --git a/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc index 70d0eb8dc11b8a98c08ed4ba566d85917d2b8173..4e7d519e9c8fad9a6afb0a72850cd1e907a3dd6d 100644 GIT binary patch delta 20 acmbQ@FvWpen~#@^0SJyoCT-;AQ~&@lrvz#M delta 20 acmbQ@FvWpen~#@^0SGSD#Bb#0Q~&@mz66#4 diff --git a/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc index 129713a8a3c86497168d0054a2c1aa32315cbcee..f18dd4d6e66b781b4f5c8cf103e9e947dccb8930 100644 GIT binary patch delta 22 ccmeB~!Pql{kz1ROmx}=ijzuPIa0IFV diff --git a/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc index eaa4092fefd065ab0ad7592bf90a3d510715476c..1181b204d70838dcbe6a1b6ccfb1182ad1a21173 100644 GIT binary patch delta 20 acmdn4v0Z~(n~#@^0SJyoCT--_5di=)vjmp_ delta 20 acmdn4v0Z~(n~#@^0SGSD#Bb!*5di=*$^^pz diff --git a/custom_components/album_slideshow/config_flow.py b/custom_components/album_slideshow/config_flow.py index 31cd371..9b7f9fb 100644 --- a/custom_components/album_slideshow/config_flow.py +++ b/custom_components/album_slideshow/config_flow.py @@ -54,6 +54,19 @@ from .const import ( DEFAULT_ICLOUD_IMAGE_SIZE, ICLOUD_IMAGE_FULL, ICLOUD_IMAGE_PREVIEW, + CONF_SYNOLOGY_URL, + CONF_SYNOLOGY_USERNAME, + CONF_SYNOLOGY_PASSWORD, + CONF_SYNOLOGY_DEVICE_ID, + CONF_SYNOLOGY_SPACE, + CONF_SYNOLOGY_ALBUM_ID, + CONF_SYNOLOGY_IMAGE_SIZE, + DEFAULT_SYNOLOGY_IMAGE_SIZE, + SYNOLOGY_SPACE_PERSONAL, + SYNOLOGY_SPACE_SHARED, + SYNOLOGY_IMAGE_SMALL, + SYNOLOGY_IMAGE_MEDIUM, + SYNOLOGY_IMAGE_LARGE, DEFAULT_REVERSE_GEOCODE, PROVIDER_GOOGLE_SHARED, PROVIDER_LOCAL_FOLDER, @@ -61,6 +74,7 @@ from .const import ( PROVIDER_IMMICH, PROVIDER_PHOTOPRISM, PROVIDER_ICLOUD, + PROVIDER_SYNOLOGY, DEFAULT_RECURSIVE, ) @@ -108,6 +122,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._pp_password: str | None = None self._pp_albums: dict[str, str] = {} self._pp_people: dict[str, str] = {} + # Synology flow state carried between steps. + self._syn_url: str | None = None + self._syn_username: str | None = None + self._syn_password: str | None = None + self._syn_device_id: str | None = None + self._syn_space: str = SYNOLOGY_SPACE_PERSONAL + self._syn_albums: dict[str, str] = {} @staticmethod @callback @@ -143,6 +164,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_photoprism() if self._provider == PROVIDER_ICLOUD: return await self.async_step_icloud() + if self._provider == PROVIDER_SYNOLOGY: + return await self.async_step_synology() return await self.async_step_google_shared() schema = vol.Schema( @@ -153,6 +176,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): PROVIDER_IMMICH: "Immich (direct API, full metadata)", PROVIDER_PHOTOPRISM: "PhotoPrism (direct API, full metadata)", PROVIDER_ICLOUD: "iCloud Shared Album", + PROVIDER_SYNOLOGY: "Synology Photos (direct API, full metadata)", PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)", }) } @@ -651,6 +675,140 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="icloud", data_schema=schema, errors=errors ) + async def async_step_synology( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Collect the Synology URL + credentials (and optional 2FA code).""" + errors: dict[str, str] = {} + + if user_input is not None: + url = user_input[CONF_SYNOLOGY_URL].strip() + username = user_input[CONF_SYNOLOGY_USERNAME].strip() + password = user_input.get(CONF_SYNOLOGY_PASSWORD) or "" + space = user_input.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL) + otp = (user_input.get("otp_code") or "").strip() + + from . import synology as syn_api + + client = syn_api.SynologyClient( + self.hass, + url, + username=username, + password=password, + space=space, + ) + try: + await client.async_login(otp_code=otp or None) + albums = await client.async_list_albums() + except syn_api.SynologyOtpRequired: + errors["otp_code"] = "synology_otp_required" + except Exception: # noqa: BLE001 - any failure means bad URL/creds + errors["base"] = "synology_cannot_connect" + else: + self._syn_url = client.base_url + self._syn_username = username + self._syn_password = password + self._syn_space = space + # A trusted-device token is captured only on the OTP login; + # store it so future logins skip the 2FA prompt. + self._syn_device_id = client.captured_device_id + self._syn_albums = { + str(a["id"]): (a.get("name") or str(a["id"])) + for a in albums + if a.get("id") is not None + } + await client.async_logout() + return await self.async_step_synology_select() + + schema = vol.Schema( + { + vol.Required(CONF_SYNOLOGY_URL): str, + vol.Required(CONF_SYNOLOGY_USERNAME): str, + vol.Required(CONF_SYNOLOGY_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required( + CONF_SYNOLOGY_SPACE, default=SYNOLOGY_SPACE_PERSONAL + ): vol.In( + { + SYNOLOGY_SPACE_PERSONAL: "Personal (My Photos)", + SYNOLOGY_SPACE_SHARED: "Shared Space", + } + ), + vol.Optional("otp_code"): str, + } + ) + return self.async_show_form( + step_id="synology", data_schema=schema, errors=errors + ) + + async def async_step_synology_select( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Pick a Synology album (or the whole space) and image size.""" + errors: dict[str, str] = {} + + if user_input is not None: + name = user_input[CONF_ALBUM_NAME].strip() + size = user_input.get( + CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE + ) + album_id = user_input.get("album") + if album_id in (None, "", "__all__") or album_id not in self._syn_albums: + album_id = "" + + unique = ( + f"{DOMAIN}:{PROVIDER_SYNOLOGY}:{self._syn_url}:" + f"{self._syn_space}:{album_id or 'all'}" + ) + await self.async_set_unique_id(unique) + self._abort_if_unique_id_configured() + data = { + CONF_PROVIDER: PROVIDER_SYNOLOGY, + CONF_SYNOLOGY_URL: self._syn_url, + CONF_SYNOLOGY_USERNAME: self._syn_username, + CONF_SYNOLOGY_PASSWORD: self._syn_password, + CONF_SYNOLOGY_SPACE: self._syn_space, + CONF_SYNOLOGY_IMAGE_SIZE: size, + CONF_ALBUM_NAME: name, + } + if album_id: + data[CONF_SYNOLOGY_ALBUM_ID] = album_id + if self._syn_device_id: + data[CONF_SYNOLOGY_DEVICE_ID] = self._syn_device_id + return self.async_create_entry(title=name, data=data) + + album_options = [ + selector.SelectOptionDict(value="__all__", label="All photos in this space") + ] + [ + selector.SelectOptionDict(value=aid, label=aname) + for aid, aname in self._syn_albums.items() + ] + schema = vol.Schema( + { + vol.Required(CONF_ALBUM_NAME): str, + vol.Optional("album", default="__all__"): selector.SelectSelector( + selector.SelectSelectorConfig( + options=album_options, + mode=selector.SelectSelectorMode.DROPDOWN, + custom_value=False, + ) + ), + vol.Optional( + CONF_SYNOLOGY_IMAGE_SIZE, default=DEFAULT_SYNOLOGY_IMAGE_SIZE + ): vol.In( + { + SYNOLOGY_IMAGE_LARGE: "Large (best for slideshow)", + SYNOLOGY_IMAGE_MEDIUM: "Medium", + SYNOLOGY_IMAGE_SMALL: "Small (thumbnail, fastest)", + } + ), + } + ) + return self.async_show_form( + step_id="synology_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 02db829..84cadc0 100644 --- a/custom_components/album_slideshow/const.py +++ b/custom_components/album_slideshow/const.py @@ -57,6 +57,34 @@ PROVIDER_MEDIA_SOURCE = "media_source" PROVIDER_IMMICH = "immich" PROVIDER_PHOTOPRISM = "photoprism" PROVIDER_ICLOUD = "icloud" +PROVIDER_SYNOLOGY = "synology" + +# Synology Photos (direct API) provider. Talks to a DSM Photos package over its +# entry.cgi web API. The account password is stored so the coordinator can +# re-authenticate when the session id expires; accounts with 2FA are handled by +# capturing a trusted-device token during setup (see synology.py). +CONF_SYNOLOGY_URL = "synology_url" +CONF_SYNOLOGY_USERNAME = "synology_username" +CONF_SYNOLOGY_PASSWORD = "synology_password" +CONF_SYNOLOGY_DEVICE_ID = "synology_device_id" +CONF_SYNOLOGY_SPACE = "synology_space" +CONF_SYNOLOGY_ALBUM_ID = "synology_album_id" +CONF_SYNOLOGY_IMAGE_SIZE = "synology_image_size" + +# Personal ("My Photos") vs shared ("Shared Space") library. +SYNOLOGY_SPACE_PERSONAL = "personal" +SYNOLOGY_SPACE_SHARED = "shared" + +# Native Synology thumbnail sizes. ``xl`` is the largest (best for a slideshow). +SYNOLOGY_IMAGE_SMALL = "sm" +SYNOLOGY_IMAGE_MEDIUM = "m" +SYNOLOGY_IMAGE_LARGE = "xl" +SYNOLOGY_IMAGE_SIZE_OPTIONS = [ + SYNOLOGY_IMAGE_SMALL, + SYNOLOGY_IMAGE_MEDIUM, + SYNOLOGY_IMAGE_LARGE, +] +DEFAULT_SYNOLOGY_IMAGE_SIZE = SYNOLOGY_IMAGE_LARGE # iCloud Shared Album provider. The share token in the pasted link is the only # credential; no account or password is involved. diff --git a/custom_components/album_slideshow/coordinator.py b/custom_components/album_slideshow/coordinator.py index 6712cbe..b5e5a7b 100644 --- a/custom_components/album_slideshow/coordinator.py +++ b/custom_components/album_slideshow/coordinator.py @@ -44,6 +44,15 @@ from .const import ( CONF_ICLOUD_TOKEN, CONF_ICLOUD_IMAGE_SIZE, DEFAULT_ICLOUD_IMAGE_SIZE, + CONF_SYNOLOGY_URL, + CONF_SYNOLOGY_USERNAME, + CONF_SYNOLOGY_PASSWORD, + CONF_SYNOLOGY_DEVICE_ID, + CONF_SYNOLOGY_SPACE, + CONF_SYNOLOGY_ALBUM_ID, + CONF_SYNOLOGY_IMAGE_SIZE, + DEFAULT_SYNOLOGY_IMAGE_SIZE, + SYNOLOGY_SPACE_PERSONAL, DEFAULT_REVERSE_GEOCODE, DOMAIN, PROVIDER_GOOGLE_SHARED, @@ -52,6 +61,7 @@ from .const import ( PROVIDER_IMMICH, PROVIDER_PHOTOPRISM, PROVIDER_ICLOUD, + PROVIDER_SYNOLOGY, ) from .store import SlideshowStore @@ -939,6 +949,8 @@ class AlbumCoordinator(DataUpdateCoordinator): data = await self._update_photoprism() elif self.provider == PROVIDER_ICLOUD: data = await self._update_icloud() + elif self.provider == PROVIDER_SYNOLOGY: + data = await self._update_synology() else: raise UpdateFailed(f"Unsupported provider: {self.provider}") except UpdateFailed: @@ -1512,6 +1524,87 @@ class AlbumCoordinator(DataUpdateCoordinator): "items": items, } + async def _update_synology(self) -> dict[str, Any]: + """Fetch photos from a Synology Photos library via its web API. + + Metadata (capture date, GPS, address, description) is returned inline + with each item, so every ``MediaItem`` is built fully here - there is + no background enrichment pass. Thumbnail URLs carry no SID; the session + cookie is stored on the coordinator and sent server-side by the camera + (like the Immich x-api-key), so the SID never reaches the browser. + """ + from . import synology as syn_api + + url = self.entry.data.get(CONF_SYNOLOGY_URL) + username = self.entry.data.get(CONF_SYNOLOGY_USERNAME) + password = self.entry.data.get(CONF_SYNOLOGY_PASSWORD) + device_id = self.entry.data.get(CONF_SYNOLOGY_DEVICE_ID) + space = self.entry.data.get(CONF_SYNOLOGY_SPACE, SYNOLOGY_SPACE_PERSONAL) + album_id = self.entry.data.get(CONF_SYNOLOGY_ALBUM_ID) + size = self.entry.data.get( + CONF_SYNOLOGY_IMAGE_SIZE, DEFAULT_SYNOLOGY_IMAGE_SIZE + ) + if not url or not username or not password: + raise UpdateFailed("Synology provider is missing URL or credentials") + + client = syn_api.SynologyClient( + self.hass, + url, + username=username, + password=password, + device_id=device_id, + space=space, + ) + try: + await client.async_login() + photos = await client.async_collect_assets(album_id or None) + except Exception as err: + raise UpdateFailed(f"Error querying Synology Photos: {err}") from err + + if not photos: + await client.async_logout() + raise UpdateFailed("No images found for the selected Synology source") + + # Store the session cookie so the camera can fetch thumbnail bytes + # server-side. Do not log out: the SID must stay valid until the next + # refresh re-authenticates. + self.image_request_headers = dict(client.image_headers) + + items: list[MediaItem] = [] + for p in photos: + ref = syn_api.thumbnail_ref(p) + if not ref: + continue + unit_id, cache_key = ref + meta = syn_api.parse_photo_meta(p) + items.append( + MediaItem( + url=syn_api.build_thumbnail_url( + client.base_url, unit_id, cache_key, size, space + ), + width=meta.get("width"), + height=meta.get("height"), + mime_type=None, + filename=p.get("filename"), + captured_at=meta.get("captured_at"), + byte_size=meta.get("byte_size"), + latitude=meta.get("latitude"), + longitude=meta.get("longitude"), + location=meta.get("location"), + description=meta.get("description"), + source_id=str(p.get("id")) if p.get("id") is not None else None, + exif_scanned=True, + ) + ) + + if not items: + raise UpdateFailed("Could not resolve any Synology images") + + 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 3623459..e3d5ebf 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.4.0" + "version": "1.5.0" } diff --git a/custom_components/album_slideshow/strings.json b/custom_components/album_slideshow/strings.json index 9d66110..3f42f14 100644 --- a/custom_components/album_slideshow/strings.json +++ b/custom_components/album_slideshow/strings.json @@ -84,6 +84,26 @@ "icloud_url": "Shared album link", "icloud_image_size": "Image quality" } + }, + "synology": { + "title": "Synology Photos", + "description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.", + "data": { + "synology_url": "DSM URL", + "synology_username": "Username", + "synology_password": "Password", + "synology_space": "Library", + "otp_code": "2FA code (only if enabled)" + } + }, + "synology_select": { + "title": "Synology source", + "description": "Pick an album or show the whole library, and choose the image quality.", + "data": { + "album_name": "Album name", + "album": "Album", + "synology_image_size": "Image quality" + } } }, "error": { @@ -100,7 +120,9 @@ "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.", "invalid_icloud_url": "That does not look like an iCloud Shared Album link.", - "icloud_cannot_connect": "Could not reach that album. Check the link is a current public share." + "icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.", + "synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.", + "synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue." } }, "options": { diff --git a/custom_components/album_slideshow/translations/en.json b/custom_components/album_slideshow/translations/en.json index 9d66110..3f42f14 100644 --- a/custom_components/album_slideshow/translations/en.json +++ b/custom_components/album_slideshow/translations/en.json @@ -84,6 +84,26 @@ "icloud_url": "Shared album link", "icloud_image_size": "Image quality" } + }, + "synology": { + "title": "Synology Photos", + "description": "Connect to the Photos package on your Synology NAS. Enter the DSM address (e.g. http://192.168.1.10:5000 or your HTTPS/QuickConnect URL) and an account. If the account has two-factor authentication, also enter a current 6-digit code once; a trusted-device token is then stored so future refreshes do not need a code. Tip: use a dedicated read-only Photos account rather than an admin login.", + "data": { + "synology_url": "DSM URL", + "synology_username": "Username", + "synology_password": "Password", + "synology_space": "Library", + "otp_code": "2FA code (only if enabled)" + } + }, + "synology_select": { + "title": "Synology source", + "description": "Pick an album or show the whole library, and choose the image quality.", + "data": { + "album_name": "Album name", + "album": "Album", + "synology_image_size": "Image quality" + } } }, "error": { @@ -100,7 +120,9 @@ "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.", "invalid_icloud_url": "That does not look like an iCloud Shared Album link.", - "icloud_cannot_connect": "Could not reach that album. Check the link is a current public share." + "icloud_cannot_connect": "Could not reach that album. Check the link is a current public share.", + "synology_cannot_connect": "Could not connect to Synology. Check the URL, username and password.", + "synology_otp_required": "This account needs a two-factor code. Enter a current 6-digit code to continue." } }, "options": { diff --git a/custom_components/album_slideshow/www/album-slideshow-card.js b/custom_components/album_slideshow/www/album-slideshow-card.js index 6bd3a01..b2f5000 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.4.0"; +const VERSION = "1.5.0"; const ANIMATED_TRANSITIONS = [ "fade", diff --git a/custom_components/alexa_media/__pycache__/__init__.cpython-314.pyc b/custom_components/alexa_media/__pycache__/__init__.cpython-314.pyc index d81640fbe1664c999a6d53388d24020930df8287..4fc6f84e15c07bad90baf4b8f4beba47676ff983 100644 GIT binary patch delta 28 icmezOjpN@p4sLBeUM>b8xEh(%$lc1#xRsl!*ct$kZwPS! delta 28 icmezOjpN@p4sLBeUM>b8*l!To$lc1#xRsl!*ct$h0thJp diff --git a/custom_components/alexa_media/__pycache__/alarm_control_panel.cpython-314.pyc b/custom_components/alexa_media/__pycache__/alarm_control_panel.cpython-314.pyc index 5f1f2adb78018f6f9e22313bd7c7251bc0e36311..ba54f625b69e13a99784f11c58f99e7be05637dc 100644 GIT binary patch delta 20 acmcbfc|DU`n~#@^0SK-{CT-+CV*&s|Sq3Kn delta 20 acmcbfc|DU`n~#@^0SNXRL~i6hV*&s`qXpmq diff --git a/custom_components/alexa_media/__pycache__/alexa_entity.cpython-314.pyc b/custom_components/alexa_media/__pycache__/alexa_entity.cpython-314.pyc index e25cacffea7b1b6b954e2814c1f16d643b184600..45b331a56026f091dce7155fe943f145666ffe01 100644 GIT binary patch delta 22 ccmdnb8xEh(Xk$Yn)09DNg9smFU delta 22 ccmdnb8*l!TIk$Yn)08|hL)&Kwi diff --git a/custom_components/alexa_media/__pycache__/alexa_media.cpython-314.pyc b/custom_components/alexa_media/__pycache__/alexa_media.cpython-314.pyc index 752cf2751d277fc7225f17dc1a2e62bb6f370404..9a541b8e4eed9e03d077a6eb5b08ed60290a762c 100644 GIT binary patch delta 20 acmew^^j(Nsn~#@^0SK-{CT---;RFCV@&zsc delta 20 acmew^^j(Nsn~#@^0SNXRL~i8H;RFCUJOt+e diff --git a/custom_components/alexa_media/__pycache__/binary_sensor.cpython-314.pyc b/custom_components/alexa_media/__pycache__/binary_sensor.cpython-314.pyc deleted file mode 100644 index 68459a9504328902250ecc84b359a51aa45afead..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6358 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 delta 1428 zcmZ`(e@s(X6n^KGmcClZU@Zl#VoebX3f&gQHWk6R8D~UilvMVrMOubfGPul%WJ2a- zo69z*=O2)?(jksy8=JiFYhpH&xj%4m7~))D74`=pE|?9LEOATRJ+H8tt$Rtod+v9? zbH4NLy|1s|)^vt7@mcY427y0cI`>w7K5#K!-;!Mj+6})7g#EiCBYTl6%0$vFJX$_# z1i?7(Q8u?!YH&EH$Ky3-d~nW!)r?wRXju0hC*35SR-M2z#Pk4unO`r3c zd&VtUQq%;;V!|%1g&MYF=v*GIy(JmQW&837#f>=?G0t4fSlE{T8fQi|R@rFAI@LH+ zHQxBzL&luq#+-_5!y0uP)~MvAPbExiGsoEYOJG-9ih5{)VoA9f@&jzaP3@JB?Bs{b zihu(hy(1Y1+w;zxZxiQ4<-_w1(Eni1{-o}v{V8R#BTodkvaGWpmJj>U_0j zrPV%1JZr$Ji4n+G3V)JgtUXksmqRW6HI%Q2Y)%X1W?{{U4M!&p@C;5)HfT?C@+?+N zmFqir;f9qKmS9xO4UiT?t8KJUN>IV|a1)&$KEdIGc-ZS#xh#bLRz`&1(cP P^B0lA-#qy3z1+V5UoVgH diff --git a/custom_components/alexa_media/__pycache__/const.cpython-314.pyc b/custom_components/alexa_media/__pycache__/const.cpython-314.pyc index cf00f4c7d6b7d4a3551ec9868dbe203c099f5e78..11065b00fd2f324b12b963a6c267d8ae082f90d8 100644 GIT binary patch delta 20 acmcaveY2Wdn~#@^0SK-{CT-+CXA1yDItE$* delta 20 acmcaveY2Wdn~#@^0SNXRL~i6hXA1yBga#7; diff --git a/custom_components/alexa_media/__pycache__/coordinator.cpython-314.pyc b/custom_components/alexa_media/__pycache__/coordinator.cpython-314.pyc index a51f5cdf5a08aa5ba6ea683e444b692d431ab336..500e665d0cb8b5f153da8e3ffbef7f7531d64be4 100644 GIT binary patch delta 20 acmdlbw@Z#&n~#@^0SK-}CT--_=LY~XCj^!N delta 20 acmdlbw@Z#&n~#@^0SNXRL~i8P=LY~VY6MOI diff --git a/custom_components/alexa_media/__pycache__/diagnostics.cpython-314.pyc b/custom_components/alexa_media/__pycache__/diagnostics.cpython-314.pyc index e8cd81ad3e6ba808ba88cbe0eabbf79fb429f2be..240fd97d5a669109262bd923062d4e3845d2b0b0 100644 GIT binary patch delta 22 ccmeC1#n?BCkz1ROmx}=iu0$qnieNdN!< delta 22 ccmeC1#n?BCkz1ROmx}=i_8UZQb8xDuJPky|$d08j7+P5=M^ delta 22 ccmdmSfpNzLMs96BUM>b8*l!TIky|$d08Tmv2LJ#7 diff --git a/custom_components/alexa_media/__pycache__/media_player.cpython-314.pyc b/custom_components/alexa_media/__pycache__/media_player.cpython-314.pyc index 0e3296302459767e81586f4b09734b88aea4e5a1..6f7f7274c901c62fd74e36c4340958b0a6edb031 100644 GIT binary patch delta 25 ecmeBcWb12WtOx)!&;WzT-gDk_HWrjaLD^I= z+cYade8%@Xs!sQqc2}tp#pdxI=jBzk3*aEWJ(a6gK|%sM!j1L204jKC!w^6zW;Ygs z6L&N=fCEE~8ZbWH*q@{}2yUbRtxefHe0Dk!S}=6JP(4mwtL|OWT5kY1UTa$^ViRp% zU|4*yO+7)3wK~Ia>7a}ehyis`1sZ)xWkis`z5%9_xm*HiS$hZ$5;zI;zG&i%2F&ysCi|-G)sij810oz|$mvIsDPHeK22ttX?Xf7GY+0)`ymYpW8zU z@yefXksuAb#;wM=BtMTJhrb)UcJQc2C24j0q)ZC^i%BKjp36k=e4thKE*%PsG$ei$wltswk! z@>g1+_61Rkn-yuLEv&S%G|Oga)~G*yLI!>O=$?0)4)*NabDs0u^W1ya-zz`wl?@AL z8RBBZ=kBuYCC9EB3QA&GKE%Xucgdj?D8fiprs@%ER>I?}^8wu4R&y7i7&B^@f&;hI z)_@%cYAsO4f7iC_pc;FEX8yYVS1i=yz=@^m0nr@J!M=F?6RY6r)?Ltq`8!cR74sr=P8*wT`V6Kdc$gG-pB(L(OJ$kot7Yx8LjZ zc-?+Qn6Q{kCRQv0cAARWnrK$bYDul2?mF%`Ym}gaSDmj?)LWFQK;5Oq=3)_ACDu)y z$3^UyNX!zIY8Q#{0!`|`rA3OtM`W2L5V;<&1k)hEdxAIRNx`Gna^}oBUEbxJQLYE~`Ozq~-fseaa z>pv0YGr<=$JUASPP<$&X z2`e~nr`bc}NNQ55phWk#Oza=vxg)C}*+{*KLd!3{F%$2&1#z@%MHZD> zM0&bKmOYte*8iGQo{qfTIqB)^TWi9UC(@a4f}6*@D*ZKt@y@XTB=HUJ7E6%Fy%8t; E11>32MgRZ+ diff --git a/custom_components/alexa_media/__pycache__/services.cpython-314.pyc b/custom_components/alexa_media/__pycache__/services.cpython-314.pyc index a41f6aa814909abbf2d8070e5fe37b9c1eee3183..aa871fe038f879222f9f78cda4f16e6d1d6decfc 100644 GIT binary patch delta 22 ccmcaOlkwtAMs96BUM>b8xDuJPk^7hr08FL^j{pDw delta 22 ccmcaOlkwtAMs96BUM>b8*l!TIk^7hr07~!%NB{r; diff --git a/custom_components/alexa_media/__pycache__/switch.cpython-314.pyc b/custom_components/alexa_media/__pycache__/switch.cpython-314.pyc index 8fd4063a4621531b175476f64d9c74c181be0415..c3fd8f86461d2540e601dd230551378282012c63 100644 GIT binary patch delta 22 ccmZqQ#n`@!kz1ROmx}=iu0|$p=1.14.0", "dictor>=0.1.12,<0.2" ], - "version": "5.15.6" + "version": "5.15.7" } diff --git a/custom_components/alexa_media/sensor.py b/custom_components/alexa_media/sensor.py index 5e0d2f4..8a8cffd 100644 --- a/custom_components/alexa_media/sensor.py +++ b/custom_components/alexa_media/sensor.py @@ -664,7 +664,7 @@ class AlexaMediaNotificationSensor(SensorEntity): next_item["snoozedToTime"] = None return value - def _is_active_notification(self, item, now): + def _is_active_notification(self, item, _now): """Return whether a notification should be considered active.""" status = item[1].get("status") if status == "ON": diff --git a/custom_components/alexa_media/translations/fr.json b/custom_components/alexa_media/translations/fr.json index caf325e..2dfb83b 100644 --- a/custom_components/alexa_media/translations/fr.json +++ b/custom_components/alexa_media/translations/fr.json @@ -3,32 +3,32 @@ "abort": { "forgot_password": "La page de réinitialisation du mot de passe a été détectée. Cela résulte généralement de trop nombreuses tentatives de connexion échouées. Amazon peut exiger une action avant qu'une nouvelle connexion ne puisse être tentée.", "login_failed": "Alexa Media Player n'a pas réussi à se connecter.", - "reauth_successful": "Alexa Media Player s'est ré-authentifié avec succès. Veuillez ignorer le message \"Abandonné\" de Home Assistant." + "reauth_successful": "Alexa Media Player s'est ré-authentifié avec succès. Veuillez ignorer le message « Abandonné » de Home Assistant." }, "error": { "2fa_key_invalid": "{otp_secret} n'est pas valide", - "connection_error": "Erreur de connexion ; vérifiez le réseau et réessayez", + "connection_error": "Erreur de connexion ; vérifiez le réseau et réessayez", "identifier_exists": "L'adresse e-mail pour cette URL Alexa est déjà enregistrée", "invalid_auth": "La connexion a échoué. Veuillez vérifier votre adresse e-mail, votre mot de passe et votre clé d'authentification.", "invalid_credentials": "Identifiants invalides", - "invalid_url": "L'URL n'est pas valide: {message}", + "invalid_url": "L'URL n'est pas valide : {message}", "oauth_error": "Impossible de terminer la connexion OAuth. Veuillez réessayer.", "unable_to_connect_hass_url": "Impossible de se connecter à l'URL locale de Home Assistant. Veuillez vérifier l'URL sous Paramètres > Système > Réseau > URL de Home Assistant > Réseau local", - "unknown_error": "Erreur inconnue : {message}" + "unknown_error": "Erreur inconnue : {message}" }, "step": { "proxy_warning": { "data": { "proxy_warning": "Ignorer et continuer - Je comprends qu'aucune assistance pour les problèmes de connexion ne sera fournie si je contourne cet avertissement." }, - "description": "Le serveur Home Assistant ne peut pas se connecter à l'URL fournie : {hass_url}.\n> {error}\n\nPour résoudre ce problème, veuillez confirmer que votre navigateur peut atteindre {hass_url}. Ce champ provient de Paramètres > Système > Réseau > URL de Home Assistant.\n\nSi vous êtes **certain** que votre navigateur peut accéder à cette URL, vous pouvez ignorer cet avertissement.", + "description": "Le serveur Home Assistant ne peut pas se connecter à l'URL fournie : {hass_url}.\n> {error}\n\nPour résoudre ce problème, veuillez confirmer que votre navigateur peut atteindre {hass_url}. Ce champ provient de Paramètres > Système > Réseau > URL de Home Assistant.\n\nSi vous êtes **certain** que votre navigateur peut accéder à cette URL, vous pouvez ignorer cet avertissement.", "title": "Alexa Media Player - Impossible de se connecter à l'URL de HA" }, "totp_register": { "data": { "registered": "Oui, le code OTP a été vérifié" }, - "description": "**{email} - alexa.{url}**\nAvez-vous vérifié le code OTP dans la validation en deux étapes Amazon ?\n> Code OTP : {message}", + "description": "**{email} - alexa.{url}**\nAvez-vous vérifié le code OTP dans la validation en deux étapes Amazon ?\n> Code OTP : {message}", "title": "Alexa Media Player - Confirmation OTP" }, "user": { @@ -46,10 +46,10 @@ "scan_interval": "Intervalle d'interrogation programmé (secondes)", "securitycode": "Mot de passe à usage unique (OTP)", "should_get_network": "Découvrir le réseau Alexa", - "url": "Domaine de la région Amazon (ex : amazon.fr)" + "url": "Domaine de la région Amazon (ex : amazon.fr)" }, "data_description": { - "debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé. \n Non recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux. \n Assurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.", + "debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé.\nNon recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux.\nAssurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.", "otp_secret": "Exemple : 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA" } } @@ -58,7 +58,7 @@ "entity": { "sensor": { "air_quality": { - "name": "qualité de l'air" + "name": "Qualité de l'air" }, "air_quality_carbon_monoxide": { "name": "Monoxyde de carbone" @@ -67,7 +67,7 @@ "name": "Humidité" }, "air_quality_indoor_air_quality": { - "name": "qualité de l'air intérieur" + "name": "Qualité de l'air intérieur" }, "air_quality_particulate_matter": { "name": "Matières particulaires" @@ -82,7 +82,7 @@ "name": "Prochain rappel" }, "next_timer": { - "name": "La prochaine fois" + "name": "Prochain minuteur" }, "temperature": { "name": "Température" @@ -117,11 +117,11 @@ "otp_secret": "Clé d'authentification à 52 caractères pour Amazon 2SV", "public_url": "URL publique partagée avec les services externes hébergés", "queue_delay": "Délai pour regrouper plusieurs commandes (secondes)", - "scan_interval": "Fréquence d'interrogation programmée (secondes)", + "scan_interval": "Intervalle d'interrogation programmé (secondes)", "should_get_network": "Découvrir le réseau Alexa" }, "data_description": { - "debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé. \n Non recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux. \n Assurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.", + "debug": "Active une journalisation très détaillée, au niveau de la trace, pour un dépannage avancé.\nNon recommandé en fonctionnement normal en raison de l'augmentation du volume des journaux.\nAssurez-vous que le niveau de journalisation est défini sur DEBUG pour obtenir une sortie complète.", "otp_secret": "Exemple : 35T5 LQSY I5IO 3EFQ LGAJ I6YB JWBY JJPR PYT7 XPPW IDAK SQBJ CVXA" }, "description": "* Champs obligatoires", @@ -135,7 +135,7 @@ "fields": { "email": { "description": "Adresse e-mail du compte Alexa ou liste d'adresses (facultatif). Si vide, tous les comptes connus seront actualisés.", - "name": "Adresse email" + "name": "Adresse e-mail" } }, "name": "Activer la découverte du réseau" @@ -155,7 +155,7 @@ "fields": { "entity_id": { "description": "Entité pour laquelle obtenir l'historique", - "name": "Sélectionner le lecteur multimédia:" + "name": "Sélectionner le lecteur multimédia :" }, "entries": { "description": "Nombre d'entrées à récupérer", @@ -169,7 +169,7 @@ "fields": { "entity_id": { "description": "Entité sur laquelle restaurer le niveau de volume précédent", - "name": "Sélectionner le lecteur multimédia:" + "name": "Sélectionner le lecteur multimédia :" } }, "name": "Restaurer le volume précédent" diff --git a/custom_components/ha_mcp_tools/__init__.py b/custom_components/ha_mcp_tools/__init__.py index 6e6fcde..96eeafb 100644 --- a/custom_components/ha_mcp_tools/__init__.py +++ b/custom_components/ha_mcp_tools/__init__.py @@ -1000,7 +1000,8 @@ def _is_path_allowed_for_read( Allowed: - Files directly in config dir: configuration.yaml, automations.yaml, etc. - - Files in allowed directories: www/, themes/, custom_templates/ + - Files in allowed directories: www/, themes/, custom_templates/, + dashboards/, blueprints/ (blueprints is read-only — issue #1965) - Files matching patterns: /*.yaml, custom_components/**/*.py - User-configured extra directories (``extra_dirs``), granted read+write (issue #1567) 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 9c151faed08f72c534a5f60401a2a8f5d77e61b9..a5b6731752d035bea29102dbc4a71907566dd830 100644 GIT binary patch delta 1257 zcmYM!e@sG81X zb@d(`>G`l3gi?|N*?C2|1%*q3 z%R(uE#77~pBorvj&B_^`AIy6@aOvH0-4QCv4J150I(1Z%U31{FA}zd2;_RA3>5yzc zsI50ix9h(j?xe`^XqaU`XsC8;s!vUW0{iAqo7|Lz*Ee0$VR`cosIZ5e`OU$9ZV+s- zlh0Qen)-{maL`U{`AQRRYK_ofAN}Q|(v-DLflKzeD=8jL&yMwA+MisnG#C^f6{*wV zZe#)swo`9aYQoK(-!j}Ty?xNFnR#ymJa1p@?l#D?&;Ij-lR6%B!b)j@41>=k&BF>O z?=s1RZQ}E?&d^r)2*L?*`#J39f*g-wn9})nZ{iJlN}s;mQ1labfBK?x+yI_bjF3?S znPf0lmIp{g=PmK%D;7$xAygUKokQ6V>m@G0nG9i_Ew4~t!kN8J(S`lltjpYwwrD+^`1Cr$OcxN>png1-a6yxQaNxbIP_L=Og zZmASZVUD3alFmRysa%>)mxoipFzZ}kDfWoj^Uf~!sWLpk@o7yj5$A)6bcmutq#g;ptA6X}j`Ajyn{X%5G z*HXBQ6Q*|jN`0BX#J38=t9|?f)+#2-r)!vNFwy$`Bd(Gur$6Q>WLlqZ;5OM(Rzi#- z+xnuE8iJBhrl&yAdMd07xI+4uQ{+scw1Q!#_P}Op6)UV&mE7Z)EU%)(qcyh@uZWh! zY8Idi`Qdi%_RtpZWV@lFqpade23zShx~$o9xQ0crSQ7T|LJxDX{$xYbt-8$&kn-<1 zqg3|)!#CFI;k(6K=iGCzyjqX#G50#d>;Eg;=;O>(=5D7a*?O~_H*bX&FVPMYF(h<1(v`fIr}pUOl|6A zsxUKsj~;WcO+ThY3a&UWU4?Z0%2lOpZD%HwSR*^w##TAg!8+I>SvSZuwfj5Sqs*<& webSui##!M%SxdXb-DP#Gw#uivJ=Dsk7**%i&hMknKV~|Ar91OvT!Na-zhH{J(f|Me delta 1233 zcmX}saZFWJ9KiASJ@>uKgXen%xF7}XNlpz7Yz~zv%r-^IN+JrUs5qNL2ezziDb1;+ zPDR~=ZdL>5bhP9T=_0?>UWla=)KVvySSBf`sOw=UMYTHDw(cMIbAI>ycK18y{O-Nb z;;m@$`cnh`7?-}%$1PY^Rqr2lC^H8#dR@-_heroT3}=3^$aZ12f&PL$CS zfI0T`)>^mb?sv0bg+1}?$8L6n&R^)!LAb3BD(z(#Z(>%uU9Od-q2Zk~L%T1XqsD%t zqsq{Due=6fyX@!vn$VEW2q$gdFQ=5|;&okIr#-wo&7*no_EzxNKi;V}NDi%zH0xk$ zZx#gYHTSADp`m?;8Ed!qeeKp%{jm*l?UX+U43^u&{)yAk!SVbC@5)F;p}|M;o`-jw zxZh+h?2|k%n+@${A7#)YxqiNJ(;0C9m zYbRhRdSycf$I#|X!Ut~2pG>DnjJY0M$t(8fGRvSPc8exBbwoUwxW2HvLunZj@xr<2nqI zlp;>JwLQgTDwH*H2`88>cb4$CsqI;g4dqf@iaySAD|ZEF*d&RsGsDm>TuC9+$(L`^ zYHHW7*3%3~UO9$WTkt-c6xniRBLxQ8)`TtGpiq*wavXnQv~Hkhb*JBQt#w&*wvcF< z6`ZA5E>;k$D7J3xpn+1U-l=OcOP%zbP-k|6^m=J4Nd06szs1ZoGPVq1kzxNZm1Pk9N^X z$`tk1_pR*Wo|K$o4D?IrG)boRzp7p^6)-zFJ+8BjC*=O4M%h0BEps$J6_{*%* zyVh`-5?CO^exk_KuDM1Hs?cYhZPn|`?&58>Nn@AOft8YVgBvQU(x=z6%k|%SruUGJ zxy^Yr*$Q@%WLb8aQTzCk^6i$09Tg>R{#J2 delta 22 ccmcb0o$=OnMs96BUM>b8Fwl+L$bCK=08%0baR2}S 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 0c2eba47fc74a613cc1db1faa2bd68a2773d432a..1f37edc9cce313726c0ed1aa5160bd304d0c950a 100644 GIT binary patch delta 196 zcmbPhzu%run~#@^0SH|FBxZfgoXD5Um^raVQ#GqZD2P{&u|znQUy;FpAt*bS(S#AC zj)9>>1S*y@@zi9-+|6-}$&8G7n-4LqVq!GjoXZ-<#Q1&l5q1S2HTfy$LMA50$sJq| zfz;+~ZVq7*V=dy+|F}~Y;gk1qhO@7L`km)DGa}@WX#EUW3q}&2n!p7sK@3S8E0ky D$|ouT 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 76eee243dba0ae9b44967091ff9d601206b2d03c..db5f5f94178c027e0353b73abaa10302f63173f5 100644 GIT binary patch delta 20 acmZ2ry1 delta 20 acmZ2ry1z`PzXU7* 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 a4f54784861e15f19ca8ac3c8ea63deec86ee618..8c01c75224c42c5134e45a106c2838464bd73685 100644 GIT binary patch delta 22 ccmey@!}zad0(S+085+&q5uE@ 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 f58000eac4720ebc3352d25883e6c5b5a7ebc5aa..b60b7e51fe253fd91d0ab338256b8848e471e800 100644 GIT binary patch delta 4119 zcma)9U2Ggz8J)X7_IlP{+i{%4apJv+8{6x6H#l)p+xan0LJ}16qa`Q^oUZTg-t`VU zJG0!GwdJbny5*%s5mj;3QToyfMg5@yQ3SGz5NR49@KTWy;*#c}0`UOk!J&axZKybR zW;bz@K(XYzvv=-%_k8C&-?_8zd=hx_Ofa%J(xfW*+kcCFU&rRv zN^)SeqPN&n$vw$9z9r;0oyki@TkbAR`+@ADpA;}$>dC*J|h9v{(`vh zD@ySF0iS%L(|IgLqVsR~)UK$KB;5*CCIYp!bxL-FqO4O&%0%5fi>@0=`c5cAcuxLB zmGyX_q>L#?Lf)rFG-aoMy`qtcu(w80CK{cmJIT8FQ$Gu-Ey{s{qLhd?w|@TPU$?C| zL57I8miNi6CRR9K+l=*d7gq-4J%?>4E9(v)u0>k!Thx_R4TD6ixew+5*{syRLYH)H_bvSg$N~` zWq97yC1sjL4b!H~P+8hC^|H;WTs%N6lkPk2QE0OQ5Xf~g} zEKN?v67)nK6tOgPAzAtig&+Z8&#;Pwl^sB+oGG|!=aK4EnF&l~1heu0q#5y&Ff-h; zP`G3UW`ecAded;V;pcl zoBsqoDch|g1%rO!Mpm|J6|_2KRx=jd2EEm0MmAMlfUjIxM;mxpY_OFZt=2-9%t(30 zWwcqf+V*!E4YifK%2u%|Xj~qbt0^y;P%Fz$L$w@hpbK|hk=qRR!fJs8&j~J-k)Zpz z&Zkfcj|RvC-JZqXkV*k0S+tD0=5nZ@HVuOU{m{L^bW80sOmLJsT9F_mhU0*Ms%??8 z5>869&}*^}%>v@bTsS!f&X72@O*#WfN%6Ub1m%WAaa&E$|J}ZbSXq->37P~9DNI@anm12?2rIg{Z$Yk8#4lTmg^k!7U zG~%C9yk%>64y{yT;0!=i^+dkHp%__W=n3wIGJ5UDv!ieU(3vRbvL~dW*UU{?k31f5URAJr zRZz58wrpy6<=}DGe&C@fE39S!_0}?ZRql*OW?;;KZ9-69FJnH>^%57-24%=;ZZ4^P%W$k86QDDL>8`*?<^x+qmJ9Fip!y6%7s%mu8oG^=s zRaIr*vh%lgn4xQ`hmh24ta{MzbjvEc!qdWity1z|H66FZ+}h)rQM$=`_^stg?*-}! z`fj)^haUfbwsv=}xF)^ReNE7evPA0|HuAlrCMguGMjG&tzWcRBkCZcbU+ zOB#aewNUF~sQc|u_vO&0KZYV7UJJL~RO;k>^Ha@FHZS>nO=C-SN>j@R4Z#MyaAWv* z$&Upe-Ha%$?azF9v3=lD`@of!_!EI^;mBfm*Tr5!5?Ax-n=|JFt#^FQ3P zE|3gNv@JZ=PMDvl&Y#ziO+lAVr>BDq)t!>0o^~cXhzN9E4y<>sb&w6ASm3B#$_cuO+)f-$o(klnEl^&;RbB21oB9Tb<3FgcG0q4ROSoYsEItb&{0} zw>n9mFLD!49F3{&p5nT*wTm1kA3BeAk2Q8Q$@2%LhQxI_+>%3w9R7{r6(aiZVc}otafK+h$a`{dB*H_u0fcH5;rBO?ea)w% zapecm@fQF2W`a=?H9G&^k&v@zBYqE^b*zoR`#tx;|BY95oIw(iG`839u3a#?$xCFA zpf`jM!*Xc#5}F*&N@)8%Xf^V_w26#0pYGbMyc!*k_|G>Hj5TQW&byn)2>HoEdk+zW z+;pDkB|Au+bFP=HLP4(dl9RZZ=pzk;{Ayu1MxGDi^PL1aL&(yC_#7GYlP>4S+ejZ- za^BrW{(x_<4Uu7Auzun1L*zZ*FqL>$0Fm3-F~8`N6OvoeEwk|BR!@)D>mn|v$r5fhzoYiDQlX0(rp19Ej?a?33k#adx2CkeYN;5%(kf7&5PP6)78i$C> z;0*84{7YPTz$L|B=FwWYYB{1QBq0#uM+pChp`ApxgQ~)8MTxlNj1ycrLWIzWR@^Y! z8=(Q)^f-jDlSzzyC>Wo-Hm#+{u8dwE(^BKpQ`#;agm94$pKn4QaLX78W68|d!*(nJ zE5_YUbjxtkMh|MSLWpo;C#^@&BE7O0g;8uu6=nQvtw>NWaPi5SQ#5MD7gSPyAIkVB z`TF?iCyQY_1qZmHiWvGaO^FzX-Ju6Dgi&aGjN!6`b8aQTzCkz0En08GXPY5)KL delta 22 ccmdnChiTg$CT?v$UM>b8Fwl+L$gMpO07p>;ga7~l 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 index e75c088b01e4f619950fdc69d0681ee90aaddf5d..982bdf81f576ac26b0f8bca41c9e10fa77ee07b4 100644 GIT binary patch delta 20 acmexp`q7kIn~#@^0SH|FByQw>CItXK+y(^z delta 20 acmexp`q7kIn~#@^0SF9q<2G_XlL7!aX9XVs 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 1a022c772957dfb6e25a5acb28dc26cc3316dcf0..12fc2187ceddf1b3103570828b839c1b18aec2ce 100644 GIT binary patch delta 20 acmdm?yF-^-n~#@^0SH|FByQwhD+T~G&;>>S delta 20 acmdm?yF-^-n~#@^0SF9q<2G`y6$1b-TLfSL 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 84954dc8e8d791009af88e8e2bd88dae846f006b..b606a7dfe5cbe13d443645993832320bca140fce 100644 GIT binary patch delta 22 ccmZ4A!L+`EiCdeGmx}=iT>d0()Gnar^KqBC2U)7hYx61xXUOjt zyZJpoXu+i6(nqjX{_K*CV2w2AcA->;c>z(^urLW>tbF>)A3k`seB645sOl&=g0qGI zu$O7KRj?1{5X8Hl{l(Yja!mdzhFqS3+W;Pev<6+#!$#l_xJ@2PAl&ITd*}xt z?6!E=3J$;E20WBOIO+yH3<1Hn+ct+t`}Hp5=gTi%e;J_;K#DickjjlDIcx^d{Ba^U zF+NaedgCGSUkJhzq-QZe-gIO*Mhr&{v;)uyB4B=y-4)D~YtT=m&K&^{>xq1%D->&^5IHkulV`g#|UsEqHnNUQje$&Eukp zZMBF?1#Fmxn!IY}RjibXW>P5>Ec0y@=QTS=y|wZZ&Y4D0GfJ9q1uHgIa6wlz1D6aM zzo^oqoKmC}Y1;A%?x$P-H^cT^QPXw2U|QD{D{tf9RzJq3p|9Yau9WiXkfOzHY$=wC z&rZ-<$%CHsDRup_l3T)>%^KOZX44)6L)n3Df^L3NzeHclk|4Qr^C!Xk)GE>5zl)|I z5pR7Oa|eAbp&fbfes)_2+u@NX@=-TMDz`H59U`rt=sO8P zxMvRtEUDvXIrc=3xrl3@gwHuX7;-s!@YkM1rq&f_i#C4mWW%%oe-J0?(h2aW8B+1c z-^#eRp4o(FkA)~#eA1~XsDxQ$rISTgy5ngc{K!vqQLzyf(}>$h`cI8>8{<6XO~ldA zO&+KFxy=}4+{e;3$lnuF^-B;DRC&3^TpH zY7fu2kHu7n#mp;Wb&T<2j-y41a`rrV5a&@FSN`sUI~?<~x3#+0=`7YpTA)x%$tzV7GOI-r2ujm?x_M delta 719 zcmY+COH30%7{}+E?St-?W!r8GNFP`pO(`u1@d368NCc`08dE)JBM=3niHQMCh(Q)S zi6wwed|XU0@n8rhn7wNvg|_jgJ$UHBgNgBi(?rsEFgiO266f&$e*esud^7W(-y1ly zj^&74Eh4r$qnB?K9=w%D)2a+zWyEAHL5;GRVoqO3PY7_Dd>{1-B_%+_vB$8hzZjdw zuuB|Q%3#%ns}{_PI5_~|*GI1X5Mfe3HuViN=ewg>w7VFg=@~X_9#v2uhQ>12FJdhf z4Et5`P_qHF3Q01xaTJl$O(l+Yq_}7`M;X<5X$?mOc^avMqZ1uyrY?>ustZy#M-Q@y z<=Q?>8fJYsYtnmX;~1w8lGQmkS(!Tu4P<*R=00RR!v8DXMXIzGaFUo-PhQ=0k&Bue zj*wg0gl!oiFAAV^l(fy?14Cn`lytU(yjk&)%KQaz=_3n&EkY{nC4t4KzlS@4$!Il`~iq7o}9t!uZ7N_(ku;B)86RiUW1e^R2fnf%No;XLVC>VY=$ zd}&-;XD7UWqAJ-4AEbWMkQr^&05e;P)8Hnnp%Jd#9>!Tw9t@&voza=~8l72Ri1D_m z-_OiWP#VI*PE;982s;Tg;~W;)cFr73`h;A_J`}g+x@`u>rD402@301#$+xOWn>F7H z!wE5e+|0OujHZH3O4dsSm9tM^1IYN9OJp--#(%ILT7DuJ_1&ZErgQ9D?FAiR+3Qk4 q*^8wBP63_4JTDmqn)b783hiebitK0G60Y!UD}yE%_@~;~tNa6i)WPBa 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 index 6256b0a3e821f5f6be924161c6aba7cbce976cb1..85c3516b7b4cc398658ecccaf4530797c19fde06 100644 GIT binary patch delta 1013 zcmYjQ-)mGy6y8Y_(TfdId{SDESmUnQ1dXvKDHxVyt0Ddf$qN30+`0Fj-8=yApoS8YyeBU|WckZLR?C*PQ zd~*EDpg`a9d;a5x`Df$bPmKIL+FzZx&U!&*X{owat1iR(%El@bg|!*Nk(2Xh=1L3T zQegpA($+#bpgmVwbz3^K5OpCzD;f&k+_?{w#-ODW1cN_i!k~rQXf&L!fYc2!yks>9 z2eDQ*lg@%d;d&_=VnOI&lxp1skr0*v9>h|h=S(OH*PVun>JkZbQVmU|J9hAl@U;=1 zAm++v4A!+ls0%6mF|a8&m_VDF5UMd1`x345SBhbHvC_M`xmLZjymIN>=F;Wm>P8rr zpd(xg8#AZBgE-|!#FA$TPIE!zQbs&(mVrB}P{b(=CF5{B zGF&B73OmPr>k4CHKW2lJvNrAUCPD*AjWBfBYHP!dkloEp@hvVSk0gd6WF&!aM_VKe zk{$3?n?w$%hsu@O`9bZQwc3R+EW>GN2~{G!;uzbGdXF&X8NDR!$w7;9Rk=&X_I z%nhbaE7={?L#knLxsthbDiJo;#My=V|E72VAS3F+Sog^UuSH}8*5w{Q$&z<5$sZm2 zi@nIVivLi&QTmO&?!PTPAKmqu{vErLtGRFcucrTCV_%K!-Y(`3PF!Oj^6jIG{h5;o zM%do`;;CB{|2)-V`}(u9v!k?#xnCJwdO!d8aG4Rv0YbV{cvslZ=Kl5J;n&-Kc{)p{ z{_hJ}?8*3Pvw}$^6C=aqDzges21W*kqH>_h z$?F8|CtI1TF|uqdV#R2GLEhjC1Cahw#i-55bwOI^ Q3j>h;9>WMUqX=X+0O#97n*aa+ 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 index 93f9d800a4c6c61f073771ddcb1c0cb73a83d9c6..f4cfc3ae78ad08b4a08f0a2891ef1937f60537db 100644 GIT binary patch delta 796 zcmX||cil^JFnTAPZJw!qV zTGZ6mrGsrRcriU1w~%pfmFf)3GIQBf57}6=6FQR>rYf?gB}mXR+Ni#}0a0X~Sd>|b ziR+gE^2Cjr3dr340(`9Y7c7W=e^P=aQF!$>fXE~*Ca$~lN;cmu$x~y5e30-0B3ksXcg6LIm{2xHaN#`(REa_WdthtzVITdTRp zKjuW7>Mc$gHTV654eslomm~)#16m4w11}&`oPYSl0U2Ups0o0Gvf)k#V&-uYpiKCZ t0S97y)CW){R-Sq!M9Da&-VluwgA%+KD^m|(zZU?1`IP>t=RMP<{{c?bA^HFS delta 535 zcmXBRUr3Wt7{~Gb-pz=SBZvyh=ty*tDXo75iP`kV*)W4qQ)>0P-nl>T{>X$gWDPtjG-m2_7TMn%+3U=W6@WnGl?Y@Lhm=fHt;9ym{4DUV(#j=hd* zyM>pt!5_(GA3A2-yS7!?ix$r0rj;`DtrebIE4=LV@*%x*w<=b(8FHuRZ%dCBOr$iS z#s@_pqy?hF2&sk$sBsZg!+O0)CxlwrY8QoS7+Nf8h_n&bbr!Sz5%-D9IO=ja6ct|Ab0FOM8d;l-~NS45SN!5Udt}<*uK-$eEMC5lf z$IbNLMVxkDGJ%-P4Ln6Zbq$WKXNp4v%+vJMV>agV^*svSkmF{pjd5lg$`|>EzhYIk zk1Q%{imc7*Wrnk}P^fVowGm<9UdF7GIQfOz)9_SpOq<0xPca0o^&b9z5-9Z aO5IbV3ZBvO{adg%gTI^et3zI!Iq(lnv&2vU 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 23db671b8a4fd342792fc0b11688171052a2401a..f51659e7ab6794cc83268354d7a4fa49c65ee17a 100644 GIT binary patch delta 22 ccmdlzgK75+CT?v$UM>b8aQTzCk=vjL07wr89{>OV delta 22 ccmdlzgK75+CT?v$UM>b8Fwl+L$ZgOA079AtIRF3v 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 e8ef2fe8c51877e1187bd2ca043ecb30292b5c81..3a53b9d5dc10639e0cde1f9dea038ca59b4342b4 100644 GIT binary patch delta 22 ccmccD%y_Svkz1ROmx}=iT>d0(d0Ba<_6bwsJGIax-t`X8GC&0F&+r<^TWy delta 34 ocmZpeC)6-ch+CVFmx}=i40Pifxm&pzTe+E9xtX_evwZCX0E4jz00000 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 eec3a5fc57ca5ce496e596aac33919489a7f2686..c4ff7e738db9d2bab723382743cb852b577684ce 100644 GIT binary patch delta 20 acmey7_9Km3n~#@^0SH|FByQx+H3k4h@dgzD delta 20 acmey7_9Km3n~#@^0SF9q<2G{V8Up}8d<8E6 diff --git a/custom_components/ha_mcp_tools/const.py b/custom_components/ha_mcp_tools/const.py index 762a5b0..dcbdea2 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.2.2" +COMPONENT_VERSION = "1.2.3" # Config-entry discriminator (``entry.data[CONF_ENTRY_TYPE]``). A missing value # means "tools" so the pre-existing services entry keeps working across the @@ -40,8 +40,17 @@ 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) -ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards"] +# Allowed directories for file operations (relative to config dir). +# "blueprints" is read-only BY DEFAULT — in ALLOWED_READ_DIRS but not +# ALLOWED_WRITE_DIRS, so ha_write_file / ha_delete_file reject it (raw blueprint +# reads are safe: community YAML, no secrets — issue #1965). This is the default +# allowlist, not an absolute guarantee: an admin who adds "blueprints" as a +# custom extra directory (issue #1567, see _current_extra_dirs) grants it +# read+write, since extra_dirs are honored on the write path too. Blueprint +# writes should instead go through ha_import_blueprint (which invokes the +# blueprint/save WS command internally). Prefer ha_get_blueprint for the parsed +# body; raw read is the escape hatch for the exact on-disk text. +ALLOWED_READ_DIRS = ["www", "themes", "custom_templates", "dashboards", "blueprints"] ALLOWED_WRITE_DIRS = ["www", "themes", "custom_templates", "dashboards"] # NON-OVERRIDABLE deny floor for the user-configurable extra read/write diff --git a/custom_components/ha_mcp_tools/embedded_server.py b/custom_components/ha_mcp_tools/embedded_server.py index c2e3158..116fbed 100644 --- a/custom_components/ha_mcp_tools/embedded_server.py +++ b/custom_components/ha_mcp_tools/embedded_server.py @@ -1568,6 +1568,64 @@ _PENDING_INSTALL_DONE: threading.Event | None = None _CACHED_IMPORT_VERSION: str | None = None +def _safe_invalidate_caches() -> None: + """Run ``importlib.invalidate_caches()``, completing it if a finder breaks. + + On Python 3.14 with ``homeassistant`` installed as a setuptools *editable* + package (the official HA container image), ``PathFinder.invalidate_caches()`` + raises ``KeyError`` at its ``del sys.path_importer_cache[name]`` line: the + synthetic ``__editable__..finder.__path_hook__`` placeholder is not an + absolute path, so CPython takes the ``del`` branch on a key an earlier + iteration already removed — a CPython 3.14 bug (the ``del`` should be a + ``pop``). That aborts ``importlib.invalidate_caches()`` partway and, before + this guard, crashed in-process server bring-up on every boot (issues #1891, + #1985). + + On that ``KeyError`` we do CPython's own cleanup ourselves — prune the dead / + relative ``sys.path_importer_cache`` entries with ``pop`` instead of the + buggy ``del`` (the stale placeholder that trips the sweep is among them) — + then re-run ``importlib.invalidate_caches()``. With the offending entries + gone the retry completes CPython's full sweep itself: every live path-entry + finder invalidated, the namespace-path epoch advanced, and the metadata + finder refreshed. Delegating the second pass keeps us off private internals + (no ``_NamespacePath`` / ``_path_isabs`` poking) and faithful to whatever the + running Python's ``invalidate_caches`` does. Recovery only ever runs on the + broken 3.14 path (the top-level call succeeds everywhere else); every + non-``KeyError`` still propagates. + + The retry is itself guarded: a concurrent import on another HA-core thread + could re-add a stale placeholder in the window between the prune and the + retry, so a *second* ``KeyError`` is tolerated (logged, best-effort) rather + than re-raised — a partial cache refresh must never re-crash bring-up, which + is the whole point of this helper. The recovery is logged at WARNING (it + recurs on every version check on an affected install), so it is visible for + diagnosis rather than a silent workaround. + """ + try: + importlib.invalidate_caches() + return + except KeyError as err: + _LOGGER.warning( + "importlib.invalidate_caches() raised KeyError from a broken " + "(setuptools editable / Python 3.14) finder; pruning stale " + "sys.path_importer_cache entries and retrying: %s", + err, + ) + for name in list(sys.path_importer_cache): + if sys.path_importer_cache.get(name) is None or not os.path.isabs(name): + sys.path_importer_cache.pop(name, None) + try: + importlib.invalidate_caches() + except KeyError as err: + _LOGGER.warning( + "importlib.invalidate_caches() still raised KeyError after pruning " + "stale sys.path_importer_cache entries; continuing with a best-effort " + "cache state (a concurrent import may have re-added the placeholder): " + "%s", + err, + ) + + def _purge_ha_mcp_modules() -> None: """Drop every cached ``ha_mcp`` module so the next import loads fresh code. @@ -1595,7 +1653,7 @@ def _purge_ha_mcp_modules() -> None: return for name in purged: sys.modules.pop(name, None) - importlib.invalidate_caches() + _safe_invalidate_caches() _LOGGER.debug("Purged %d cached ha_mcp module(s) before worker start", len(purged)) @@ -1608,7 +1666,7 @@ def _installed_ha_mcp_version(preferred_dist: str | None = None) -> str | None: provided, checks that channel first so stale metadata from a failed best-effort conflicting uninstall cannot mask the package just installed. """ - importlib.invalidate_caches() + _safe_invalidate_caches() # Metadata alone is not proof: a channel switch's best-effort uninstall # can leave ORPHANED .dist-info whose files are gone (the shared ha_mcp/ # tree belongs to whichever dist installed last). Require the import @@ -1631,7 +1689,7 @@ def _dist_installed(dist_name: str) -> bool: Invalidates the import caches first so a just-completed (un)install is seen. """ - importlib.invalidate_caches() + _safe_invalidate_caches() try: importlib.metadata.version(dist_name) except importlib.metadata.PackageNotFoundError: @@ -1648,7 +1706,7 @@ def _installed_dist_version(dist_name: str) -> str | None: the auto-update check compares the newest PyPI build against the version of the channel actually installed. """ - importlib.invalidate_caches() + _safe_invalidate_caches() try: return importlib.metadata.version(dist_name) except importlib.metadata.PackageNotFoundError: diff --git a/custom_components/ha_mcp_tools/manifest.json b/custom_components/ha_mcp_tools/manifest.json index 1b2adee..27726c8 100644 --- a/custom_components/ha_mcp_tools/manifest.json +++ b/custom_components/ha_mcp_tools/manifest.json @@ -22,5 +22,5 @@ "requirements": [ "ruamel.yaml>=0.18.0" ], - "version": "1.2.2" + "version": "1.2.3" } diff --git a/custom_components/ha_mcp_tools/mcp_webhook.py b/custom_components/ha_mcp_tools/mcp_webhook.py index df4e72f..eb18cda 100644 --- a/custom_components/ha_mcp_tools/mcp_webhook.py +++ b/custom_components/ha_mcp_tools/mcp_webhook.py @@ -486,6 +486,13 @@ def _register_metadata_views(hass: HomeAssistant) -> None: """ if hass.data.get(_OAUTH_VIEWS_REGISTERED_KEY): return + # Set the flag only AFTER every view registers (issue #1978): it must mean + # "the full bundle is bound", so a partial bind stays distinguishable from a + # complete one. Marking it bound early would let a later setup assign a + # provider and advertise discovery while some RFC metadata routes are still + # unbound — a 404 for the clients that probe them. On a partial bind the flag + # stays unset; the none-mode caller then fails open (the retry's duplicate + # register is caught harmlessly) while ha_auth/legacy fail closed. for view in _metadata_views(hass): hass.http.register_view(view) hass.data[_OAUTH_VIEWS_REGISTERED_KEY] = True @@ -735,9 +742,28 @@ async def async_register_webhook( # 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() + # + # Fails OPEN, unlike ha_auth/legacy (issue #1978): none mode is + # intentionally unauthenticated, and this discovery is an + # enhancement layered on a webhook that (already registered + # above) otherwise always forwards. A failure here must NOT fall + # through to the outer teardown and take down a webhook the user + # configured to need no auth — it only means claude.ai's rare + # OAuth-discovery fallback goes unassisted. Mirrors the add-on's + # _setup_none_autoapprove. Provider is assigned last, so a + # partial bind leaves none-autoapprove inactive (plain proxy) + # rather than half-enabled. + try: + _register_metadata_views(hass) + bind_autoapprove_views(hass) + cfg[CFG_AUTOAPPROVE_PROVIDER] = AutoApproveProvider() + except Exception: + _LOGGER.exception( + "MCP webhook: failed to set up none-mode auto-approve " + "discovery; continuing as a plain unauthenticated proxy " + "(the webhook still forwards — only claude.ai's rare " + "OAuth-discovery fallback is unassisted)." + ) except Exception: # Never leave a live endpoint (or a leaked session) behind a failed # auth-setup path. suppress: the ORIGINAL error must be what diff --git a/custom_components/ha_mcp_tools/oauth_autoapprove.py b/custom_components/ha_mcp_tools/oauth_autoapprove.py index 18b34fd..76ea9e5 100644 --- a/custom_components/ha_mcp_tools/oauth_autoapprove.py +++ b/custom_components/ha_mcp_tools/oauth_autoapprove.py @@ -185,6 +185,18 @@ class AutoApproveAuthorizeView(HomeAssistantView): 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). + + ACCEPTED RISK (issue #1978): this endpoint is anonymous by design — none + mode requires zero HA login — so it consults neither the webhook id nor a + client identity. Anyone who knows the HA origin can therefore fill the + shared pending-code store (``MAX_PENDING_CODES``) with S256 challenges bound + to the public claude.ai callback, at which point a *brand-new* connector's + handshake gets ``temporarily_unavailable`` until those codes expire + (``AUTH_CODE_TTL``, 5 min). Accepted because it is self-healing, exposes no + data, and grants no access: completing the flow needs the PKCE verifier the + attacker never has, and the issued token is cosmetic (none mode ignores + bearers). The webhook URL itself keeps forwarding throughout — only the rare + OAuth-discovery fallback for a *first* connect is briefly delayed. """ requires_auth = False @@ -298,6 +310,11 @@ def bind_autoapprove_views(hass: HomeAssistant) -> None: """ if hass.data.get(_AUTOAPPROVE_VIEWS_REGISTERED_KEY): return + # Set the flag only AFTER both views register (issue #1978): see + # mcp_webhook._register_metadata_views. Marking the bundle bound before + # /token registers would let a later none-mode setup assign the provider and + # advertise OAuth with an unbound /token — a 404 on the token exchange. The + # flag must mean the full bundle succeeded; a partial bind leaves it unset. 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 index 80c5c1b..46805b6 100644 --- a/custom_components/ha_mcp_tools/oauth_legacy.py +++ b/custom_components/ha_mcp_tools/oauth_legacy.py @@ -436,7 +436,11 @@ class PKCECodeStore: 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. + once the verifier clears the cheap RFC 7636 shape guard below, so every + well-formed attempt burns it — a wrong verifier, expired code, or + redirect mismatch still consumes the code. A malformed verifier is + rejected before the pop and does NOT burn the code, so a client that + sends a syntactically broken verifier can retry. """ # Validate the verifier shape per RFC 7636 §4.1 before doing any # crypto. A confused client passing an empty/short verifier should be diff --git a/custom_components/ha_mcp_tools/services.yaml b/custom_components/ha_mcp_tools/services.yaml index 552d4fe..4370503 100644 --- a/custom_components/ha_mcp_tools/services.yaml +++ b/custom_components/ha_mcp_tools/services.yaml @@ -58,7 +58,10 @@ list_files: fields: path: name: Path - description: Relative path from config directory (e.g., "www/", "themes/") + description: >- + Relative path from config directory. Allowed: www/, themes/, + custom_templates/, dashboards/, blueprints/, plus your configured + packages/ folder (e.g., "www/", "blueprints/automation"). required: true example: "www/" selector: @@ -81,7 +84,8 @@ read_file: Relative path from config directory. Allowed paths include configuration.yaml, automations.yaml, scripts.yaml, scenes.yaml, secrets.yaml (values masked), home-assistant.log, www/**, themes/**, - custom_templates/**, packages/*.yaml, custom_components/**/*.py + custom_templates/**, dashboards/**, blueprints/**, packages/*.yaml, + custom_components/**/*.py required: true example: "configuration.yaml" selector: @@ -119,13 +123,13 @@ read_file: write_file: name: Write File - description: Write a file to allowed directories (www/, themes/, custom_templates/). + description: Write a file to allowed directories (www/, themes/, custom_templates/, dashboards/). fields: path: name: Path description: >- - Relative path from config directory. Must be in www/, themes/, or - custom_templates/. + Relative path from config directory. Must be in www/, themes/, + custom_templates/, or dashboards/. required: true example: "www/custom.css" selector: @@ -155,13 +159,13 @@ write_file: delete_file: name: Delete File - description: Delete a file from allowed directories (www/, themes/, custom_templates/). + description: Delete a file from allowed directories (www/, themes/, custom_templates/, dashboards/). fields: path: name: Path description: >- - Relative path from config directory. Must be in www/, themes/, or - custom_templates/. + Relative path from config directory. Must be in www/, themes/, + custom_templates/, or dashboards/. required: true example: "www/old-file.css" selector: diff --git a/custom_components/ha_washdata/__init__.py b/custom_components/ha_washdata/__init__.py index 7395514..52e31be 100644 --- a/custom_components/ha_washdata/__init__.py +++ b/custom_components/ha_washdata/__init__.py @@ -18,6 +18,7 @@ from __future__ import annotations +import asyncio import json import logging from pathlib import Path @@ -1035,6 +1036,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): manager = hass.data[DOMAIN].pop(entry.entry_id) await manager.async_shutdown() + + # Settle registry: mark any still-RUNNING tasks for this entry as + # cancelled so they don't appear as zombies after reload. + from . import task_registry as _task_registry + _cancelled_tasks = _task_registry.get_registry(hass).cancel_entry_tasks(entry.entry_id) + # Drain cancelled WS-spawned tasks so their finally blocks (lock releases) + # complete before we remove the write lock below. + if _cancelled_tasks: + await asyncio.gather(*_cancelled_tasks, return_exceptions=True) + + # Release the per-entry write lock so it doesn't block the next setup. + from .ws_api import _WS_WRITE_LOCKS_KEY + hass.data.get(_WS_WRITE_LOCKS_KEY, {}).pop(entry.entry_id, None) + # When the last WashData entry is removed, tear down the shared panel/sidebar # so no stale registration flags or sidebar entry linger. if not hass.data.get(DOMAIN): diff --git a/custom_components/ha_washdata/analysis.py b/custom_components/ha_washdata/analysis.py index 195d1f3..966c75b 100644 --- a/custom_components/ha_washdata/analysis.py +++ b/custom_components/ha_washdata/analysis.py @@ -67,6 +67,10 @@ def find_best_alignment( n_curr = len(curr) n_ref = len(ref) + # Guard: cross-correlation crashes on empty or single-element arrays. + if n_curr < 2 or n_ref < 2: + return 0.0, {"corr": 0.0, "mae_score": 0.0}, 0 + # 1. Coarse Alignment (Cross-Correlation) # Downsample for speed if arrays are large ds_factor = 1 @@ -251,11 +255,12 @@ def _dtw_component_score( band: float, derivative: bool, scale: float, + curr_resampled: np.ndarray | None = None, ) -> float: """DTW similarity in [0,1] for one candidate: resample both series to a common grid, warp (level or derivative), and express the distance relative to the current peak (behaviour-neutral at MATCH_MAE_REF_PEAK).""" - a = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N) + a = curr_resampled if curr_resampled is not None else _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N) b = _resample_to(sample_arr, MATCH_DTW_RESAMPLE_N) dtw_dist = compute_dtw_lite(a, b, band_width_ratio=band, derivative=derivative) norm_dist = dtw_dist / MATCH_DTW_RESAMPLE_N @@ -326,6 +331,8 @@ def compute_matches_worker( l1_scale = float(config.get("dtw_l1_scale", MATCH_DTW_DIST_SCALE)) ddtw_scale = float(config.get("dtw_ddtw_scale", MATCH_DDTW_DIST_SCALE)) ensemble_w = float(config.get("dtw_ensemble_w", MATCH_DTW_ENSEMBLE_W)) + # Resample the current trace once — it's the same for every candidate. + curr_resampled = _resample_to(curr_arr, MATCH_DTW_RESAMPLE_N) for cand in to_refine: sample_arr = np.array(cand["sample"]) @@ -340,8 +347,8 @@ def compute_matches_worker( elif dtw_mode == "ensemble": # Blend the level-based (L1) and shape-based (derivative) DTW # scores; they are complementary signals. - s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale) - s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale) + s_l1 = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, False, l1_scale, curr_resampled=curr_resampled) + s_dd = _dtw_component_score(curr_arr, sample_arr, current_peak, dtw_bandwidth, True, ddtw_scale, curr_resampled=curr_resampled) dtw_score = ensemble_w * s_l1 + (1.0 - ensemble_w) * s_dd norm_dist = 0.0 # composite; per-component distance not meaningful else: @@ -352,7 +359,7 @@ def compute_matches_worker( use_deriv = dtw_mode == "ddtw" scale = ddtw_scale if use_deriv else l1_scale dtw_score = _dtw_component_score( - curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale + curr_arr, sample_arr, current_peak, dtw_bandwidth, use_deriv, scale, curr_resampled=curr_resampled ) norm_dist = 0.0 diff --git a/custom_components/ha_washdata/const.py b/custom_components/ha_washdata/const.py index f456abd..bcddcce 100644 --- a/custom_components/ha_washdata/const.py +++ b/custom_components/ha_washdata/const.py @@ -297,6 +297,27 @@ MATCH_RANKING_HISTORY_MAX = 500 # hard limit: this only surfaces "running longer than usual" for the UI. Kept # below the zombie threshold so it lights up well before any termination. CYCLE_OVERRUN_ANOMALY_RATIO = 1.5 + +# Duration-anchored hard-finalize backstop in the ENDING state. Smart Termination +# is (deliberately) gated on a confident, non-ambiguous match; an ambiguous / +# prefix-ambiguous match therefore skips it and relies on the power+energy fallback +# timeout, which a low standby baseline (below stop_threshold but energetic enough +# to trip the energy gate) can hold open until the 8 h cap / zombie-kill — the +# #296/#311 "finishes hours/1000+ min late" reports. This SEPARATE backstop +# finalizes a matched cycle that has sat in ENDING well past its expected duration +# AND been genuinely quiet (below stop_threshold) for a sustained span. It is +# asymmetric (can only ever SHORTEN a stuck wait, never end a cycle early) and the +# sustained-quiet guard is what keeps it from truncating a longer program that was +# mismatched to a shorter profile — a real longer program has high-power phases +# that keep resetting the below-threshold timer, so it never accumulates the +# required continuous quiet. Sits between CYCLE_OVERRUN_ANOMALY_RATIO (1.5, soft +# visible signal) and the manager's 3x zombie-kill, so it fires well after any +# legitimate overrun but well before the hard kill. +ENDING_HARD_FINALIZE_RATIO = 2.0 +ENDING_HARD_FINALIZE_MIN_QUIET_S = 600.0 # continuous sub-threshold span floor + +# NOTE: STANDBY_BAND_* constants live further down, after the DEVICE_TYPE_* +# definitions they reference (search "Standby-band stuck-in-RUNNING finalize"). # Underrun anomaly: a cycle that finishes in less than this fraction of its # matched profile's median duration is flagged "underrun" (post-cycle only, # never a live signal — computed in _async_process_cycle_end after the cycle @@ -536,6 +557,68 @@ DEVICE_TYPES = { DEVICE_TYPE_OTHER: "Threshold Device", } +# Standby-band stuck-in-RUNNING finalize (#296). Some appliances finish but hold +# a small, flat "anti-crease" / display standby draw that sits ABOVE stop_threshold_w +# (e.g. a ~2.5-3.2 W baseline while stop_threshold is ~1.2 W). Because power never +# drops below the stop threshold, _time_below_threshold never accumulates, so the +# cycle never reaches PAUSED/ENDING and runs until the 8 h RUNNING cap — and the +# anti-wrinkle handler can't help because entering it requires a completed +# TIMEOUT/SMART finish that never happens (chicken-and-egg). This detector spots a +# sustained, FLAT, tiny-fraction-of-peak plateau *past* the expected duration and +# finalizes the cycle (as a normal TIMEOUT completion, so an anti-wrinkle-enabled +# washer/dryer still routes into ANTI_WRINKLE afterwards). Heavily gated so it can +# never end an active low-power phase: it needs a matched profile, elapsed >= 2x +# expected, and the whole recent window flat + below a small fraction of the cycle's +# own peak. Restricted to wet appliances where a stuck baseline is unambiguously +# anomalous (bread-maker keep-warm, pump, air-fryer etc. have legitimate holds and +# are excluded). +STANDBY_BAND_FINALIZE_DEVICE_TYPES = ( + DEVICE_TYPE_WASHING_MACHINE, + DEVICE_TYPE_WASHER_DRYER, + DEVICE_TYPE_DRYER, +) +STANDBY_BAND_MIN_RATIO = 2.0 # only past 2x the expected duration +STANDBY_BAND_WINDOW_S = 600.0 # require a >=10 min flat plateau +STANDBY_BAND_MAX_FRACTION = 0.10 # plateau level <= 10% of the cycle's peak +STANDBY_BAND_FLATNESS_FRACTION = 0.03 # window (max-min) <= 3% of the cycle's peak +STANDBY_BAND_FLATNESS_FLOOR_W = 2.0 # absolute flatness floor for low-peak devices + +# Issue #296 follow-up: anti-crease ("Knitterschutz") back-to-back handling. +# +# After a wash finishes, some machines (e.g. Miele) hold a low-power tumble tail - +# a constant baseline plus periodic sub-``anti_wrinkle_max_power`` bursts, NO +# heating - until the door is opened. To the power-off detector this looks like +# continued RUNNING (the bursts recur faster than off_delay and keep reviving the +# cycle out of ENDING), so the cycle never finalises into STATE_ANTI_WRINKLE - the +# state that is designed to absorb the tail and split off the next wash. If a +# second load is started before the door is opened, the whole sequence +# (wash -> tail -> wash -> tail) merges into one multi-hour "cycle". +# +# Two coordinated mechanisms fix it, BOTH opt-in via ``anti_wrinkle_enabled`` and +# gated to the anti-wrinkle device types (see ``anti_wrinkle_active`` in +# cycle_detector): +# +# * a proactive finalise (``_is_anticrease_tail`` -> Smart Termination into +# ANTI_WRINKLE): once a *matched* cycle is past its expected duration AND the +# recent window shows only the low-power tail, finalise without waiting to reach +# ENDING through the burst-defeated off_delay path; +# * a match freeze (``_try_profile_match`` guard) under the SAME condition, so +# re-matching on the growing flat tail cannot drift the label to a longer +# near-duplicate profile (which would push expected_duration out and break the +# finalise gate) - the field failure that breaks Smart Termination. +# +# Gated on ``elapsed >= expected * ratio`` so a legitimate mid-wash low-power phase +# can NEVER trigger it: a washer spends most of its cycle below +# ``anti_wrinkle_max_power`` (only brief heating spikes exceed it), but every +# observed clean cycle's mid-cycle sub-max_power gap ENDS well before its expected +# duration, while the anti-crease tail BEGINS after it. Also requires a genuinely +# energetic cycle (peak above ``anti_wrinkle_max_power``) so a low-power program +# that never heats is left alone. Asymmetric (finalise-only, can only shorten the +# wait) and self-correcting (a new wash's heating burst leaves the regime and +# re-arms matching). +ANTI_CREASE_FINALIZE_RATIO = 0.98 # elapsed must reach 98% of expected duration +ANTI_CREASE_CONFIRM_WINDOW_S = 180.0 # recent window that must hold no reading > max_power + # Device Type Defaults # Device Type Defaults (Maps) @@ -618,6 +701,26 @@ DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS = 600.0 # a shorter window can never fire it mid-cycle. DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS = 300.0 +# Sustained "true off" (power below stop_threshold_w) window that cancels a +# user-paused STARTING state back to OFF. A user pause during STARTING is held +# indefinitely (issue #306) waiting for Resume Cycle, but a genuinely paused +# appliance keeps standby power above the stop threshold; sustained power below it +# means the machine was switched off, so without this the detector could stay +# pinned in STARTING forever. Generous enough not to abort a real pause whose +# standby briefly dips (and well above the issue-#306 test's 10 s hold), while +# bounding the pinned-forever case. +STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS = 300.0 + +# Upper bound on the washer / washer-dryer Smart-Termination debounce, which is +# otherwise derived as max(180, min_off_gap * 0.5). At the shipped defaults this +# is 240 s (washing machine, min_off_gap 480) / 300 s (washer-dryer, min_off_gap +# 600) and the cap never bites. It only bounds the case where a suggested or +# hand-set min_off_gap (e.g. 1800 s) would inflate the quiet-time requirement to +# 15 min, starving end-detection for confident non-ambiguous matches the same way +# the old dishwasher off_delay*0.25 coupling did. 600 s leaves ample headroom for +# a washer's longest legitimate mid-cycle soak trough while capping the pathology. +WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS = 600.0 + DEFAULT_OFF_DELAY_BY_DEVICE = { DEVICE_TYPE_DISHWASHER: 1800, # 30 min (Drying) DEVICE_TYPE_BREAD_MAKER: 300, # 5 min (Keep-warm phase after baking) diff --git a/custom_components/ha_washdata/cycle_detector.py b/custom_components/ha_washdata/cycle_detector.py index 4d88c2d..f378517 100644 --- a/custom_components/ha_washdata/cycle_detector.py +++ b/custom_components/ha_washdata/cycle_detector.py @@ -18,6 +18,7 @@ from __future__ import annotations +import itertools import logging import math from dataclasses import dataclass @@ -51,9 +52,21 @@ from .const import ( DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS, DISHWASHER_END_SPIKE_WAIT_SECONDS, DISHWASHER_SMART_TERMINATION_DEBOUNCE_SECONDS, + WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS, + STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS, DISHWASHER_MATCH_FREEZE_QUIET_SECONDS, DISHWASHER_MIN_CYCLE_DURATION_S, TERMINAL_DROP_OFF_DELAY_SECONDS, + ENDING_HARD_FINALIZE_RATIO, + ENDING_HARD_FINALIZE_MIN_QUIET_S, + STANDBY_BAND_FINALIZE_DEVICE_TYPES, + STANDBY_BAND_MIN_RATIO, + STANDBY_BAND_WINDOW_S, + STANDBY_BAND_MAX_FRACTION, + STANDBY_BAND_FLATNESS_FRACTION, + STANDBY_BAND_FLATNESS_FLOOR_W, + ANTI_CREASE_FINALIZE_RATIO, + ANTI_CREASE_CONFIRM_WINDOW_S, ) # The dishwasher end-spike wait window is shared between two code paths @@ -312,6 +325,11 @@ class CycleDetector: # OFF only when the machine has clearly been switched off rather # than briefly dipped. self._delay_wait_true_off_seconds: float = 0.0 + # _starting_paused_off_since anchors the first below-stop reading while + # a user-paused STARTING state is held (issue #306). Elapsed time is + # measured from this anchor, not accumulated per-dt, so a single large + # (but sub-outage) interval cannot prematurely credit minutes of quiet time. + self._starting_paused_off_since: datetime | None = None # _delay_wait_high_start anchors the first high-power reading # observed inside DELAY_WAIT. We only transition to STARTING # when the high-power streak has lasted at least @@ -383,6 +401,18 @@ class CycleDetector: ): return + # Terminal-tail match freeze (anti-crease, #296): once a washer/dryer with + # anti-wrinkle enabled is past its expected duration and has settled into + # the low-power tumble tail, re-matching on the growing flat tail drifts the + # label toward a LONGER near-duplicate (its expected duration grows), which + # pushes the anti-crease finalize gate out and breaks Smart Termination - + # the field failure that merges back-to-back washes. Keep the good + # pre-tail match instead. Self-correcting: a new wash's heating burst above + # anti_wrinkle_max_power leaves the tail regime, so this stops applying and + # matching re-arms for the next cycle. + if self._matched_profile and self._in_anticrease_freeze(timestamp): + return + # Rate limiting if not force and self._last_match_time: elapsed = (timestamp - self._last_match_time).total_seconds() @@ -459,6 +489,23 @@ class CycleDetector: Can be called by the matcher callback directly or asynchronously. """ + # Terminal-tail match freeze (anti-crease, #296). This is the single sink + # for ALL match updates - the detector's own _try_profile_match AND the + # manager's async 5-min matcher (manager.py calls update_match directly). + # Once a washer/dryer with anti-wrinkle enabled is past its expected + # duration and has settled into the low-power tumble tail, re-matching on + # the growing flat tail drifts the label toward a LONGER near-duplicate (or + # flips it ambiguous), which pushes out expected_duration and would block + # the anti-crease finalize - the field failure that merges back-to-back + # washes. Keep the good pre-tail match instead. Self-correcting: a new + # wash's heating burst above anti_wrinkle_max_power leaves the tail regime, + # so this stops applying and matching re-arms for the next cycle. + if ( + self._matched_profile + and self._power_readings + and self._in_anticrease_freeze(self._power_readings[-1][0]) + ): + return # Unpack 5 elements (or 4 for backward compatibility if needed, but wrapper is updated) # wrapper returns (name, confidence, duration, phase, is_mismatch) # Or MatchResult object if refactored, but currently wrapper returns tuple. @@ -582,6 +629,13 @@ class CycleDetector: self._time_below_threshold = 0.0 self._last_match_time = None self._matched_profile = None + # Clear stale match state so the next cycle starts with clean defaults. + # _expected_duration left at 0 tells the dishwasher end-spike gate that + # no profile is matched yet; stale non-zero would mis-gate the spike check. + self._expected_duration = 0.0 + self._last_match_confidence = 0.0 + self._match_ambiguous = False + self._match_prefix_ambiguous = False self._ignore_power_until_idle = False # Reset lockout self._lockout_high_seconds = 0.0 # Clear the verified-pause flag so it can't leak into the next cycle (B6): @@ -597,6 +651,7 @@ class CycleDetector: self._delay_band_seconds = 0.0 self._delay_band_peak = 0.0 self._delay_wait_true_off_seconds = 0.0 + self._starting_paused_off_since = None self._delay_wait_high_start = None @property @@ -972,6 +1027,10 @@ class CycleDetector: self._power_readings.append((timestamp, power)) self._cycle_max_power = max(self._cycle_max_power, power) + if is_high: + # Power back up - clear any accumulated "true off" hold time. + self._starting_paused_off_since = None + if self._time_above_threshold >= self._config.start_duration_threshold: if self._energy_since_idle_wh >= self._config.start_energy_threshold: self._transition_to(STATE_RUNNING, timestamp) @@ -982,7 +1041,41 @@ class CycleDetector: # that the low power is intentional, not a false start. if not is_high and self._time_below_threshold > 1.0: # 1s grace period if getattr(self, "_verified_pause", False): - pass # user pause holds; wait for Resume Cycle + # User pause holds; wait for Resume Cycle (issue #306). But a + # genuinely paused appliance keeps standby power above the stop + # threshold - sustained power *below* it means the machine was + # switched off, so fall back to OFF rather than pinning STARTING + # forever. + if power < self._config.stop_threshold_w: + # An outage-sized gap since the last reading is NOT observed + # quiet (the machine may still be paused, we just lost + # telemetry): reset anchor so only genuinely-sampled + # sustained-off time can cancel a paused STARTING. + if dt > self._outage_threshold_s(): + self._starting_paused_off_since = None + elif self._starting_paused_off_since is None: + # First below-stop reading: anchor the timestamp. + # Don't credit the preceding dt interval — we only + # know the device is off *now*, not how long before + # this sample it went quiet. + self._starting_paused_off_since = timestamp + observed_off_s = ( + (timestamp - self._starting_paused_off_since).total_seconds() + if self._starting_paused_off_since is not None + else 0.0 + ) + if observed_off_s >= STARTING_PAUSED_TRUE_OFF_TIMEOUT_SECONDS: + self._logger.info( + "Paused STARTING cancelled: power off (%.1fW) for " + "%.0fs → OFF", + power, + observed_off_s, + ) + self._transition_to(STATE_OFF, timestamp) + return + else: + # Power recovered — clear the off anchor. + self._starting_paused_off_since = None else: # False start self._logger.debug( @@ -999,6 +1092,13 @@ class CycleDetector: self._power_readings.append((timestamp, power)) self._cycle_max_power = max(self._cycle_max_power, power) + # Anti-crease finalize (#296): a matched cycle past its expected + # duration that has settled into the low-power tumble tail is done - + # finalize into anti-wrinkle now instead of letting the periodic + # bursts keep reviving RUNNING until a second wash merges in. + if self._maybe_finalize_anticrease_tail(timestamp): + return + # Use dynamic threshold thresh = self._dynamic_pause_threshold if self._time_below_threshold >= thresh: @@ -1008,16 +1108,71 @@ class CycleDetector: # Periodic profile matching self._try_profile_match(timestamp) + # Standby-band stuck finalize (#296): an appliance holding a flat + # low standby draw ABOVE stop_threshold never accumulates + # _time_below_threshold, so it never reaches PAUSED/ENDING. Detect + # the plateau and finalize as a normal completion (so anti-wrinkle + # still engages). Cheaply gated on being well past expected before + # the window scan runs. + if self._is_standby_band_stuck(timestamp): + start_time = self._current_cycle_start or timestamp + current_duration = (timestamp - start_time).total_seconds() + # The plateau sits ABOVE stop_threshold, so it keeps advancing + # _last_active_time and the default keep_tail=False trim would NOT + # remove it - inflating the stored duration/energy with minutes of + # standby. Snap the end back to the last real activity (the last + # reading above the plateau ceiling) and drop the trailing plateau. + level_ceiling = float(self._cycle_max_power) * STANDBY_BAND_MAX_FRACTION + plateau_start_idx = None + for i in range(len(self._power_readings) - 1, -1, -1): + if float(self._power_readings[i][1]) > level_ceiling: + plateau_start_idx = i + break + if ( + plateau_start_idx is not None + and plateau_start_idx < len(self._power_readings) - 1 + ): + self._power_readings = self._power_readings[: plateau_start_idx + 1] + self._last_active_time = self._power_readings[-1][0] + current_duration = ( + self._last_active_time - start_time + ).total_seconds() + self._logger.info( + "Standby-band finalize: flat plateau ~%.1fW (peak %.0fW) held " + "past expected %.0fs — appliance finished but holds a standby " + "draw above stop_threshold; finalizing (plateau trimmed, " + "duration %.0fs).", + power, + self._cycle_max_power, + self._expected_duration, + current_duration, + ) + self._finish_cycle( + timestamp, + status="completed", + termination_reason=TerminationReason.TIMEOUT, + keep_tail=False, + ) + return + # Max duration safety if ( self._current_cycle_start and (timestamp - self._current_cycle_start).total_seconds() > 28800 ): # 8h safety - self._finish_cycle(timestamp, status="force_stopped") + self._finish_cycle( + timestamp, + status="force_stopped", + termination_reason=TerminationReason.FORCE_STOPPED, + ) elif self._state == STATE_PAUSED: self._power_readings.append((timestamp, power)) + # Anti-crease finalize (#296) - see the RUNNING branch. + if self._maybe_finalize_anticrease_tail(timestamp): + return + if is_high: # Resume to RUNNING self._transition_to(STATE_RUNNING, timestamp) @@ -1032,6 +1187,25 @@ class CycleDetector: elif self._state == STATE_ENDING: self._power_readings.append((timestamp, power)) + # Hard cap: ENDING must not run longer than RUNNING's 8 h safety limit. + # Without this a standby baseline can hold the state open indefinitely. + if ( + self._current_cycle_start + and (timestamp - self._current_cycle_start).total_seconds() > 28800 + ): + self._finish_cycle( + timestamp, + status="force_stopped", + termination_reason=TerminationReason.FORCE_STOPPED, + ) + return + + # Anti-crease finalize (#296) - see the RUNNING branch. Fires ahead of + # the is_high end-spike handling so a sub-max_power tail burst finalizes + # into anti-wrinkle instead of reviving RUNNING. + if self._maybe_finalize_anticrease_tail(timestamp): + return + if is_high: start_time = self._current_cycle_start or timestamp current_duration = (timestamp - start_time).total_seconds() @@ -1196,8 +1370,14 @@ class CycleDetector: # the soak-bridging min_off_gap before committing # Smart Termination, so a near-duplicate profile # doesn't cut a long cycle short during a mid-cycle - # power trough. - smart_debounce = max(180.0, self._config.min_off_gap * 0.5) + # power trough. Bounded above so a large suggested / + # hand-set min_off_gap can't inflate the quiet-time + # requirement and starve end-detection (see + # WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS). + smart_debounce = min( + WASHER_SMART_TERMINATION_DEBOUNCE_MAX_SECONDS, + max(180.0, self._config.min_off_gap * 0.5), + ) else: smart_debounce = 120.0 @@ -1276,6 +1456,64 @@ class CycleDetector: ) return + # --- DURATION-ANCHORED HARD FINALIZE (backstop) --- + # Separate safety net for a matched cycle whose Smart + # Termination is blocked (ambiguous / prefix-ambiguous match) + # and whose fallback energy gate is held open by a low standby + # baseline: without this it sits in ENDING until the 8 h cap / + # zombie-kill (#296/#311). Fires only well past the expected + # duration AND after a long *continuous* sub-threshold span, so + # it can never truncate a longer program mismatched to a shorter + # profile (a real longer program has high-power phases that keep + # resetting the quiet timer) — asymmetric, shorten-only. Not + # for user-paused cycles. + required_quiet = max( + ENDING_HARD_FINALIZE_MIN_QUIET_S, + float(max(self._config.off_delay, self._config.min_off_gap)), + ) + # Require the required_quiet tail to be actually SAMPLED (no + # outage-sized gap): otherwise a telemetry outage that inflated + # _time_below_threshold could finalize an active cycle early. + # Walk in reverse so we can capture the boundary reading (the + # first reading outside the window) — an outage right before the + # window would be invisible if we only passed in-window timestamps. + quiet_ts: list[datetime] = [] + _boundary_q: datetime | None = None + for _ts, _ in reversed(self._power_readings): + if (timestamp - _ts).total_seconds() <= required_quiet: + quiet_ts.append(_ts) + elif quiet_ts: + _boundary_q = _ts + break + if _boundary_q is not None: + quiet_ts.append(_boundary_q) + if ( + self._expected_duration > 0 + and current_duration + >= self._expected_duration * ENDING_HARD_FINALIZE_RATIO + and self._time_below_threshold >= required_quiet + and not self._verified_pause + and not self._window_has_outage_gap(quiet_ts) + ): + self._logger.info( + "Duration-anchored finalize: cycle in ENDING at %.0fs " + "(%.1fx expected %.0fs), quiet %.0fs — Smart Termination " + "was blocked (ambiguous=%s prefix=%s); finalizing.", + current_duration, + current_duration / self._expected_duration, + self._expected_duration, + self._time_below_threshold, + self._match_ambiguous, + self._match_prefix_ambiguous, + ) + self._finish_cycle( + timestamp, + status="completed", + termination_reason=TerminationReason.TIMEOUT, + keep_tail=False, + ) + return + # --- FALLBACK TIMEOUT CHECK --- # Rule: To separate cycles, we must wait at least min_off_gap. effective_off_delay = max(self._config.off_delay, self._config.min_off_gap) @@ -1333,11 +1571,15 @@ class CycleDetector: if self._time_below_threshold >= effective_off_delay: - recent_window = [ - r - for r in self._power_readings - if (timestamp - r[0]).total_seconds() <= gate_window - ] + # Walk from the tail — readings are chronological so we can + # break as soon as we exceed the gate window (O(window) not O(n)). + recent_window = [] + for r in reversed(self._power_readings): + if (timestamp - r[0]).total_seconds() <= gate_window: + recent_window.append(r) + else: + break + recent_window.reverse() if not recent_window: # Check deferred finish for matched profiles @@ -1408,6 +1650,10 @@ class CycleDetector: self._delay_wait_high_start = None self._delay_wait_high_power = None self._preserve_delay_band_on_off = False + # Clear the paused-STARTING true-off accumulator so a later STARTING + # cycle cannot inherit stale hold time and finalize to OFF prematurely + # (this path is also reached via the paused-STARTING cancellation). + self._starting_paused_off_since = None # Reset end spike tracker when entering ENDING state if new_state == STATE_ENDING: @@ -1433,6 +1679,8 @@ class CycleDetector: elif new_state == STATE_STARTING: # Reset idle time if exiting ANTI_WRINKLE to STARTING (high-power burst resumed) self._anti_wrinkle_idle_time = 0.0 + # Fresh STARTING cycle: never inherit a prior cycle's true-off hold. + self._starting_paused_off_since = None elif new_state == STATE_RUNNING: self._delay_band_start = None self._delay_band_seconds = 0.0 @@ -1478,6 +1726,266 @@ class CycleDetector: self._ml_end_cache = (now_ts, exp, start, result) return result + def _window_has_outage_gap(self, window_ts: list[datetime]) -> bool: + """Whether a 'sustained window' contains a data-outage-sized hole. + + The span + coverage checks in the standby / anti-crease window scans accept + e.g. three readings spanning the window even if a long unobserved gap sits + between them (a sensor dropout, or a sparse burst next to one old reading). + Finalizing on such a window could wrongly cut an active cycle, so reject it. + The gap ceiling is the sensor's own data-driven outage threshold + (``energy_gap_threshold_s`` over the full trace), so a change-only sensor's + legitimately-sparse stable stretches (tens of seconds between reports) are + NOT rejected while a genuine dropout is. + """ + if len(window_ts) < 2: + return True # too few points to trust as a sustained window + max_gap = self._outage_threshold_s() + ordered = sorted(t.timestamp() for t in window_ts) + return any((b - a) > max_gap for a, b in itertools.pairwise(ordered)) + + def _outage_threshold_s(self) -> float: + """Sensor-adaptive gap ceiling (seconds): intervals longer than this are + treated as telemetry outages, not observed quiet. Data-driven from the + trace's own cadence (`energy_gap_threshold_s`), so a change-only sensor's + sparse-but-real stable stretches are not mistaken for a dropout. + """ + all_ts = np.array( + [r[0].timestamp() for r in self._power_readings], dtype=float + ) + return energy_gap_threshold_s(all_ts) + + def _is_standby_band_stuck(self, timestamp: datetime) -> bool: + """Whether a RUNNING cycle is stuck on a flat standby plateau (#296). + + Returns True only when ALL of the following hold, so this can never end + an active low-power phase: + + * the device is a wet appliance where a stuck baseline is unambiguously + anomalous (``STANDBY_BAND_FINALIZE_DEVICE_TYPES``); + * a profile is matched and elapsed >= ``STANDBY_BAND_MIN_RATIO`` x the + expected duration (well past when it should have ended); + * not user-paused; + * the most recent >= ``STANDBY_BAND_WINDOW_S`` of readings are ALL at or + below ``STANDBY_BAND_MAX_FRACTION`` of the cycle's own peak power AND + span no more than ``STANDBY_BAND_FLATNESS_FRACTION`` of the peak (a flat + plateau, not fluctuating activity). + + The expensive window scan runs only after the cheap duration gate passes, + so normal cycles never pay for it. + """ + if self._config.device_type not in STANDBY_BAND_FINALIZE_DEVICE_TYPES: + return False + if getattr(self, "_verified_pause", False): + return False + if not (self._matched_profile and self._expected_duration > 0): + return False + start = self._current_cycle_start + if start is None: + return False + current_duration = (timestamp - start).total_seconds() + if current_duration < self._expected_duration * STANDBY_BAND_MIN_RATIO: + return False + peak = float(self._cycle_max_power) + if peak <= 0: + return False + + level_ceiling = peak * STANDBY_BAND_MAX_FRACTION + # Walk the tail; readings are chronological so we can break once outside + # the window (O(window), not O(n)). + window: list[float] = [] + window_ts: list[datetime] = [] + oldest_in_window: datetime | None = None + saw_older = False # a reading older than the window exists -> full coverage + _standby_boundary_ts: datetime | None = None + for ts, p in reversed(self._power_readings): + if (timestamp - ts).total_seconds() <= STANDBY_BAND_WINDOW_S: + window.append(float(p)) + window_ts.append(ts) + oldest_in_window = ts + else: + saw_older = True + _standby_boundary_ts = ts # boundary: last reading before the window + break + # The plateau must actually SPAN the required window (data exists from + # before it), not just a couple of recent samples, and have enough points + # to judge. `saw_older` (rather than an exact span >= WINDOW check) is + # robust to sample phase/granularity: with e.g. 30 s sampling the oldest + # in-window reading is typically only ~570-599 s old, which an exact check + # would wrongly reject. A coverage sanity (oldest >= 90% of the window) + # plus an adjacent-gap check (``_window_has_outage_gap``) guard against a + # sparse burst of samples sitting next to one old reading across a dropout. + if ( + oldest_in_window is None + or not saw_older + or len(window) < 3 + or (timestamp - oldest_in_window).total_seconds() + < STANDBY_BAND_WINDOW_S * 0.9 + or self._window_has_outage_gap( + [_standby_boundary_ts, *window_ts] + if _standby_boundary_ts is not None + else window_ts + ) + ): + return False + hi = max(window) + lo = min(window) + if hi > level_ceiling: + return False # a real active reading in the window - not standby + flatness_limit = max( + STANDBY_BAND_FLATNESS_FLOOR_W, peak * STANDBY_BAND_FLATNESS_FRACTION + ) + if (hi - lo) > flatness_limit: + return False # fluctuating - still doing work + return True + + def _anticrease_gate_open(self, timestamp: datetime) -> bool: + """Core anti-crease gate (#296): everything except the current power level + and the low-power-window check. Shared by the match freeze + (``_in_anticrease_freeze``) and the finalise (``_is_anticrease_tail``). + + True only when a genuinely energetic, confidently-matched cycle for an + anti-wrinkle device is PAST its expected duration - the discriminator that + separates the post-wash anti-crease tail from a mid-wash low-power trough + (a washer spends most of its cycle below ``anti_wrinkle_max_power``, but a + mid-wash trough is always BEFORE the expected duration, the tail after it). + """ + if not self._config.anti_wrinkle_enabled: + return False + if self._config.device_type not in ( + DEVICE_TYPE_WASHING_MACHINE, + DEVICE_TYPE_DRYER, + DEVICE_TYPE_WASHER_DRYER, + ): + return False + if getattr(self, "_verified_pause", False): + return False + if not (self._matched_profile and self._expected_duration > 0): + return False + if self._last_match_confidence < 0.4: + return False + if self._match_ambiguous or self._match_prefix_ambiguous: + return False + if self._cycle_max_power <= float(self._config.anti_wrinkle_max_power): + return False # never a hot/energetic cycle - leave low-power programs alone + start = self._current_cycle_start + if start is None: + return False + current_duration = (timestamp - start).total_seconds() + if current_duration < self._expected_duration * ANTI_CREASE_FINALIZE_RATIO: + return False + return True + + def _in_anticrease_freeze(self, timestamp: datetime) -> bool: + """Whether match updates should be frozen (#296): the anti-crease gate is + open AND the most recent reading is in the low-power regime (at or below + ``anti_wrinkle_max_power``). + + Deliberately lighter than ``_is_anticrease_tail`` - it does NOT wait for the + full ``ANTI_CREASE_CONFIRM_WINDOW_S``, so the confident pre-tail match is + preserved from the instant the cycle crosses its expected duration in a + low-power state, before the window accrues. Without this a match that + degrades to ambiguous within the first window's worth of tail would + deadlock both the freeze and the finalise (both require an unambiguous + match). Self-correcting: a heating burst above ``anti_wrinkle_max_power`` + leaves the regime and re-arms matching. + """ + if not self._power_readings: + return False + if float(self._power_readings[-1][1]) > float( + self._config.anti_wrinkle_max_power + ): + return False + return self._anticrease_gate_open(timestamp) + + def _is_anticrease_tail(self, timestamp: datetime) -> bool: + """Whether a matched, past-expected cycle has settled into the anti-crease + tumble tail (#296) - the trigger for the finalise into STATE_ANTI_WRINKLE. + + Miele-style "Knitterschutz": after the wash proper ends, the machine holds + a constant baseline plus periodic sub-``anti_wrinkle_max_power`` tumble + bursts (no heating) until the door is opened. Because those bursts recur + faster than off_delay they keep reviving the cycle out of ENDING, so the + normal power-off path never finalises it and STATE_ANTI_WRINKLE - which is + built to absorb the tail and split off the next wash - never engages. + Recognising the tail lets us finalise into anti-wrinkle directly. + + Requires the core gate (``_anticrease_gate_open``) AND that the most recent + >= ``ANTI_CREASE_CONFIRM_WINDOW_S`` of readings are ALL at or below + ``anti_wrinkle_max_power`` (we are in the low-power tail, clear of the final + spin and not mid-heating). The expensive window scan runs only after the + cheap gate passes, so normal cycles never pay for it. + """ + if not self._anticrease_gate_open(timestamp): + return False + max_power = float(self._config.anti_wrinkle_max_power) + # Walk the tail; readings are chronological so we can break once outside the + # window (O(window), not O(n)). + window: list[float] = [] + window_ts: list[datetime] = [] + oldest_in_window: datetime | None = None + saw_older = False + _ac_boundary_ts: datetime | None = None + for ts, p in reversed(self._power_readings): + if (timestamp - ts).total_seconds() <= ANTI_CREASE_CONFIRM_WINDOW_S: + window.append(float(p)) + window_ts.append(ts) + oldest_in_window = ts + else: + saw_older = True + _ac_boundary_ts = ts # boundary: last reading before the window + break + # The low-power tail must actually SPAN the window (data exists from before + # it) and have enough points to judge - not just a couple of recent samples. + # ``saw_older`` plus a coverage sanity and an adjacent-gap check + # (``_window_has_outage_gap``) is robust to sample phase/granularity while + # rejecting a dropout-sized hole (mirrors _is_standby_band_stuck). + if ( + oldest_in_window is None + or not saw_older + or len(window) < 3 + or (timestamp - oldest_in_window).total_seconds() + < ANTI_CREASE_CONFIRM_WINDOW_S * 0.9 + or self._window_has_outage_gap( + [_ac_boundary_ts, *window_ts] + if _ac_boundary_ts is not None + else window_ts + ) + ): + return False + if max(window) > max_power: + return False # a heating / high-spin reading in the window - still washing + return True + + def _maybe_finalize_anticrease_tail(self, timestamp: datetime) -> bool: + """Finalise a cycle that has entered the anti-crease tail into + STATE_ANTI_WRINKLE (#296). Returns True if the cycle was finalised. + + Shared by the RUNNING / PAUSED / ENDING branches so the finalise fires no + matter which state a burst left the detector in. Uses Smart Termination + (in ``ANTI_WRINKLE_ELIGIBLE_REASONS``) so ``_finish_cycle`` routes into + STATE_ANTI_WRINKLE, which then absorbs the tail and splits off any next + wash on its first heating burst above ``anti_wrinkle_max_power``. + """ + if not self._is_anticrease_tail(timestamp): + return False + start_time = self._current_cycle_start or timestamp + current_duration = (timestamp - start_time).total_seconds() + self._logger.info( + "Anti-crease finalize: matched '%s' past expected %.0fs (elapsed %.0fs), " + "settled into the low-power tumble tail — finalizing into anti-wrinkle.", + self._matched_profile, + self._expected_duration, + current_duration, + ) + self._finish_cycle( + timestamp, + status="completed", + termination_reason=TerminationReason.SMART, + keep_tail=True, + ) + return True + def _is_terminal_drop(self) -> bool: """Whether the current low-power event is an anomalously-early hard drop. diff --git a/custom_components/ha_washdata/frontend.py b/custom_components/ha_washdata/frontend.py index 429896c..8b02c94 100644 --- a/custom_components/ha_washdata/frontend.py +++ b/custom_components/ha_washdata/frontend.py @@ -56,10 +56,24 @@ class LovelaceResourceItem(TypedDict, total=False): def get_cache_buster(filename: str = CARD_NAME) -> str: - """Generate a stable cache buster based on a www asset's mtime.""" + """Generate a stable cache buster based on a www asset's mtime. + + Also considers the translations/panel/ directory mtime so that + translation-only releases (e.g. GitLocalize merges) still bust the + browser cache for both the panel JS and the per-language JSON files. + """ try: - src = Path(__file__).parent / "www" / filename - return str(int(os.path.getmtime(src))) + base = Path(__file__).parent + src_mtime = os.path.getmtime(base / "www" / filename) + try: + panel_dir = base / "translations" / "panel" + trans_mtime = max( + (os.path.getmtime(f) for f in panel_dir.iterdir() if f.is_file()), + default=0.0, + ) + except OSError: + trans_mtime = 0.0 + return str(int(max(src_mtime, trans_mtime))) except OSError: # Deterministic fallback when file is unavailable. return "1" diff --git a/custom_components/ha_washdata/learning.py b/custom_components/ha_washdata/learning.py index bf3e058..3ad3174 100644 --- a/custom_components/ha_washdata/learning.py +++ b/custom_components/ha_washdata/learning.py @@ -140,6 +140,7 @@ class LearningManager: self._sample_interval_model = StatisticalModel(max_samples=200) self._last_suggestion_update: datetime | None = None self._last_batch_simulation_count: int = 0 # track when to re-run batch + self._last_suggestions_labeled_count: int = 0 # gate model/detection passes def _apply_suggestions_and_notify(self, suggestions: dict[str, Any]) -> None: """Apply suggestions that pass quality gates.""" @@ -232,9 +233,17 @@ class LearningManager: predicted_duration: Expected duration in seconds match_result: MatchResult from profile_store.async_match_profile() (optional) """ - # 1. Trigger background simulation to find optimal parameters for this cycle - if cycle_data.get("power_data"): - # Offload to executor since simulation can be heavy + # 1. Trigger single-cycle simulation — only for cleanly-completed, labeled, + # non-noise cycles. Skipping force_stopped/unlabeled/noise avoids deriving + # start/stop thresholds from mis-detected or truncated cycles. + _profile = detected_profile or cycle_data.get("profile_name") + _is_clean = ( + cycle_data.get("power_data") + and _profile + and _profile != "noise" + and cycle_data.get("status") == "completed" + ) + if _is_clean: self.hass.async_create_task(self._async_run_simulation(cycle_data)) # 2. Check if we should request feedback @@ -242,11 +251,20 @@ class LearningManager: cycle_data, detected_profile, confidence, predicted_duration, match_result ) - # 3. Update model-based suggestions (durations etc) - self._update_model_suggestions() - - # 3b. Update statistical detection suggestions (thresholds, gates, etc.) - self._update_detection_suggestions() + # 3+3b. Heavy per-profile suggestion passes — only run when the labeled + # cycle count has grown since the last update (skips passes for unlabeled / + # noise / duplicate ends with no new data). + labeled_count = sum( + 1 for c in self.profile_store.get_past_cycles() + if isinstance(c, dict) + and c.get("profile_name") + and c.get("profile_name") != "noise" + and c.get("status") in ("completed", "force_stopped") + ) + if labeled_count > self._last_suggestions_labeled_count: + self._last_suggestions_labeled_count = labeled_count + self._update_model_suggestions() + self._update_detection_suggestions() # 4. Run multi-cycle batch simulation when enough new labeled cycles have accumulated self._maybe_run_batch_simulation() @@ -517,18 +535,27 @@ class LearningManager: # even if the matcher was confident. Downgrade to a feedback request so # the user can verify the match; this catches confident but wrong labels. ml_quality = cycle_data.get("ml_quality_score") - ml_suspicious = ( - isinstance(ml_quality, float) - and ml_quality >= ML_QUALITY_SUSPICIOUS_THRESHOLD - ) + # Use float() so numpy scalars (float32/float64) returned by resolve_scorer + # are accepted — isinstance(numpy_float, float) is False in NumPy ≥ 2.0. + # Wrap in try/except so non-numeric sentinel values are silently ignored. + try: + ml_suspicious = ( + ml_quality is not None + and float(ml_quality) >= ML_QUALITY_SUSPICIOUS_THRESHOLD + ) + except (TypeError, ValueError): + ml_suspicious = False # Also downgrade when the cycle's power trace is mostly outside the # profile envelope band (low conformance = the shape matched but the # actual power levels are inconsistent with the profile). _conformance = cycle_data.get("envelope_conformance") - envelope_suspicious = ( - isinstance(_conformance, float) - and _conformance < 0.40 - ) + try: + envelope_suspicious = ( + _conformance is not None + and float(_conformance) < 0.40 + ) + except (TypeError, ValueError): + envelope_suspicious = False if route_conf >= auto_label_conf: if ml_suspicious or envelope_suspicious: if ml_suspicious: diff --git a/custom_components/ha_washdata/manager.py b/custom_components/ha_washdata/manager.py index 6ef829e..da1c1c4 100644 --- a/custom_components/ha_washdata/manager.py +++ b/custom_components/ha_washdata/manager.py @@ -25,7 +25,9 @@ import hashlib import inspect import math import uuid +import asyncio from asyncio import Task +from collections.abc import Coroutine from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, cast import numpy as np @@ -380,6 +382,7 @@ class WashDataManager: self._notify_finish_services: list[str] = [] self._notify_live_services: list[str] = [] self._notify_actions: list[dict[str, Any]] = [] + self._notify_script: Any = None # cached Script; invalidated on options reload self._notify_people: list[str] = [] self._notify_cycle_timers: list[dict[str, Any]] = [] self._fired_cycle_timers: set[int] = set() @@ -485,6 +488,12 @@ class WashDataManager: self._sample_intervals: list[float] = [] self._sample_interval_stats: dict[str, Any] = {} self._matching_task: Task[Any] | None = None + self._cycle_end_task: Task[Any] | None = None + # Detached store-touching tasks (matching trigger, active-cycle clear, + # post-cycle processing) tracked so async_shutdown can cancel them before a + # reload/unload swaps the ProfileStore out from under them. + self._background_tasks: set[Task[Any]] = set() + self._is_shutdown: bool = False self._last_state_save = 0.0 self._last_cycle_end_time: datetime | None = None self._remove_state_expiry_timer = None @@ -503,7 +512,9 @@ class WashDataManager: self.entry_id, min_duration_ratio=config_entry.options.get( CONF_PROFILE_MATCH_MIN_DURATION_RATIO, - DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO, + DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get( + self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO + ), ), max_duration_ratio=config_entry.options.get( CONF_PROFILE_MATCH_MAX_DURATION_RATIO, @@ -769,7 +780,7 @@ class WashDataManager: # Snapshotted for thread safety indirectly by task logic # We don't need a wrapper task if we unify with _update_estimates matching # but for now let's keep the detector callback as a trigger - self.hass.async_create_task(self._async_perform_combined_matching(readings)) + self._spawn_tracked(self._async_perform_combined_matching(readings)) return (None, 0.0, 0.0, None) self.detector = CycleDetector( @@ -2060,7 +2071,9 @@ class WashDataManager: new_min_ratio = float( config_entry.options.get( CONF_PROFILE_MATCH_MIN_DURATION_RATIO, - DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO, + DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get( + self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO + ), ) ) new_max_ratio = float( @@ -2074,6 +2087,10 @@ class WashDataManager: self.profile_store.set_duration_ratio_limits( min_ratio=new_min_ratio, max_ratio=new_max_ratio ) + # Keep the detector's copy in sync: _should_defer_finish() reads + # detector.config.min_duration_ratio, which otherwise keeps the + # construction-time value until a restart (diverging from the matcher). + self.detector.config.min_duration_ratio = new_min_ratio self._logger.info( "Updated duration ratios: min %.2f→%.2f, max %.2f→%.2f", old_min_ratio, @@ -2106,6 +2123,7 @@ class WashDataManager: self._notify_actions = list( cast(list[dict[str, Any]], config_entry.options.get(CONF_NOTIFY_ACTIONS, []) or []) ) + self._notify_script = None self._notify_people = list( config_entry.options.get(CONF_NOTIFY_PEOPLE, []) or [] ) @@ -2200,8 +2218,43 @@ class WashDataManager: self._logger.info("Configuration reloaded successfully") + def _spawn_tracked(self, coro: Coroutine[Any, Any, Any]) -> Task[Any]: + """Create a detached task and track it so shutdown can cancel it. + + Use for fire-and-forget tasks that touch the ProfileStore (matching + trigger, active-cycle clear, post-cycle processing): if a reload/unload + swaps the store out mid-flight, an untracked task would keep writing to the + stale store. The task auto-removes itself from the set when it finishes. + """ + task = self.hass.async_create_task(coro) + # Real HA always returns a Task; guard for degenerate returns (e.g. a + # mocked hass in tests) so tracking never breaks the caller. + if task is not None and hasattr(task, "add_done_callback"): + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + return task + async def async_shutdown(self) -> None: """Shutdown.""" + self._is_shutdown = True + # Cancel in-flight matching and cycle-end tasks so they don't race a + # freshly-loaded ProfileStore on reload_config_entry. + _to_await: list[Task[Any]] = [] + if self._matching_task and not self._matching_task.done(): + self._matching_task.cancel() + _to_await.append(self._matching_task) + if self._cycle_end_task and not self._cycle_end_task.done(): + self._cycle_end_task.cancel() + _to_await.append(self._cycle_end_task) + # Cancel every other tracked detached task (matching trigger, active-cycle + # clear, post-cycle processing) for the same reason. + for task in list(self._background_tasks): + if not task.done(): + task.cancel() + _to_await.append(task) + # Drain cancelled tasks so they don't race the freshly-reloaded ProfileStore. + if _to_await: + await asyncio.gather(*_to_await, return_exceptions=True) if self._remove_listener: self._remove_listener() if self._remove_external_trigger_listener: @@ -2759,10 +2812,32 @@ class WashDataManager: now = dt_util.now() - # Throttle updates to avoid CPU overload on noisy sensors - # BUT always allow updates if power is below min_power (critical end-of-cycle signal). + # Throttle updates to avoid CPU overload on noisy sensors. + # Low-power readings bypass throttling when: + # (a) a cycle is active (RUNNING/ENDING/PAUSED) — critical end-of-cycle signal, or + # (b) this is a genuine power DROP from above min_power — captures power-off events + # that occur before the detector has processed the previous above-threshold reading. + # Without the guard, an idle device at 0W fires an update on every sensor poll (typically + # every 1–5 s), flooding the detector with zero-value no-ops. min_p = float(self.detector.config.min_power) - is_low_power = power < min_p + # For the "genuine drop" bypass, compare against the previous RAW sensor + # value (old_state), not _current_power: the latter is only updated after a + # reading passes the throttle, so a suppressed high reading would leave it + # low and the following low reading would be throttled too, missing a short + # high->low transition. old_state reflects the plug's actual prior value. + prev_raw_power = self._current_power + old_state = cast(State | None, event_data.get("old_state")) + if old_state is not None and old_state.state not in ( + STATE_UNKNOWN, STATE_UNAVAILABLE + ): + try: + prev_raw_power = float(old_state.state) + except ValueError: + pass + is_low_power = power < min_p and ( + self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING) + or prev_raw_power >= min_p # genuine drop from active power + ) if ( not is_low_power @@ -3191,6 +3266,31 @@ class WashDataManager: self._notify_update() return + # Secondary zombie guard for unmatched cycles (expected == 0 when no profile + # has been matched). The detector hard-caps RUNNING at 8h but a chatty sensor + # that never goes silent can keep the watchdog from reaching the end state. + # Kill here after 4h so a stuck false-start doesn't run indefinitely. + elif ( + expected == 0 + and not self._is_user_paused + and not _verified_pause_zombie + and elapsed > 14400 + # Gate on the active detector state, not _current_program: the latter is + # only set to "detecting..." on the RUNNING transition, so a cycle stuck + # in STARTING keeps a stale program and would never hit this 4h guard. + and self.detector.state in ( + STATE_STARTING, STATE_RUNNING, STATE_PAUSED, STATE_ENDING + ) + ): + self._logger.warning( + "Watchdog: Unmatched cycle exceeded 4h (%.0fs). Force-ending.", + elapsed, + ) + self.detector.force_end(now) + self._current_power = 0.0 + self._notify_update() + return + # 1. GHOST CYCLE SUPPRESSOR # If we are "detecting" for more than 10 minutes and haven't seen an update for 5 minutes, # it's likely a pump-out spike or an accidental start (ghost cycle). @@ -3457,6 +3557,14 @@ class WashDataManager: }, ) self._start_event_fired = True + # Mark the start fully handled ONLY when there is no push to send, so + # the restart-recovery fallback does not re-enter for event-only + # configs. When a push service/action IS configured, leave + # _notified_start False so the push block below still fires: a config + # with both events and push must get both (event delivery is tracked + # separately by _start_event_fired). + if not (self._notify_start_services or self._notify_actions): + self._notified_start = True # Fire push notification immediately - do not wait for profile matching. if not self._notified_start and (self._notify_start_services or self._notify_actions): @@ -3512,7 +3620,7 @@ class WashDataManager: stranded in a terminal state with a stale active snapshot until the next cycle. Deliberately does NOT persist, notify, or run the learning pipeline. """ - self.hass.async_create_task(self.profile_store.async_clear_active_cycle()) + self._spawn_tracked(self.profile_store.async_clear_active_cycle()) # Anchor the terminal state so _handle_state_expiry (and power-off) can act, # then arm the expiry timer that resets terminal -> Off after the reset delay. self._cycle_completed_time = dt_util.now() @@ -3606,9 +3714,10 @@ class WashDataManager: # detector into a fresh RUNNING before post-processing completes). See B1 in # _async_process_cycle_end. end_token = self._ranking_snapshot_cycle_id - self.hass.async_create_task( - self._async_process_cycle_end(cycle_data, cycle_token=end_token) - ) + if not self._is_shutdown: + self._cycle_end_task = self._spawn_tracked( + self._async_process_cycle_end(cycle_data, cycle_token=end_token) + ) def _ml_end_confidence( self, points: list[tuple[float, float]], expected_duration: float @@ -3811,7 +3920,11 @@ class WashDataManager: self._logger, ) - def _compute_cycle_quality_score(self, cycle_data: dict[str, Any]) -> None: + def _compute_cycle_quality_score( + self, + cycle_data: dict[str, Any], + past_cycles_snapshot: list[dict[str, Any]] | None = None, + ) -> None: """Score a just-finished cycle with the hybrid_curve_quality model (opt-in). When ML models are enabled for this device, computes P(cycle is a problem) @@ -3842,7 +3955,16 @@ class WashDataManager: durations: list[float] = [] energies: list[float] = [] peaks: list[float] = [] - for c in self.profile_store.get_past_cycles(): + # This function runs in an executor thread and the event loop may + # append cycles concurrently; iterating (or even copying) the live list + # here is a data race. Prefer the snapshot taken on the event loop at + # the call site; only fall back to a local copy for direct callers. + cycles = ( + past_cycles_snapshot + if past_cycles_snapshot is not None + else list(self.profile_store.get_past_cycles()) + ) + for c in cycles: if c.get("profile_name") != profile_name: continue if c.get("duration") is not None: @@ -4265,8 +4387,12 @@ class WashDataManager: from .ml.engine import ml_models_enabled # noqa: PLC0415 if ml_models_enabled(self.config_entry.options): + # Snapshot past_cycles on the event loop before offloading: iterating + # (or copying) the live list inside the executor races the loop + # appending this just-finished cycle. + past_cycles_snapshot = list(self.profile_store.get_past_cycles()) await self.hass.async_add_executor_job( - self._compute_cycle_quality_score, cycle_data + self._compute_cycle_quality_score, cycle_data, past_cycles_snapshot ) # Add cycle to store immediately (still sync but offloadable parts optimized @@ -4331,10 +4457,10 @@ class WashDataManager: # If a new cycle started during the awaits above, it now owns the active # snapshot; clearing it here would strip the new cycle's restart-resilience. if cycle_token is None or self._ranking_snapshot_cycle_id == cycle_token: - self.hass.async_create_task(self.profile_store.async_clear_active_cycle()) + self._spawn_tracked(self.profile_store.async_clear_active_cycle()) # Auto post-process: merge fragmented cycles from last 3 hours - self.hass.async_create_task(self._run_post_cycle_processing()) + self._spawn_tracked(self._run_post_cycle_processing()) # Prepare cycle data for event (enrich if needed) # IMPORTANT: Exclude large fields to prevent exceeding HA's 32KB event data limit @@ -5079,33 +5205,52 @@ class WashDataManager: if not actions: return False - try: - script = script_helper.Script( - self.hass, - actions, - name=f"{self.config_entry.title} notification", - domain=DOMAIN, - logger=_LOGGER, - ) - except (ValueError, TypeError, HomeAssistantError) as err: - self._logger.error( - "Invalid notification action configuration for %s: %s", - self.config_entry.title, - err, - ) - return False - except Exception as err: - self._logger.exception( - "Unexpected error while building notification actions for %s: %s", - self.config_entry.title, - err, - ) - return False + if self._notify_script is None: + try: + self._notify_script = script_helper.Script( + self.hass, + actions, + name=f"{self.config_entry.title} notification", + domain=DOMAIN, + logger=_LOGGER, + ) + except (ValueError, TypeError, HomeAssistantError) as err: + self._logger.error( + "Invalid notification action configuration for %s: %s", + self.config_entry.title, + err, + ) + return False + except Exception as err: + self._logger.exception( + "Unexpected error while building notification actions for %s: %s", + self.config_entry.title, + err, + ) + return False + script = self._notify_script try: - self.hass.async_create_task( + action_task = self.hass.async_create_task( script.async_run(variables, context=Context()) ) + # This method is synchronous, so the script runs fire-and-forget: a + # failure inside async_run() would otherwise land after we return True + # and be swallowed. Surface it via a done-callback so an action-only + # setup at least logs the drop. (A user-visible fallback would require + # awaiting, i.e. making the whole notification-dispatch chain async.) + def _log_action_failure(task: Task[Any]) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + self._logger.warning( + "Notification action execution failed for %s: %s", + self.config_entry.title, + exc, + ) + + action_task.add_done_callback(_log_action_failure) return True except HomeAssistantError as err: self._logger.warning( @@ -6118,7 +6263,7 @@ class WashDataManager: @property def samples_recorded(self): """Return the number of power samples recorded in current cycle.""" - return len(self.detector.get_power_trace()) + return self.detector.samples_recorded @property def sample_interval_stats(self): diff --git a/custom_components/ha_washdata/manifest.json b/custom_components/ha_washdata/manifest.json index a96b265..c9ff7f6 100644 --- a/custom_components/ha_washdata/manifest.json +++ b/custom_components/ha_washdata/manifest.json @@ -21,5 +21,5 @@ "requirements": [ "numpy" ], - "version": "0.5.1" + "version": "0.5.2" } \ No newline at end of file diff --git a/custom_components/ha_washdata/ml/engine.py b/custom_components/ha_washdata/ml/engine.py index bd107c2..748657c 100644 --- a/custom_components/ha_washdata/ml/engine.py +++ b/custom_components/ha_washdata/ml/engine.py @@ -116,6 +116,28 @@ def resolve_scorer(capability: str, store: object | None): # classifier and regression capability keys are disjoint today, but this # guard keeps it safe if a key were ever reused. if isinstance(spec, dict) and spec.get("kind") != "standardized_linear": + # Feature-column schema guard: a spec promoted under an older + # FEATURE_COLUMNS must be dropped rather than silently scoring on a + # stale/neutral-filled schema. The call-time guard already catches + # shape mismatches, but this catches them at load time and logs + # clearly, so users see a single warm-up warning instead of a + # per-inference warning storm. + module_name = _MODEL_MODULES.get(capability) + if module_name is not None: + try: + _bm = importlib.import_module(f"{__package__}.{module_name}") + _expected = list(getattr(_bm, "FEATURE_COLUMNS", [])) + _stored = list(spec.get("feature_columns") or []) + if _expected and _stored and _stored != _expected: + _LOGGER.warning( + "Promoted spec for %r has stale feature schema " + "(%d cols vs current %d); reverting to baseline.", + capability, len(_stored), len(_expected), + ) + return _baseline() + except Exception: # noqa: BLE001 - schema check must not break inference + pass + from .trainer import score_spec def _on_device_score(feats, _s=spec): @@ -165,6 +187,21 @@ def resolve_regressor(capability: str, store: object | None): record = versions.get(capability) spec = record.get("spec") if isinstance(record, dict) else None if isinstance(spec, dict) and spec.get("kind") == "standardized_linear": + # Feature-column schema guard for regression specs. + try: + from .feature_extraction import PROGRESS_FEATURE_COLUMNS + _expected_r = list(PROGRESS_FEATURE_COLUMNS) + _stored_r = list(spec.get("feature_columns") or []) + if _expected_r and _stored_r and _stored_r != _expected_r: + _LOGGER.warning( + "Promoted regression spec for %r has stale feature schema " + "(%d cols vs current %d); reverting to inert.", + capability, len(_stored_r), len(_expected_r), + ) + return (None, None) + except Exception: # noqa: BLE001 - schema check must not break inference + pass + from .trainer import predict_value_spec def _on_device_predict(feats, _s=spec): diff --git a/custom_components/ha_washdata/ml/training_task.py b/custom_components/ha_washdata/ml/training_task.py index 3fd8aec..906ab41 100644 --- a/custom_components/ha_washdata/ml/training_task.py +++ b/custom_components/ha_washdata/ml/training_task.py @@ -469,7 +469,9 @@ def _train_regression_capability( } preds = np.clip(T.predict_matrix_spec(spec_probe, X_te), 0.0, 1.0) metrics = T.regression_metrics(y_te, preds) - model_mae = float(metrics.get("mae") or 1.0) + # Explicit None check — `or 1.0` would coerce MAE=0.0 to 1.0, rejecting a + # perfect regressor and blocking promotion. + model_mae = float(metrics.get("mae") if metrics.get("mae") is not None else 1.0) naive_col = columns.index("elapsed_over_expected") if "elapsed_over_expected" in columns else 0 naive = np.clip(X_te[:, naive_col], 0.0, 1.0) diff --git a/custom_components/ha_washdata/playground.py b/custom_components/ha_washdata/playground.py index a2e1cf0..2d66238 100644 --- a/custom_components/ha_washdata/playground.py +++ b/custom_components/ha_washdata/playground.py @@ -107,6 +107,10 @@ DEFAULT_RECENT_CYCLES = 20 MAX_BATCH_CYCLES = 50 # Cap the per-cycle event log so a pathological trace cannot bloat the payload. MAX_EVENTS_PER_CYCLE = 300 +# Cap the per-cycle timeline series so a very long cycle (4h dishwasher = ~2800 pts) +# does not bloat the task result. Points are thinned at finalize time — evenly-spaced, +# so the shape is preserved rather than truncated. +MAX_SERIES_PER_CYCLE = 600 # Override keys the Playground honours, mapped to CycleDetectorConfig fields. # Only detection-relevant knobs matter; everything else in settings_override is @@ -232,7 +236,7 @@ def _build_match_snapshots( ) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, list[str]], dict[str, Any]]: """Prepare the matcher snapshots + config once from the store. - Mirrors :meth:`ProfileStore.match_profile`: one snapshot per profile using + Mirrors the store's async matching path: one snapshot per profile using its sample cycle's decompressed trace, plus the store's live matching config (with any on-device tuned weight overrides merged in). @@ -357,7 +361,7 @@ def _readings_from_cycle( _PROGRESS_STATES = (STATE_RUNNING, STATE_PAUSED, STATE_ENDING) # States in which no progress estimate is shown (mirrors _update_remaining_only). _DEAD_STATES = (STATE_OFF, STATE_UNKNOWN, STATE_IDLE) -_SIM_SERIES_THROTTLE_S = 5.0 # production recomputes progress every 5s +_SIM_SERIES_THROTTLE_S = 30.0 # cap estimator calls; 5s matched cadence made this a no-op def simulate_cycle_detail( @@ -954,12 +958,20 @@ class _DetailSim: alerts.append({"code": "underrun", "severity": "warn", "detail": f"Finished at {ratio:.0%} of typical duration."}) + series = self.series + if len(series) > MAX_SERIES_PER_CYCLE: + # Thin evenly so the shape is preserved (first + last always kept). + # Span (len-1)/(N-1) so the final index lands on the true last point + # (a plain len/N tops out below it and drops the terminal sample). + step = (len(series) - 1) / (MAX_SERIES_PER_CYCLE - 1) + series = [series[round(i * step)] for i in range(MAX_SERIES_PER_CYCLE)] + 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, + "series": series, "events": self.events, "alerts": alerts, "outcome": outcome, @@ -1030,10 +1042,13 @@ def _run_rows( settings_override: dict[str, Any] | None, options: dict[str, Any], price: float | None, + prebuilt: tuple[Any, Any, Any, Any] | None = None, ) -> list[dict[str, Any]]: # Snapshots are store-derived (independent of the cycle and the detector-level # settings_override), so build them ONCE and reuse across all cycles/values. - prebuilt = _build_match_snapshots(store) + # Callers that drive many chunks should pass prebuilt= to avoid rebuilding per chunk. + if prebuilt is None: + prebuilt = _build_match_snapshots(store) rows: list[dict[str, Any]] = [] for cycle in cycles: detail = simulate_cycle_detail( @@ -1054,6 +1069,7 @@ def run_playground_history( options: dict[str, Any] | None, price: float | None, concurrency: int, + prebuilt: tuple[Any, Any, Any, Any] | None = None, ) -> dict[str, Any]: """Per-cycle rows for the Test-on-history table, plus a before/after diff when ``settings_override`` is set. Executor-safe; never raises.""" @@ -1076,11 +1092,11 @@ def run_playground_history( selected = selected[:concurrency] override = settings_override or None - rows = _run_rows(store, selected, base_config, override, options, price) + rows = _run_rows(store, selected, base_config, override, options, price, prebuilt) payload: dict[str, Any] = {"rows": rows, "summary": _rows_summary(rows)} if override: - base_rows = _run_rows(store, selected, base_config, None, options, price) + base_rows = _run_rows(store, selected, base_config, None, options, price, prebuilt) payload["baseline_rows"] = base_rows payload["baseline_summary"] = _rows_summary(base_rows) payload["diff"] = _diff_rows(base_rows, rows) @@ -1272,6 +1288,7 @@ def run_playground_sweep( concurrency: int, param_y: str | None = None, values_y: list[float] | None = None, + prebuilt: tuple[Any, Any, Any, Any] | None = None, ) -> dict[str, Any]: """Sweep one param (1D curve) or two params (2D heatmap) and score each point by ``objective`` computed from the per-cycle rows. Executor-safe; never raises. @@ -1296,7 +1313,7 @@ def run_playground_sweep( selected = selected[:concurrency] def _metric_for(override: dict[str, Any]) -> tuple[float | None, dict[str, Any]]: - rows = _run_rows(store, selected, base_config, override, options, price) + rows = _run_rows(store, selected, base_config, override, options, price, prebuilt) return objective_metric(rows, objective), _rows_summary(rows) current_x = _sim_config_summary(base_config).get( diff --git a/custom_components/ha_washdata/profile_store.py b/custom_components/ha_washdata/profile_store.py index 6a7e7f4..f78e53c 100644 --- a/custom_components/ha_washdata/profile_store.py +++ b/custom_components/ha_washdata/profile_store.py @@ -3081,9 +3081,16 @@ class ProfileStore: separate ``reference_cycles`` list so imported cycles never enter usage stats. """ dest = self._data["past_cycles"] if target is None else target - # Generate SHA256 ID + # Generate SHA256 ID — dedup suffix avoids collisions when two cycles share + # an identical raw start_time + duration (e.g. bulk reference-cycle imports). + existing_ids = {c.get("id") for c in dest if isinstance(c, dict)} unique_str = f"{cycle_data['start_time']}_{cycle_data['duration']}" - cycle_data["id"] = hashlib.sha256(unique_str.encode()).hexdigest()[:12] + candidate = hashlib.sha256(unique_str.encode()).hexdigest()[:12] + suffix = 0 + while candidate in existing_ids: + suffix += 1 + candidate = hashlib.sha256(f"{unique_str}_{suffix}".encode()).hexdigest()[:12] + cycle_data["id"] = candidate # Preserve profile_name if already set by manager; default to None otherwise if "profile_name" not in cycle_data: @@ -4814,63 +4821,6 @@ class ProfileStore: is_prefix_ambiguous=is_prefix_ambiguous, ) - def match_profile( - self, power_data: list[tuple[str, float]], duration: float - ) -> MatchResult: - """Synchronous wrapper for matching (for use in executor tasks).""" - # Convert to list for worker - p_list = [p[1] for p in power_data] - - # Prepare snapshots safely - snapshots: list[dict[str, Any]] = [] - # Accessing self._data in thread is generally safe for reads if not modifying - for name, profile in self._data["profiles"].items(): - sample_id = profile.get("sample_cycle_id") - sample_cycle = next((c for c in self._data["past_cycles"] if c["id"] == sample_id), None) - if not sample_cycle: - continue - - # Decompress sample data - sample_p_data = decompress_power_data(sample_cycle) - if not sample_p_data: - continue - - snapshots.append({ - "name": name, - "avg_duration": profile.get("avg_duration", sample_cycle.get("duration", 0)), - "sample_power": [x[1] for x in sample_p_data], - }) - - config = { - "min_duration_ratio": self._min_duration_ratio, - "max_duration_ratio": self._max_duration_ratio, - "dtw_bandwidth": self.dtw_bandwidth, - # On-device tuned scoring weights (opt-in); empty = shipped defaults. - **self._matching_overrides(), - } - - candidates = analysis.compute_matches_worker( - p_list, duration, cast(Any, snapshots), config - ) - - if not candidates: - return MatchResult(None, 0.0, 0.0, None, [], False, 0.0) - - best = candidates[0] - - margin, is_ambiguous = _ambiguity_from_candidates(candidates) - - return MatchResult( - best["name"], - best["score"], - best["profile_duration"], - None, - candidates, - is_ambiguous, - margin, - ranking=candidates, - ) - async def async_verify_alignment( self, profile_name: str, @@ -4952,23 +4902,34 @@ class ProfileStore: if not phases: return None + # Guard against non-dict entries (legacy/malformed storage) and + # None/non-numeric start values before sorting. phases_sorted = sorted( - phases, - key=lambda p: float(p.get("start", 0)), + [p for p in phases if isinstance(p, dict)], + key=lambda p: float(p.get("start") or 0), ) + last_matched: str | None = None for phase in phases_sorted: - p_start = phase.get("start", 0) - p_end = phase.get("end", 0) + p_start = float(phase.get("start") or 0) + p_end = float(phase.get("end") or 0) + if p_end <= p_start: + # Missing or zero end — phase range is invalid; skip silently. + continue if p_start <= duration <= p_end: return str(phase.get("name", "Unknown")) + if p_start <= duration: + # Track the last phase whose start we have passed; used for gap fallback. + last_matched = str(phase.get("name", "Unknown")) - # If duration is outside explicit bounds, keep a phase label anyway so - # entities avoid falling back to generic running/starting states. + # Duration is outside explicit bounds — keep a label so entities avoid + # falling back to generic running/starting states. if phases_sorted: - if duration < float(phases_sorted[0].get("start", 0)): + if duration < float(phases_sorted[0].get("start") or 0): return str(phases_sorted[0].get("name", "Unknown")) - return str(phases_sorted[-1].get("name", "Unknown")) + # Return the phase that just ended (last_matched) when in a gap, or the + # final phase when past all ranges, rather than always the absolute last. + return last_matched or str(phases_sorted[-1].get("name", "Unknown")) return None @@ -5835,6 +5796,19 @@ class ProfileStore: cycle["sampling_interval"] = round(sampling_interval, 1) cycle["duration"] = new_duration + # Recompute energy and max_power from the trimmed trace so stored stats + # reflect the kept window (not the pre-trim full cycle). + ts_s = np.array([r[0] for r in renorm], dtype=float) + p_s = np.array([r[1] for r in renorm], dtype=float) + if len(ts_s) > 1: + cycle["energy_wh"] = round( + integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3 + ) + cycle["max_power"] = round(float(np.max(p_s)), 1) + elif len(ts_s) == 1: + cycle["energy_wh"] = 0.0 + cycle["max_power"] = round(float(p_s[0]), 1) + # Keep end_time consistent with the updated start_time and duration new_start_ts = _value_to_timestamp(cycle.get("start_time")) if new_start_ts is not None: @@ -5986,38 +5960,55 @@ class ProfileStore: return [] cycle = cycles[idx] - cycles.pop(idx) # Remove original - new_ids: list[str] = [] - original_profile = cycle.get("profile_name") + # Validate before mutating — early returns below this point would otherwise + # permanently delete the source cycle from the live list. start_dt_base_parsed = _parse_start_dt(cycle["start_time"]) if not start_dt_base_parsed: return [] - start_ts = start_dt_base_parsed.timestamp() - - # Decompress original data p_data_tuples = decompress_power_data(cycle) if not p_data_tuples: return [] + # Pre-materialise all segment bounds before touching history; a malformed + # segment that raises after the pop would leave the source cycle permanently + # deleted with only partial children created. + try: + materialized_segs: list[tuple[float, float, str | None]] = [] + for seg in segments: + if isinstance(seg, (list, tuple)): + seg_s = float(seg[0]) + seg_e = float(seg[1]) + seg_p: str | None = None + else: + seg_s = float(seg["start"]) + seg_e = float(seg["end"]) + seg_p = seg.get("profile") + if seg_e <= seg_s: + return [] + materialized_segs.append((seg_s, seg_e, seg_p)) + except (TypeError, ValueError, KeyError, IndexError): + return [] + # A split into fewer than two pieces is not a real split; guard here so + # an empty or single-element segments list can't pop the source cycle and + # leave zero or one orphaned children. + if len(materialized_segs) < 2: + return [] + + cycles.pop(idx) # Remove original — all segments validated, safe to mutate + + new_ids: list[str] = [] + original_profile = cycle.get("profile_name") + start_ts = start_dt_base_parsed.timestamp() + # Prepare points (relative seconds) points: list[tuple[float, float]] = [] for offset_seconds, val in p_data_tuples: points.append((float(offset_seconds), float(val))) # Create new cycles - for seg in segments: - if isinstance(seg, (list, tuple)): - seg_tuple = cast(tuple[Any, ...] | list[Any], seg) - seg_start = float(seg_tuple[0]) - seg_end = float(seg_tuple[1]) - seg_profile = None - else: - seg_start = float(seg["start"]) - seg_end = float(seg["end"]) - seg_profile = seg.get("profile") - + for seg_start, seg_end, seg_profile in materialized_segs: seg_dur = seg_end - seg_start new_cycle_start = start_dt_base_parsed + timedelta(seconds=seg_start) new_cycle_start_ts = new_cycle_start.timestamp() @@ -6051,14 +6042,14 @@ class ProfileStore: "power_data": p_data_abs, "profile_name": seg_profile } - self.add_cycle(new_cycle) + await self.async_add_cycle(new_cycle) new_ids.append(new_cycle["id"]) # Fix profile refs (handle original sample cycle logic) original_sample_id = cycle.get("id") best_replacement_id = None longest_dur = 0 - new_cycles_objs = [c for c in cycles if c["id"] in new_ids] # 'cycles' is mutated by add_cycle + new_cycles_objs = [c for c in cycles if c["id"] in new_ids] for c in new_cycles_objs: d = c.get("duration", 0) @@ -6239,6 +6230,18 @@ class ProfileStore: c1["power_data"] = new_power_data + # Recompute energy from the merged trace (merge keeps only c1's original + # energy_wh which under-represents the combined cycle). + try: + ts_s = np.array([pt[0] for pt in new_power_data], dtype=float) + p_s = np.array([pt[1] for pt in new_power_data], dtype=float) + if len(ts_s) > 1: + c1["energy_wh"] = round( + integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3 + ) + except Exception: # pylint: disable=broad-exception-caught + self._logger.warning("Energy recompute failed after cycle merge", exc_info=True) + # New Hash ID new_id = hashlib.sha256(f"{c1['start_time']}_{c1['duration']}".encode()).hexdigest()[:12] old_c1_id = c1["id"] diff --git a/custom_components/ha_washdata/setup_advisor.py b/custom_components/ha_washdata/setup_advisor.py index 50306cc..65a456d 100644 --- a/custom_components/ha_washdata/setup_advisor.py +++ b/custom_components/ha_washdata/setup_advisor.py @@ -127,7 +127,14 @@ def compute_setup_phase( # 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): + # Auto-graduated when the device is clearly established: ≥2 real profiles, or + # ≥5 cycles assigned to real profiles. At that point the "record your first + # cycle" nudge is stale and misleading regardless of whether the user ever + # clicked Skip. + _established = len(real) >= 2 or _real_cycle_count(past_cycles, real) >= 5 + _coverage_gap_actionable = bool(coverage_gap and coverage_gap.get("suggest_create")) + _phase1_suppressed = _is_step_suppressed("setup_skip_phase1", skipped_steps, now) + if has_real and not _established and not _coverage_gap_actionable and not _phase1_suppressed: if has_recorded: first_recorded_profile = _first_recorded_profile_name(past_cycles, real) return SetupPhaseResult( @@ -171,6 +178,10 @@ def _real_profile_names(profile_names: list[str], past_cycles: list[dict]) -> se return {n for n in profile_names if n in named} +def _real_cycle_count(past_cycles: list[dict], real: set[str]) -> int: + return sum(1 for c in past_cycles if c.get("profile_name") in real) + + def _has_recorded_cycles(past_cycles: list[dict], real: set[str]) -> bool: return any( (c.get("meta") or {}).get("source") == "recorder" diff --git a/custom_components/ha_washdata/suggestion_engine.py b/custom_components/ha_washdata/suggestion_engine.py index dbbe859..6c3b7c3 100644 --- a/custom_components/ha_washdata/suggestion_engine.py +++ b/custom_components/ha_washdata/suggestion_engine.py @@ -481,15 +481,17 @@ def reconcile_suggestions( adjust(CONF_MIN_POWER, round(stop * 0.8, 1), "the stop threshold") # ── Rule 2: min_off_gap >= off_delay ────────────────────────────────── - # Prefer raising the gap (derived); lower off_delay only when the gap is - # a fixed current value. + # Always cascade-RAISE the gap to the off delay; never lower off_delay. + # Lowering off_delay makes end/pause detection more aggressive and can + # split a genuine multi-minute soak pause into two separate cycles. The + # detector already takes max(off_delay, min_off_gap) at runtime, so + # raising the gap is the safe (and no-op-at-runtime) direction; the + # smart_debounce coupling that a larger gap would otherwise inflate is + # bounded in cycle_detector.py. min_gap = eff(CONF_MIN_OFF_GAP) off_delay = eff(CONF_OFF_DELAY) if off_delay is not None and min_gap is not None and min_gap < off_delay and in_out(CONF_MIN_OFF_GAP, CONF_OFF_DELAY): - if is_original(CONF_MIN_OFF_GAP): - adjust(CONF_MIN_OFF_GAP, off_delay, "the off delay") - else: - adjust(CONF_OFF_DELAY, min_gap, "the minimum off gap") + adjust(CONF_MIN_OFF_GAP, off_delay, "the off delay") # ── Rule 3a: watchdog_interval >= 2 × sampling_interval ─────────────── sampling = eff(CONF_SAMPLING_INTERVAL) diff --git a/custom_components/ha_washdata/task_registry.py b/custom_components/ha_washdata/task_registry.py index 2f90f8d..7300979 100644 --- a/custom_components/ha_washdata/task_registry.py +++ b/custom_components/ha_washdata/task_registry.py @@ -28,6 +28,7 @@ listener to push updates and calls :func:`get_registry` to read/kick/cancel. """ from __future__ import annotations +import asyncio import logging import uuid from collections import OrderedDict @@ -125,6 +126,9 @@ class TaskRegistry: def __init__(self) -> None: self._tasks: OrderedDict[str, Task] = OrderedDict() self._listeners: set[Callable[[dict[str, Any]], None]] = set() + # Raw asyncio Task handles linked by ws_api so cancel_entry_tasks can + # actually inject CancelledError, not just set the polling flag. + self._asyncio_tasks: dict[str, asyncio.Task[Any]] = {} # -- listeners ----------------------------------------------------------- def add_listener(self, cb: Callable[[dict[str, Any]], None]) -> Callable[[], None]: @@ -165,6 +169,15 @@ class TaskRegistry: self._evict() return task + def link_asyncio_task(self, task_id: str, asyncio_task: asyncio.Task[Any]) -> None: + """Associate the raw asyncio Task with a registry entry. + + Call this immediately after hass.async_create_task() so that + cancel_entry_tasks() can inject CancelledError rather than only + setting the polling flag. + """ + self._asyncio_tasks[task_id] = asyncio_task + def update( self, task: Task, @@ -197,6 +210,11 @@ class TaskRegistry: result: Any = None, error: str | None = None, ) -> None: + # A cancellation that races a late-arriving normal completion must win: + # once STATE_CANCELLED is set, no subsequent finish() call can downgrade it. + if task.state == STATE_CANCELLED and state != STATE_CANCELLED: + return + self._asyncio_tasks.pop(task.id, None) task.state = state task.error = error if result is not None: @@ -215,6 +233,28 @@ class TaskRegistry: return True return False + def cancel_entry_tasks(self, entry_id: str) -> list[asyncio.Task[Any]]: + """Cancel all running tasks for an entry and mark them finished. + + Sets the polling flag and — when a raw asyncio Task was linked via + link_asyncio_task — injects CancelledError so the coroutine stops + promptly rather than waiting for the next cancel_requested poll. + Returns the list of raw asyncio Tasks that were cancelled so the caller + can await their completion (ensuring finally blocks run and locks are + released) before tearing down shared state. + Called during async_unload_entry. + """ + cancelled: list[asyncio.Task[Any]] = [] + for task in list(self._tasks.values()): + if task.entry_id == entry_id and task.state == STATE_RUNNING: + task._cancelled = True # noqa: SLF001 + raw = self._asyncio_tasks.pop(task.id, None) + if raw is not None and not raw.done(): + raw.cancel() + cancelled.append(raw) + self.finish(task, state=STATE_CANCELLED) + return cancelled + # -- reads --------------------------------------------------------------- def get(self, task_id: str) -> Task | None: return self._tasks.get(task_id) @@ -227,12 +267,16 @@ class TaskRegistry: ] def _evict(self) -> None: - finished = sorted( - [t for t in self._tasks.values() if t.state != STATE_RUNNING], - key=lambda t: t.finished_at or 0.0, - ) - while len(finished) > _MAX_FINISHED: - self._tasks.pop(finished.pop(0).id, None) + # Evict per entry_id so that a busy entry cannot displace another entry's + # finished results before the panel reads them via get_task_result. + by_entry: dict[str, list[Task]] = {} + for t in self._tasks.values(): + if t.state != STATE_RUNNING: + by_entry.setdefault(t.entry_id, []).append(t) + for tasks in by_entry.values(): + tasks.sort(key=lambda t: t.finished_at or 0.0) + while len(tasks) > _MAX_FINISHED: + self._tasks.pop(tasks.pop(0).id, None) def get_registry(hass: HomeAssistant) -> TaskRegistry: diff --git a/custom_components/ha_washdata/translations/panel/bg.json b/custom_components/ha_washdata/translations/panel/bg.json index 91c9895..4c33b0c 100644 --- a/custom_components/ha_washdata/translations/panel/bg.json +++ b/custom_components/ha_washdata/translations/panel/bg.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Спад, по-голям от тази част от текущата мощност, също се третира като рязък (0,6 = 60% спад). Допълва прага на ватовете за всички размери на уреда.", - "label": "Съотношение на рязко падане" - }, - "abrupt_drop_watts": { - "doc": "Спад на мощността, по-голям от този, означава цикъла като прекъснат (ръчно отмяна), а не като естествен завършек.", - "label": "Рязко падане" - }, "anti_wrinkle_enabled": { "doc": "Разпознайте кратките импулси на сушене с ниска мощност, които сушилнята излъчва след основната топлинна фаза, и ги запазете прикрепени към завършения цикъл, вместо да ги четете като нови цикли.", "label": "Активиране на антибръчково засичане" @@ -1714,7 +1706,6 @@ "other": "Друго" }, "pg_desc": { - "abrupt_drop_watts": "Рязък спад се третира като незабавен край", "completion_min_seconds": "Най-краткото изпълнение, което се счита за истински цикъл", "end_repeat_count": "Ниски отчитания поред преди приключване", "interrupted_min_seconds": "Кратки цикли се отбелязват като прекъснати", diff --git a/custom_components/ha_washdata/translations/panel/bs.json b/custom_components/ha_washdata/translations/panel/bs.json index c49b303..0f4c81c 100644 --- a/custom_components/ha_washdata/translations/panel/bs.json +++ b/custom_components/ha_washdata/translations/panel/bs.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Pad veći od ovog udjela struje također se tretira kao nagli (0,6 = pad od 60%). Dopunjuje prag u vatima za različite veličine uređaja.", - "label": "Omjer naglog opadanja" - }, - "abrupt_drop_watts": { - "doc": "Pad snage veći od ovog označava ciklus kao Prekinut (ručno otkazivanje), a ne kao prirodni završetak.", - "label": "Naglo opadanje" - }, "anti_wrinkle_enabled": { "doc": "Prepoznajte kratke impulse male snage koje sušilica emituje nakon glavne faze zagrijavanja i držite ih priključene na završeni ciklus umjesto da ih čitate kao nove cikluse.", "label": "Omogućiti detekciju anti-gužvanja" @@ -1714,7 +1706,6 @@ "other": "Ostalo" }, "pg_desc": { - "abrupt_drop_watts": "Nagli pad se tretira kao trenutni završetak", "completion_min_seconds": "Najkraće pokretanje koje se računa kao pravi ciklus", "end_repeat_count": "Niska očitanja zaredom prije završetka", "interrupted_min_seconds": "Kratki ciklusi označeni kao prekinuti", diff --git a/custom_components/ha_washdata/translations/panel/cs.json b/custom_components/ha_washdata/translations/panel/cs.json index 215fd3b..f905f84 100644 --- a/custom_components/ha_washdata/translations/panel/cs.json +++ b/custom_components/ha_washdata/translations/panel/cs.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Pokles větší než tento zlomek aktuálního příkonu je také považován za náhlý (0,6 = pokles o 60 %). Doplňuje prahovou hodnotu ve wattech pro spotřebiče různých velikostí.", - "label": "Poměr náhlého poklesu" - }, - "abrupt_drop_watts": { - "doc": "Pokles příkonu větší než tato hodnota označuje cyklus jako Přerušený (ručně zrušeno) spíše než přirozené ukončení.", - "label": "Náhlý pokles" - }, "anti_wrinkle_enabled": { "doc": "Rozpoznejte krátké impulzy s nízkým příkonem, které sušič emituje po hlavní tepelné fázi, a udržte je připojené k dokončenému cyklu místo čtení jako nové cykly.", "label": "Povolit detekci ochrany před zmačkáním" @@ -1714,7 +1706,6 @@ "other": "Jiné" }, "pg_desc": { - "abrupt_drop_watts": "Náhlý pokles považovaný za okamžitý konec", "completion_min_seconds": "Nejkratší běh, který se počítá jako skutečný cyklus", "end_repeat_count": "Nízká čtení za sebou před ukončením", "interrupted_min_seconds": "Krátké cykly označené jako přerušené", diff --git a/custom_components/ha_washdata/translations/panel/da.json b/custom_components/ha_washdata/translations/panel/da.json index f37eb3e..a24e784 100644 --- a/custom_components/ha_washdata/translations/panel/da.json +++ b/custom_components/ha_washdata/translations/panel/da.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Muligvis blandede programmer" }, "pg_desc": { - "abrupt_drop_watts": "Pludseligt fald behandlet som øjeblikkelig afslutning", "completion_min_seconds": "Korteste kørsel, der tæller som en rigtig cyklus", "end_repeat_count": "Lave målinger i træk før afslutning", "interrupted_min_seconds": "Korte cyklusser markeres som afbrudt", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Et fald, der er større end denne del af den nuværende effekt, behandles også som brat (0,6 = et fald på 60%). Komplementerer watt-tærsklen på tværs af apparatstørrelser.", - "label": "Ratio for pludseligt fald" - }, - "abrupt_drop_watts": { - "doc": "Et krafttab større end dette markerer cyklussen som afbrudt (manuel annullering) snarere end en naturlig finish.", - "label": "Pludseligt fald" - }, "anti_wrinkle_enabled": { "doc": "Genkend de korte tørretumblerimpulser med lav effekt, som en tørretumbler udsender efter hovedvarmefasen, og hold dem fastgjort til den færdige cyklus i stedet for at læse dem som nye cyklusser.", "label": "Aktivér anti-rynke registrering" diff --git a/custom_components/ha_washdata/translations/panel/de.json b/custom_components/ha_washdata/translations/panel/de.json index 245d9f0..52e2ae4 100644 --- a/custom_components/ha_washdata/translations/panel/de.json +++ b/custom_components/ha_washdata/translations/panel/de.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Möglicherweise gemischte Programme" }, "pg_desc": { - "abrupt_drop_watts": "Plötzlicher Abfall gilt als sofortiges Ende", "completion_min_seconds": "Kürzester Lauf, der als echter Zyklus zählt", "end_repeat_count": "Aufeinanderfolgende niedrige Messwerte vor dem Ende", "interrupted_min_seconds": "Kurze Zyklen werden als unterbrochen markiert", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Ein Abfall, der größer als dieser Bruchteil der aktuellen Leistung ist, wird ebenfalls als abrupt behandelt (0,6 = ein Abfall von 60 %). Ergänzt den Watt-Grenzwert für alle Gerätegrößen.", - "label": "Abruptes Abfallverhältnis" - }, - "abrupt_drop_watts": { - "doc": "Bei einem größeren Leistungsabfall wird der Zyklus als unterbrochen (manueller Abbruch) und nicht als natürliches Ende gekennzeichnet.", - "label": "Abrupter Abfall" - }, "anti_wrinkle_enabled": { "doc": "Erkennen Sie die kurzen, stromsparenden Wäscheimpulse, die ein Trockner nach der Hauptheizphase abgibt, und behalten Sie im Zusammenhang mit dem abgeschlossenen Zyklus bei, anstatt sie als neue Zyklen zu lesen.", "label": "Knitterschutzerkennung aktivieren" diff --git a/custom_components/ha_washdata/translations/panel/el.json b/custom_components/ha_washdata/translations/panel/el.json index 1e191c6..43d0394 100644 --- a/custom_components/ha_washdata/translations/panel/el.json +++ b/custom_components/ha_washdata/translations/panel/el.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Μια πτώση μεγαλύτερη από αυτό το κλάσμα της τρέχουσας ισχύος αντιμετωπίζεται επίσης ως απότομη (0,6 = πτώση 60%). Συμπληρώνει το όριο watt σε όλα τα μεγέθη συσκευών.", - "label": "Αναλογία Απότομης Πτώσης" - }, - "abrupt_drop_watts": { - "doc": "Μια πτώση ισχύος μεγαλύτερη από αυτήν επισημαίνει τον κύκλο ως Διακοπή (μη αυτόματη ακύρωση) και όχι ως φυσικό φινίρισμα.", - "label": "Απότομη Πτώση" - }, "anti_wrinkle_enabled": { "doc": "Αναγνωρίστε τους σύντομους παλμούς χαμηλής ισχύος που εκπέμπει ένα στεγνωτήριο μετά την κύρια φάση θερμότητας και κρατήστε τους συνδεδεμένους στον τελικό κύκλο αντί να τους διαβάζετε ως νέους κύκλους.", "label": "Ενεργοποίηση Ανίχνευσης Κατά Τσακίσματος" @@ -1714,7 +1706,6 @@ "other": "Άλλο" }, "pg_desc": { - "abrupt_drop_watts": "Απότομη πτώση αντιμετωπίζεται ως άμεση λήξη", "completion_min_seconds": "Η συντομότερη εκτέλεση που μετρά ως πραγματικός κύκλος", "end_repeat_count": "Χαμηλές μετρήσεις στη σειρά πριν τη λήξη", "interrupted_min_seconds": "Οι σύντομοι κύκλοι επισημαίνονται ως διακοπή", diff --git a/custom_components/ha_washdata/translations/panel/en.json b/custom_components/ha_washdata/translations/panel/en.json index fc863bd..6b45d29 100644 --- a/custom_components/ha_washdata/translations/panel/en.json +++ b/custom_components/ha_washdata/translations/panel/en.json @@ -337,6 +337,7 @@ "default_tab": "Default tab when opening the panel", "delta": "Δ", "description": "Description", + "drag_to_resize": "Drag to resize", "dtw_blend_w": "Blend weight", "dtw_blended": "Blended score", "dtw_ddtw": "DDTW score", @@ -780,6 +781,8 @@ "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.", + "head_trim_hint": "Remove this many seconds from the start", + "tail_trim_hint": "Remove this many seconds from the end", "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.", @@ -820,7 +823,6 @@ "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", "completion_min_seconds": "Shortest run that counts as a real cycle", "end_repeat_count": "Low readings in a row before ending", "interrupted_min_seconds": "Short cycles flagged as interrupted", @@ -931,14 +933,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "A drop larger than this fraction of current power is also treated as abrupt (0.6 = a 60% drop). Complements the watts threshold across appliance sizes.", - "label": "Abrupt Drop Ratio" - }, - "abrupt_drop_watts": { - "doc": "A power drop larger than this flags the cycle as Interrupted (manual cancel) rather than a natural finish.", - "label": "Abrupt Drop" - }, "anti_wrinkle_enabled": { "doc": "Recognise the short low-power tumble pulses a dryer emits after the main heat phase and keep them attached to the finished cycle instead of reading them as new cycles.", "label": "Enable Anti-Wrinkle Detection" @@ -1831,4 +1825,4 @@ "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 2cb7a6e..3cb2946 100644 --- a/custom_components/ha_washdata/translations/panel/es.json +++ b/custom_components/ha_washdata/translations/panel/es.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Posibles programas mezclados" }, "pg_desc": { - "abrupt_drop_watts": "Caída repentina tratada como fin inmediato", "completion_min_seconds": "Ejecución más corta que cuenta como ciclo real", "end_repeat_count": "Lecturas bajas consecutivas antes de terminar", "interrupted_min_seconds": "Ciclos cortos marcados como interrumpidos", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Una caída mayor que esta fracción de la potencia actual también se trata como abrupta (0,6 = una caída del 60%). Complementa el umbral de vatios en todos los tamaños de electrodomésticos.", - "label": "Proporción de caída abrupta" - }, - "abrupt_drop_watts": { - "doc": "Una caída de energía mayor que esto indica que el ciclo está interrumpido (cancelación manual) en lugar de un final natural.", - "label": "Caída abrupta" - }, "anti_wrinkle_enabled": { "doc": "Reconozca los impulsos cortos de baja potencia que emite una secadora después de la fase de calentamiento principal y manténgalos adjuntos al ciclo finalizado en lugar de leerlos como ciclos nuevos.", "label": "Habilitar detección antiarrugas" diff --git a/custom_components/ha_washdata/translations/panel/et.json b/custom_components/ha_washdata/translations/panel/et.json index 98b6d95..60a7f12 100644 --- a/custom_components/ha_washdata/translations/panel/et.json +++ b/custom_components/ha_washdata/translations/panel/et.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Võimalik programmide segu" }, "pg_desc": { - "abrupt_drop_watts": "Järsk langus käsitletakse kohese lõpuna", "completion_min_seconds": "Lühim käivitus, mis loetakse päris tsükliks", "end_repeat_count": "Madalate näitude arv järjest enne lõpetamist", "interrupted_min_seconds": "Lühikesed tsüklid märgitakse katkestatuks", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Sellest vooluvõimsuse osast suuremat langust käsitletakse samuti järsuna (0,6 = 60% langus). Täiendab vattide künnist seadme suuruste lõikes.", - "label": "Järsu Languse Suhe" - }, - "abrupt_drop_watts": { - "doc": "Sellest suurem võimsuslangus märgib tsükli pigem Katkestatud (käsitsi tühistamine) kui loomuliku viimistlusena.", - "label": "Järsk Langus" - }, "anti_wrinkle_enabled": { "doc": "Tuvastage lühikesed väikese võimsusega trummelimpulsid, mida kuivati ​​pärast peamist kuumutusfaasi kiirgab, ja hoidke need valmis tsükliga ühendatud, selle asemel, et lugeda neid uute tsüklitena.", "label": "Luba Kortsudevastane Tuvastamine" diff --git a/custom_components/ha_washdata/translations/panel/fi.json b/custom_components/ha_washdata/translations/panel/fi.json index 739a78b..f002ed4 100644 --- a/custom_components/ha_washdata/translations/panel/fi.json +++ b/custom_components/ha_washdata/translations/panel/fi.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Mahdollisesti sekoittuneet ohjelmat" }, "pg_desc": { - "abrupt_drop_watts": "Äkillinen pudotus tulkitaan välittömäksi lopuksi", "completion_min_seconds": "Lyhin ajo, joka lasketaan oikeaksi sykliksi", "end_repeat_count": "Peräkkäisten matalien lukemien määrä ennen päättymistä", "interrupted_min_seconds": "Lyhyet syklit merkitään keskeytyneiksi", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Tätä suurempaa pudotusta virran tehosta käsitellään myös äkillisenä (0,6 = 60 % pudotus). Täydentää wattirajaa eri laitekokojen välillä.", - "label": "Äkillisen pudotuksen suhde" - }, - "abrupt_drop_watts": { - "doc": "Tätä suurempi tehohäviö merkitsee syklin keskeytetyksi (manuaalinen peruutus) luonnollisen viimeistelyn sijaan.", - "label": "Äkillinen pudotus" - }, "anti_wrinkle_enabled": { "doc": "Tunnista lyhyet vähätehoiset rumpupulssit, joita kuivausrumpu lähettää päälämpövaiheen jälkeen, ja pidä ne kiinni valmiissa jaksossa sen sijaan, että luet ne uusina jaksoina.", "label": "Ota ryppyjenesto käyttöön" diff --git a/custom_components/ha_washdata/translations/panel/fr.json b/custom_components/ha_washdata/translations/panel/fr.json index 03bbab9..2b8905e 100644 --- a/custom_components/ha_washdata/translations/panel/fr.json +++ b/custom_components/ha_washdata/translations/panel/fr.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Programmes peut-être mélangés" }, "pg_desc": { - "abrupt_drop_watts": "Chute soudaine traitée comme une fin immédiate", "completion_min_seconds": "Plus courte exécution comptant comme un vrai cycle", "end_repeat_count": "Lectures basses consécutives avant la fin", "interrupted_min_seconds": "Cycles courts signalés comme interrompus", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Une chute supérieure à cette fraction de puissance actuelle est également traitée comme brusque (0,6 = une chute de 60 %). Complète le seuil de watts pour toutes les tailles d’appareils.", - "label": "Ratio de chute brusque" - }, - "abrupt_drop_watts": { - "doc": "Une chute de puissance plus importante signale le cycle comme étant interrompu (annulation manuelle) plutôt que comme une finition naturelle.", - "label": "Chute brusque" - }, "anti_wrinkle_enabled": { "doc": "Reconnaissez les courtes impulsions de culbutage de faible puissance qu'un sèche-linge émet après la phase de chauffage principale et conservez-les attachées au cycle terminé au lieu de les lire comme de nouveaux cycles.", "label": "Activer la détection anti-froissement" diff --git a/custom_components/ha_washdata/translations/panel/hr.json b/custom_components/ha_washdata/translations/panel/hr.json index 1c56cbf..66dbc1d 100644 --- a/custom_components/ha_washdata/translations/panel/hr.json +++ b/custom_components/ha_washdata/translations/panel/hr.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Pad veći od ovog udjela trenutne snage također se tretira kao nagli (0,6 = pad od 60%). Nadopunjuje prag u vatima za sve veličine uređaja.", - "label": "Omjer naglog pada" - }, - "abrupt_drop_watts": { - "doc": "Pad snage veći od ovoga označava ciklus kao prekinut (ručno otkazivanje), a ne prirodni završetak.", - "label": "Nagli pad (W)" - }, "anti_wrinkle_enabled": { "doc": "Prepoznajte kratke impulse male snage koje sušilica emitira nakon glavne faze zagrijavanja i držite ih vezanima uz završeni ciklus umjesto da ih čitate kao nove cikluse.", "label": "Omogući otkrivanje anti-bora" @@ -1714,7 +1706,6 @@ "other": "Ostalo" }, "pg_desc": { - "abrupt_drop_watts": "Nagli pad tretiran kao trenutni završetak", "completion_min_seconds": "Najkraći rad koji se broji kao pravi ciklus", "end_repeat_count": "Uzastopna niska očitanja prije završetka", "interrupted_min_seconds": "Kratki ciklusi označeni kao prekinuti", diff --git a/custom_components/ha_washdata/translations/panel/hu.json b/custom_components/ha_washdata/translations/panel/hu.json index 8bb13e7..979ff85 100644 --- a/custom_components/ha_washdata/translations/panel/hu.json +++ b/custom_components/ha_washdata/translations/panel/hu.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Az áramerősség ezen hányadánál nagyobb esést is hirtelennek kell tekinteni (0,6 = 60%-os csökkenés). Kiegészíti a watt-küszöböt a készülékek méretében.", - "label": "Hirtelen esés aránya" - }, - "abrupt_drop_watts": { - "doc": "Az ennél nagyobb teljesítménycsökkenés a ciklust Megszakítottként (kézi törlés) jelzi, nem pedig természetes befejezésként.", - "label": "Hirtelen esés" - }, "anti_wrinkle_enabled": { "doc": "Ismerje fel a szárítógép által a fő fűtési fázis után kibocsátott rövid, kis teljesítményű szárítópulzusokat, és tartsa őket a kész ciklushoz kapcsolva, ahelyett, hogy új ciklusként olvasná őket.", "label": "Ránctalanítás érzékelés engedélyezése" @@ -1714,7 +1706,6 @@ "other": "Egyéb" }, "pg_desc": { - "abrupt_drop_watts": "A hirtelen esést azonnali befejezésként kezeli", "completion_min_seconds": "A legrövidebb futás, ami valódi ciklusnak számít", "end_repeat_count": "Egymást követő alacsony leolvasások a befejezés előtt", "interrupted_min_seconds": "A rövid ciklusokat megszakítottként jelöli", diff --git a/custom_components/ha_washdata/translations/panel/is.json b/custom_components/ha_washdata/translations/panel/is.json index e13ead3..19b2f13 100644 --- a/custom_components/ha_washdata/translations/panel/is.json +++ b/custom_components/ha_washdata/translations/panel/is.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Hugsanlega blönduð forrit" }, "pg_desc": { - "abrupt_drop_watts": "Skyndilegt fall meðhöndlað sem tafarlaus endir", "completion_min_seconds": "Stysta keyrsla sem telst raunveruleg lota", "end_repeat_count": "Lágir álestrar í röð áður en lýkur", "interrupted_min_seconds": "Stuttar lotur merktar sem truflaðar", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Fall sem er stærra en þetta brot af núverandi afli er einnig meðhöndlað sem snöggt (0,6 = 60% lækkun). Bætir wöttaþröskuldinum yfir stærðum tækja.", - "label": "Hlutfall snöggrar niðurfellingar" - }, - "abrupt_drop_watts": { - "doc": "Aflfall sem er stærra en þetta merkir lotuna sem truflað (handvirkt hætta við) frekar en náttúrulega frágang.", - "label": "Snögg niðurfelling" - }, "anti_wrinkle_enabled": { "doc": "Viðurkenndu stuttu lág-afls þurrkarapúlsana sem þurrkari gefur frá sér eftir aðalhitafasann og haltu þeim tengdum við lokið lotu í stað þess að lesa þá sem nýjar lotur.", "label": "Virkja hrukkuleysi-greiningu" diff --git a/custom_components/ha_washdata/translations/panel/it.json b/custom_components/ha_washdata/translations/panel/it.json index 61b6ea4..6fcc951 100644 --- a/custom_components/ha_washdata/translations/panel/it.json +++ b/custom_components/ha_washdata/translations/panel/it.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Possibili programmi misti" }, "pg_desc": { - "abrupt_drop_watts": "Calo improvviso trattato come fine immediata", "completion_min_seconds": "Esecuzione più breve che conta come ciclo reale", "end_repeat_count": "Letture basse consecutive prima della fine", "interrupted_min_seconds": "Cicli brevi contrassegnati come interrotti", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Anche un calo maggiore di questa frazione della potenza attuale viene considerato brusco (0,6 = calo del 60%). Completa la soglia dei watt per tutte le dimensioni degli elettrodomestici.", - "label": "Rapporto calo brusco" - }, - "abrupt_drop_watts": { - "doc": "Un calo di potenza maggiore segnala il ciclo come Interrotto (annullamento manuale) piuttosto che come completamento naturale.", - "label": "Calo brusco" - }, "anti_wrinkle_enabled": { "doc": "Riconosci i brevi impulsi di asciugatrice a bassa potenza emessi da un'asciugatrice dopo la fase di riscaldamento principale e mantienili collegati al ciclo finito invece di leggerli come nuovi cicli.", "label": "Abilita rilevamento antirughe" diff --git a/custom_components/ha_washdata/translations/panel/ja.json b/custom_components/ha_washdata/translations/panel/ja.json index aeb7591..7185de0 100644 --- a/custom_components/ha_washdata/translations/panel/ja.json +++ b/custom_components/ha_washdata/translations/panel/ja.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ プログラム混在の可能性" }, "pg_desc": { - "abrupt_drop_watts": "急激な低下は即時終了として扱う", "completion_min_seconds": "実サイクルとみなす最短の稼働時間", "end_repeat_count": "終了前に連続する低い測定値の回数", "interrupted_min_seconds": "短いサイクルは中断としてフラグ付け", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "現在の電力のこの割合よりも大きな低下も突然として扱われます (0.6 = 60% の低下)。アプライアンスのサイズ全体でワットしきい値を補完します。", - "label": "急激な降下比率" - }, - "abrupt_drop_watts": { - "doc": "これよりも大きな電力低下は、自然な終了ではなく、サイクルに中断 (手動キャンセル) としてフラグを立てます。", - "label": "急激な降下" - }, "anti_wrinkle_enabled": { "doc": "乾燥機が主加熱フェーズの後に発する短い低電力タンブル パルスを認識し、新しいサイクルとして読み取るのではなく、終了したサイクルに接続したままにします。", "label": "しわ防止検出を有効化" diff --git a/custom_components/ha_washdata/translations/panel/ko.json b/custom_components/ha_washdata/translations/panel/ko.json index f5661cf..0fb11a8 100644 --- a/custom_components/ha_washdata/translations/panel/ko.json +++ b/custom_components/ha_washdata/translations/panel/ko.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ 프로그램이 섞였을 수 있음" }, "pg_desc": { - "abrupt_drop_watts": "급격한 강하는 즉시 종료로 처리", "completion_min_seconds": "실제 사이클로 인정되는 최단 실행", "end_repeat_count": "종료 전 연속되는 낮은 측정값 횟수", "interrupted_min_seconds": "짧은 사이클은 중단으로 표시", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "현재 전력의 이 부분보다 큰 하락도 갑작스러운 것으로 간주됩니다(0.6 = 60% 하락). 어플라이언스 크기 전반에 걸쳐 와트 임계값을 보완합니다.", - "label": "급격한 강하 비율" - }, - "abrupt_drop_watts": { - "doc": "이보다 큰 전력 감소는 주기를 자연스러운 종료가 아닌 중단됨(수동 취소)으로 표시합니다.", - "label": "급격한 강하" - }, "anti_wrinkle_enabled": { "doc": "건조기가 주요 가열 단계 후에 방출하는 짧은 저전력 텀블 펄스를 인식하고 이를 새 주기로 읽는 대신 완료된 주기에 연결해 두십시오.", "label": "구김 방지 감지 활성화" diff --git a/custom_components/ha_washdata/translations/panel/lt.json b/custom_components/ha_washdata/translations/panel/lt.json index 29a2243..04dbe98 100644 --- a/custom_components/ha_washdata/translations/panel/lt.json +++ b/custom_components/ha_washdata/translations/panel/lt.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Didesnis nei ši srovės galios sumažėjimas taip pat laikomas staigiu (0,6 = 60 % kritimas). Papildo vatų slenkstį įvairiems prietaisų dydžiams.", - "label": "Staigaus kritimo santykis" - }, - "abrupt_drop_watts": { - "doc": "Jei galios sumažėjimas didesnis nei šis, ciklas pažymimas kaip Pertrauktas (rankinis atšaukimas), o ne kaip natūrali apdaila.", - "label": "Staigus kritimas" - }, "anti_wrinkle_enabled": { "doc": "Atpažinkite trumpus mažos galios būgninius impulsus, kuriuos džiovyklė skleidžia po pagrindinės šildymo fazės, ir laikykite juos prijungtus prie baigto ciklo, o ne skaitykite juos kaip naujus ciklus.", "label": "Įjungti apsaugą nuo raukšlių" @@ -1714,7 +1706,6 @@ "other": "Kita" }, "pg_desc": { - "abrupt_drop_watts": "Staigus kritimas laikomas nedelsiama pabaiga", "completion_min_seconds": "Trumpiausias veikimas, laikomas tikru ciklu", "end_repeat_count": "Kiek žemų rodmenų iš eilės prieš pabaigą", "interrupted_min_seconds": "Trumpi ciklai pažymimi kaip pertraukti", diff --git a/custom_components/ha_washdata/translations/panel/lv.json b/custom_components/ha_washdata/translations/panel/lv.json index fe2496d..3f1ed8a 100644 --- a/custom_components/ha_washdata/translations/panel/lv.json +++ b/custom_components/ha_washdata/translations/panel/lv.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Kritums, kas ir lielāks par šo strāvas jaudas daļu, arī tiek uzskatīts par pēkšņu (0,6 = 60% kritums). Papildina vatu slieksni dažādiem ierīču izmēriem.", - "label": "Pēkšņa krituma attiecība" - }, - "abrupt_drop_watts": { - "doc": "Jaudas kritums, kas ir lielāks par šo, norāda ciklu kā Pārtraukts (manuāla atcelšana), nevis dabisku apdari.", - "label": "Pēkšņs kritums" - }, "anti_wrinkle_enabled": { "doc": "Atpazīstiet īsos mazjaudas veļas impulsus, ko žāvētājs izstaro pēc galvenās karsēšanas fāzes, un turiet tos pievienotus gatavajam ciklam, nevis nolasiet tos kā jaunus ciklus.", "label": "Iespējot pretgrumbu noteikšanu" @@ -1714,7 +1706,6 @@ "other": "Cits" }, "pg_desc": { - "abrupt_drop_watts": "Pēkšņs kritums tiek uzskatīts par tūlītējām beigām", "completion_min_seconds": "Īsākais darbības laiks, kas skaitās par īstu ciklu", "end_repeat_count": "Zemi rādījumi pēc kārtas pirms beigām", "interrupted_min_seconds": "Īsi cikli tiek atzīmēti kā pārtraukti", diff --git a/custom_components/ha_washdata/translations/panel/mk.json b/custom_components/ha_washdata/translations/panel/mk.json index 3bb74c1..35680bd 100644 --- a/custom_components/ha_washdata/translations/panel/mk.json +++ b/custom_components/ha_washdata/translations/panel/mk.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Пад поголем од овој дел од моменталната моќност исто така се третира како нагло (0,6 = пад од 60%). Го надополнува прагот на вати низ големини на апаратот.", - "label": "Сооднос на нагол пад" - }, - "abrupt_drop_watts": { - "doc": "Пад на енергија поголем од ова го означува циклусот како Прекинат (рачно откажување) наместо како природен финиш.", - "label": "Нагол пад" - }, "anti_wrinkle_enabled": { "doc": "Препознајте ги кратките пулсирања со мала моќност што ги испушта машината за сушење по главната топлинска фаза и држете ги прикачени на завршениот циклус наместо да ги читате како нови циклуси.", "label": "Овозможи откривање против брчки" @@ -1714,7 +1706,6 @@ "other": "Друго" }, "pg_desc": { - "abrupt_drop_watts": "Ненадеен пад се третира како непосреден крај", "completion_min_seconds": "Најкратко извршување што се смета за вистински циклус", "end_repeat_count": "Ниски отчитувања по ред пред завршување", "interrupted_min_seconds": "Кратки циклуси означени како прекинати", diff --git a/custom_components/ha_washdata/translations/panel/nb.json b/custom_components/ha_washdata/translations/panel/nb.json index 6e50a8a..5e8cc05 100644 --- a/custom_components/ha_washdata/translations/panel/nb.json +++ b/custom_components/ha_washdata/translations/panel/nb.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Muligens blandede programmer" }, "pg_desc": { - "abrupt_drop_watts": "Plutselig fall behandlet som umiddelbar slutt", "completion_min_seconds": "Korteste kjøring som teller som en ekte syklus", "end_repeat_count": "Lave avlesninger på rad før avslutning", "interrupted_min_seconds": "Korte sykluser merkes som avbrutt", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Et fall som er større enn denne brøkdelen av gjeldende effekt, blir også behandlet som brå (0,6 = et fall på 60 %). Utfyller watt-terskelen på tvers av apparatstørrelser.", - "label": "Bratt fall-forhold" - }, - "abrupt_drop_watts": { - "doc": "Et kraftfall større enn dette flagger syklusen som avbrutt (manuell kansellering) i stedet for en naturlig finish.", - "label": "Bratt fall" - }, "anti_wrinkle_enabled": { "doc": "Gjenkjenn de korte laveffekttrommelpulsene en tørketrommel sender ut etter hovedvarmefasen, og hold dem festet til den ferdige syklusen i stedet for å lese dem som nye sykluser.", "label": "Aktiver anti-rynke-deteksjon" diff --git a/custom_components/ha_washdata/translations/panel/nl.json b/custom_components/ha_washdata/translations/panel/nl.json index c83fb47..961ec2a 100644 --- a/custom_components/ha_washdata/translations/panel/nl.json +++ b/custom_components/ha_washdata/translations/panel/nl.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Mogelijk gemengde programma's" }, "pg_desc": { - "abrupt_drop_watts": "Plotselinge daling behandeld als onmiddellijk einde", "completion_min_seconds": "Kortste run die als een echte cyclus telt", "end_repeat_count": "Opeenvolgende lage metingen voor het einde", "interrupted_min_seconds": "Korte cycli gemarkeerd als onderbroken", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Een daling groter dan deze fractie van het huidige vermogen wordt ook als abrupt beschouwd (0,6 = een daling van 60%). Vult de wattdrempel aan voor alle apparaatformaten.", - "label": "Ratio plotse daling" - }, - "abrupt_drop_watts": { - "doc": "Een stroomdaling groter dan dit markeert de cyclus als onderbroken (handmatige annulering) in plaats van als een natuurlijke afwerking.", - "label": "Plotse daling" - }, "anti_wrinkle_enabled": { "doc": "Herken de korte tuimelpulsen met laag vermogen die een droger afgeeft na de hoofdverwarmingsfase en houd ze vast aan het voltooide programma in plaats van ze als nieuwe cycli te lezen.", "label": "Anti-kreuk detectie inschakelen" diff --git a/custom_components/ha_washdata/translations/panel/pl.json b/custom_components/ha_washdata/translations/panel/pl.json index 9740ca7..4854378 100644 --- a/custom_components/ha_washdata/translations/panel/pl.json +++ b/custom_components/ha_washdata/translations/panel/pl.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Spadek większy niż ten ułamek mocy prądu jest również traktowany jako gwałtowny (0,6 = spadek o 60%). Uzupełnia próg watów dla różnych rozmiarów urządzeń.", - "label": "Współczynnik Nagłego Spadku" - }, - "abrupt_drop_watts": { - "doc": "Większy spadek mocy oznacza cykl jako przerwany (ręczne anulowanie), a nie jako naturalne zakończenie.", - "label": "Nagły Spadek" - }, "anti_wrinkle_enabled": { "doc": "Rozpoznawaj krótkie impulsy suszenia o małej mocy, które suszarka emituje po głównej fazie nagrzewania, i pamiętaj o ich dołączeniu do zakończonego cyklu, zamiast odczytywać je jako nowe cykle.", "label": "Włącz Wykrywanie Antygniotowe" @@ -1714,7 +1706,6 @@ "other": "Inne" }, "pg_desc": { - "abrupt_drop_watts": "Nagły spadek traktowany jako natychmiastowy koniec", "completion_min_seconds": "Najkrótszy przebieg liczony jako prawdziwy cykl", "end_repeat_count": "Kolejne niskie odczyty przed zakończeniem", "interrupted_min_seconds": "Krótkie cykle oznaczane jako przerwane", diff --git a/custom_components/ha_washdata/translations/panel/pt-BR.json b/custom_components/ha_washdata/translations/panel/pt-BR.json index 11c54c8..de2fe80 100644 --- a/custom_components/ha_washdata/translations/panel/pt-BR.json +++ b/custom_components/ha_washdata/translations/panel/pt-BR.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Uma queda maior que esta fração da potência atual também é tratada como abrupta (0,6 = queda de 60%). Complementa o limite de watts em todos os tamanhos de aparelhos.", - "label": "Proporção de Queda Abrupta" - }, - "abrupt_drop_watts": { - "doc": "Uma queda de energia maior que isso sinaliza o ciclo como Interrompido (cancelamento manual) em vez de um término natural.", - "label": "Queda Abrupta" - }, "anti_wrinkle_enabled": { "doc": "Reconheça os pulsos curtos de baixa potência que uma secadora emite após a fase de aquecimento principal e mantenha-os anexados ao ciclo finalizado, em vez de lê-los como novos ciclos.", "label": "Ativar Detecção Antiamasso" @@ -1714,7 +1706,6 @@ "other": "Outro" }, "pg_desc": { - "abrupt_drop_watts": "Queda súbita tratada como fim imediato", "completion_min_seconds": "Menor duração que conta como ciclo real", "end_repeat_count": "Leituras baixas seguidas antes de terminar", "interrupted_min_seconds": "Ciclos curtos marcados como interrompidos", diff --git a/custom_components/ha_washdata/translations/panel/pt.json b/custom_components/ha_washdata/translations/panel/pt.json index ceb3fa7..0589047 100644 --- a/custom_components/ha_washdata/translations/panel/pt.json +++ b/custom_components/ha_washdata/translations/panel/pt.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Possíveis programas misturados" }, "pg_desc": { - "abrupt_drop_watts": "Queda repentina tratada como fim imediato", "completion_min_seconds": "Execução mais curta que conta como um ciclo real", "end_repeat_count": "Leituras baixas consecutivas antes de terminar", "interrupted_min_seconds": "Ciclos curtos marcados como interrompidos", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Uma queda maior que esta fração da potência atual também é tratada como abrupta (0,6 = queda de 60%). Complementa o limite de watts em todos os tamanhos de aparelhos.", - "label": "Rácio de Queda Abrupta" - }, - "abrupt_drop_watts": { - "doc": "Uma queda de energia maior que isso sinaliza o ciclo como Interrompido (cancelamento manual) em vez de um término natural.", - "label": "Queda Abrupta" - }, "anti_wrinkle_enabled": { "doc": "Reconheça os pulsos curtos de baixa potência que uma secadora emite após a fase de aquecimento principal e mantenha-os anexados ao ciclo finalizado, em vez de lê-los como novos ciclos.", "label": "Activar Detecção Anti-Vincos" diff --git a/custom_components/ha_washdata/translations/panel/ro.json b/custom_components/ha_washdata/translations/panel/ro.json index 95bef7c..f625dfa 100644 --- a/custom_components/ha_washdata/translations/panel/ro.json +++ b/custom_components/ha_washdata/translations/panel/ro.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "O scădere mai mare decât această fracțiune a puterii curente este, de asemenea, tratată ca fiind bruscă (0,6 = o scădere de 60%). Completează pragul de wați în funcție de dimensiunile aparatului.", - "label": "Raport Scădere Bruscă" - }, - "abrupt_drop_watts": { - "doc": "O scădere de putere mai mare decât aceasta indică ciclul ca întrerupt (anulare manuală), mai degrabă decât un finisaj natural.", - "label": "Scădere Bruscă" - }, "anti_wrinkle_enabled": { "doc": "Recunoașteți impulsurile scurte de rufe de putere redusă pe care le emite un uscător după faza principală de căldură și mențineți-le atașate la ciclul terminat în loc să le citiți ca cicluri noi.", "label": "Activați Detectarea Anti-Șifonare" @@ -1714,7 +1706,6 @@ "other": "Altele" }, "pg_desc": { - "abrupt_drop_watts": "Scădere bruscă tratată ca sfârșit imediat", "completion_min_seconds": "Cea mai scurtă rulare care contează ca ciclu real", "end_repeat_count": "Citiri scăzute consecutive înainte de încheiere", "interrupted_min_seconds": "Ciclurile scurte sunt marcate ca întrerupte", diff --git a/custom_components/ha_washdata/translations/panel/ru.json b/custom_components/ha_washdata/translations/panel/ru.json index 0689b17..a14584e 100644 --- a/custom_components/ha_washdata/translations/panel/ru.json +++ b/custom_components/ha_washdata/translations/panel/ru.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Падение, превышающее эту долю текущей мощности, также считается резким (0,6 = падение на 60%). Дополняет пороговое значение мощности для устройств разных размеров.", - "label": "Коэффициент Резкого Падения" - }, - "abrupt_drop_watts": { - "doc": "Падение мощности, превышающее это значение, помечает цикл как прерванный (отмена вручную), а не как естественное завершение.", - "label": "Резкое Падение" - }, "anti_wrinkle_enabled": { "doc": "Распознавайте короткие импульсы низкой мощности, которые сушильная машина излучает после основной фазы нагрева, и сохраняйте их привязанными к завершенному циклу, а не воспринимайте их как новые циклы.", "label": "Включить Защиту от Морщин" @@ -1714,7 +1706,6 @@ "other": "Другое" }, "pg_desc": { - "abrupt_drop_watts": "Резкое падение считается немедленным завершением", "completion_min_seconds": "Кратчайший запуск, считающийся настоящим циклом", "end_repeat_count": "Сколько низких показаний подряд до завершения", "interrupted_min_seconds": "Короткие циклы помечаются как прерванные", diff --git a/custom_components/ha_washdata/translations/panel/sk.json b/custom_components/ha_washdata/translations/panel/sk.json index 87b62e4..8a13743 100644 --- a/custom_components/ha_washdata/translations/panel/sk.json +++ b/custom_components/ha_washdata/translations/panel/sk.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Pokles väčší ako tento zlomok prúdu sa tiež považuje za náhly (0,6 = 60 % pokles). Dopĺňa prahovú hodnotu vo wattoch naprieč veľkosťami spotrebičov.", - "label": "Pomer náhleho poklesu" - }, - "abrupt_drop_watts": { - "doc": "Pokles výkonu väčší ako tento označí cyklus ako prerušený (manuálne zrušenie), a nie ako prirodzený koniec.", - "label": "Náhly pokles" - }, "anti_wrinkle_enabled": { "doc": "Rozpoznajte krátke pulzy bubna s nízkym výkonom, ktoré sušička vydáva po hlavnej fáze ohrevu, a nechajte ich pripojené k dokončenému cyklu namiesto toho, aby ste ich čítali ako nové cykly.", "label": "Povoliť detekciu ochrany pred krčením" @@ -1714,7 +1706,6 @@ "other": "Iné" }, "pg_desc": { - "abrupt_drop_watts": "Náhly pokles považovaný za okamžitý koniec", "completion_min_seconds": "Najkratší beh, ktorý sa počíta ako skutočný cyklus", "end_repeat_count": "Nízke čítania za sebou pred ukončením", "interrupted_min_seconds": "Krátke cykly označené ako prerušené", diff --git a/custom_components/ha_washdata/translations/panel/sl.json b/custom_components/ha_washdata/translations/panel/sl.json index 399aeb3..f7c684c 100644 --- a/custom_components/ha_washdata/translations/panel/sl.json +++ b/custom_components/ha_washdata/translations/panel/sl.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Padec, večji od tega deleža trenutne moči, se prav tako obravnava kot nenaden (0,6 = 60-odstotni padec). Dopolnjuje mejno vrednost v vatih za vse velikosti aparatov.", - "label": "Razmerje nenadnega padca" - }, - "abrupt_drop_watts": { - "doc": "Večji padec moči od tega označi cikel kot prekinjen (ročni preklic) in ne kot naravni zaključek.", - "label": "Nenaden padec" - }, "anti_wrinkle_enabled": { "doc": "Prepoznajte kratke vrtilne impulze nizke moči, ki jih oddaja sušilni stroj po glavni fazi segrevanja, in jih ohranite povezane s končanim ciklom, namesto da jih berete kot nove cikle.", "label": "Omogoči zaznavanje zaščite pred gubami" @@ -1714,7 +1706,6 @@ "other": "Drugo" }, "pg_desc": { - "abrupt_drop_watts": "Nenaden padec, obravnavan kot takojšen konec", "completion_min_seconds": "Najkrajši zagon, ki šteje kot pravi cikel", "end_repeat_count": "Zaporedni nizki odčitki pred koncem", "interrupted_min_seconds": "Kratki cikli, označeni kot prekinjeni", diff --git a/custom_components/ha_washdata/translations/panel/sq.json b/custom_components/ha_washdata/translations/panel/sq.json index cb8e4ba..bcfa5d5 100644 --- a/custom_components/ha_washdata/translations/panel/sq.json +++ b/custom_components/ha_washdata/translations/panel/sq.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Një rënie më e madhe se kjo pjesë e fuqisë aktuale trajtohet gjithashtu si e papritur (0.6 = një rënie prej 60%). Plotëson pragun e vateve në përmasat e pajisjes.", - "label": "Raporti i rënies së papritur" - }, - "abrupt_drop_watts": { - "doc": "Një rënie energjie më e madhe se kjo e shënon ciklin si të ndërprerë (anulim manual) dhe jo si përfundim natyral.", - "label": "Rënie e papritur" - }, "anti_wrinkle_enabled": { "doc": "Njihni pulset e shkurtra me fuqi të ulët që lëshon një tharëse pas fazës kryesore të nxehtësisë dhe mbajini të lidhura me ciklin e përfunduar në vend që t'i lexoni si cikle të reja.", "label": "Aktivizo zbulimin kundër rrudhave" @@ -1714,7 +1706,6 @@ "other": "Tjetër" }, "pg_desc": { - "abrupt_drop_watts": "Rënia e papritur trajtohet si përfundim i menjëhershëm", "completion_min_seconds": "Ekzekutimi më i shkurtër që llogaritet si cikël i vërtetë", "end_repeat_count": "Lexime të ulëta radhazi para përfundimit", "interrupted_min_seconds": "Ciklet e shkurtra shënohen si të ndërprera", diff --git a/custom_components/ha_washdata/translations/panel/sr-Latn.json b/custom_components/ha_washdata/translations/panel/sr-Latn.json index 4008042..30428c3 100644 --- a/custom_components/ha_washdata/translations/panel/sr-Latn.json +++ b/custom_components/ha_washdata/translations/panel/sr-Latn.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Pad veći od ovog dela struje takođe se tretira kao nagli (0,6 = pad od 60%). Dopunjuje prag u vatima za različite veličine uređaja.", - "label": "Odnos naglog pada" - }, - "abrupt_drop_watts": { - "doc": "Pad snage veći od ovog označava ciklus kao Prekinut (ručno otkazivanje), a ne kao prirodni završetak.", - "label": "Nagli pad" - }, "anti_wrinkle_enabled": { "doc": "Prepoznajte kratke impulse male snage koje mašina za sušenje emituje nakon glavne faze grejanja i držite ih povezanim sa završenim ciklusom umesto da ih čitate kao nove cikluse.", "label": "Omogući detekciju zaštite od gužvanja" @@ -1714,7 +1706,6 @@ "other": "Ostalo" }, "pg_desc": { - "abrupt_drop_watts": "Nagli pad se tretira kao trenutni završetak", "completion_min_seconds": "Najkraće pokretanje koje se računa kao pravi ciklus", "end_repeat_count": "Niska očitavanja zaredom pre završetka", "interrupted_min_seconds": "Kratki ciklusi označeni kao prekinuti", diff --git a/custom_components/ha_washdata/translations/panel/sv.json b/custom_components/ha_washdata/translations/panel/sv.json index 2626d1a..c8228f5 100644 --- a/custom_components/ha_washdata/translations/panel/sv.json +++ b/custom_components/ha_washdata/translations/panel/sv.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Möjligen blandade program" }, "pg_desc": { - "abrupt_drop_watts": "Plötsligt fall som tolkas som omedelbart slut", "completion_min_seconds": "Kortaste körning som räknas som en riktig cykel", "end_repeat_count": "Antal låga avläsningar i rad innan avslut", "interrupted_min_seconds": "Korta cykler markeras som avbrutna", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Ett fall som är större än denna andel av strömeffekten behandlas också som abrupt (0,6 = 60 % minskning). Kompletterar watt-tröskeln över apparatstorlekar.", - "label": "Abrupt fallförhållande" - }, - "abrupt_drop_watts": { - "doc": "Ett effektfall större än detta flaggar cykeln som avbruten (manuell avbrytning) snarare än en naturlig finish.", - "label": "Abrupt fall" - }, "anti_wrinkle_enabled": { "doc": "Känn igen de korta torktumlarpulser med låg effekt en torktumlare avger efter huvuduppvärmningsfasen och håll dem kopplade till den färdiga cykeln istället för att läsa dem som nya cykler.", "label": "Aktivera skrynkelskyddsdetektering" diff --git a/custom_components/ha_washdata/translations/panel/tr.json b/custom_components/ha_washdata/translations/panel/tr.json index a4afae8..dad6a63 100644 --- a/custom_components/ha_washdata/translations/panel/tr.json +++ b/custom_components/ha_washdata/translations/panel/tr.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ Karışık olabilecek programlar" }, "pg_desc": { - "abrupt_drop_watts": "Ani düşüş anında bitiş sayılır", "completion_min_seconds": "Gerçek döngü sayılan en kısa çalışma", "end_repeat_count": "Bitmeden önce arka arkaya düşük okuma", "interrupted_min_seconds": "Kısa döngüler kesintiye uğramış olarak işaretlenir", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Akım gücünün bu kısmından daha büyük bir düşüş de ani olarak değerlendirilir (0,6 = %60'lık bir düşüş). Cihaz boyutlarındaki watt eşiğini tamamlar.", - "label": "Ani Düşüş Oranı" - }, - "abrupt_drop_watts": { - "doc": "Bundan daha büyük bir güç düşüşü, döngüyü doğal bir son yerine Kesintili (manuel iptal) olarak işaretler.", - "label": "Ani Düşüş" - }, "anti_wrinkle_enabled": { "doc": "Kurutucunun ana ısıtma aşamasından sonra yaydığı kısa, düşük güçlü tambur darbelerini tanıyın ve bunları yeni döngüler olarak okumak yerine bitmiş döngüye bağlı tutun.", "label": "Kırışıklık Önleme Algılamayı Etkinleştir" diff --git a/custom_components/ha_washdata/translations/panel/uk.json b/custom_components/ha_washdata/translations/panel/uk.json index eb8891a..5124f3d 100644 --- a/custom_components/ha_washdata/translations/panel/uk.json +++ b/custom_components/ha_washdata/translations/panel/uk.json @@ -954,14 +954,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "Падіння, яке перевищує цю частку поточної потужності, також вважається різким (0,6 = падіння на 60%). Доповнює порогове значення потужності для різних розмірів приладів.", - "label": "Коефіцієнт різкого падіння" - }, - "abrupt_drop_watts": { - "doc": "Більше падіння потужності позначає цикл як перерваний (ручне скасування), а не як природне завершення.", - "label": "Різке падіння" - }, "anti_wrinkle_enabled": { "doc": "Розпізнавайте короткі малопотужні імпульси обертання, які видає сушильна машина після основної фази нагрівання, і зберігайте їх прив’язаними до завершеного циклу замість того, щоб зчитувати їх як нові цикли.", "label": "Увімкнути виявлення захисту від зминання" @@ -1714,7 +1706,6 @@ "other": "Інше" }, "pg_desc": { - "abrupt_drop_watts": "Різке падіння вважається негайним завершенням", "completion_min_seconds": "Найкоротший запуск, що вважається справжнім циклом", "end_repeat_count": "Скільки низьких показань поспіль до завершення", "interrupted_min_seconds": "Короткі цикли позначаються як перервані", diff --git a/custom_components/ha_washdata/translations/panel/zh-Hans.json b/custom_components/ha_washdata/translations/panel/zh-Hans.json index 5226e28..7e2d134 100644 --- a/custom_components/ha_washdata/translations/panel/zh-Hans.json +++ b/custom_components/ha_washdata/translations/panel/zh-Hans.json @@ -867,7 +867,6 @@ "advisory_phase_inconsistent_title": "⚠ 可能混合了不同程序" }, "pg_desc": { - "abrupt_drop_watts": "骤降视为立即结束", "completion_min_seconds": "计为真实周期的最短运行时间", "end_repeat_count": "结束前连续的低读数次数", "interrupted_min_seconds": "短周期标记为中断", @@ -978,14 +977,6 @@ } }, "setting": { - "abrupt_drop_ratio": { - "doc": "大于电流功率这一部分的下降也被视为突然下降(0.6 = 60% 下降)。补充不同设备尺寸的瓦特阈值。", - "label": "突降比例" - }, - "abrupt_drop_watts": { - "doc": "功率下降大于此值会将循环标记为已中断(手动取消)而不是自然结束。", - "label": "突降功率" - }, "anti_wrinkle_enabled": { "doc": "识别烘干机在主要加热阶段后发出的短的低功率翻滚脉冲,并将它们附加到完成的循环,而不是将它们视为新循环。", "label": "启用防皱检测" diff --git a/custom_components/ha_washdata/ws_api.py b/custom_components/ha_washdata/ws_api.py index ab4517a..ba991d1 100644 --- a/custom_components/ha_washdata/ws_api.py +++ b/custom_components/ha_washdata/ws_api.py @@ -73,8 +73,10 @@ from .const import ( CONF_WATCHDOG_INTERVAL, DEFAULT_DEVICE_TYPE, DEFAULT_MAINTENANCE_REMINDER_CYCLES, + DEFAULT_MIN_POWER, DEFAULT_OFF_DELAY, DEFAULT_OFF_DELAY_BY_DEVICE, + DEFAULT_RUNNING_DEAD_ZONE, DEVICE_TYPE_PUMP, MAINTENANCE_EVENT_TYPES, DEVICE_TYPES, @@ -1838,7 +1840,9 @@ def ws_rebuild_envelopes( entry_id, "rebuild", "Rebuilding envelopes", label_key="task.rebuild.envelopes", label_params={}, ) - hass.async_create_task(_rebuild_envelopes_task(hass, task, entry_id)) + _raw = hass.async_create_task(_rebuild_envelopes_task(hass, task, entry_id)) + if _raw is not None: + reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "rebuild_envelopes", {"task_id": task.id}) @@ -1852,8 +1856,10 @@ async def _rebuild_envelopes_task(hass: HomeAssistant, task: Any, entry_id: str) return store = manager.profile_store lock = _entry_write_lock(hass, entry_id) - await lock.acquire() + acquired = False try: + await lock.acquire() + acquired = True names = list(store.get_profiles().keys()) reg.update(task, total=len(names), done=0) rebuilt = 0 @@ -1873,11 +1879,15 @@ async def _rebuild_envelopes_task(hass: HomeAssistant, task: Any, entry_id: str) state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, result={"success": True, "rebuilt": rebuilt}, ) + except asyncio.CancelledError: + reg.finish(task, state=task_registry.STATE_CANCELLED) + raise except Exception as exc: # pylint: disable=broad-exception-caught _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() + if acquired: + lock.release() @websocket_api.websocket_command( @@ -2703,12 +2713,22 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None # retrains and rewrites the store, and must not interleave with a concurrent # import / recording persist for the same entry. lock = _entry_write_lock(hass, entry_id) - await lock.acquire() + acquired = False try: + await lock.acquire() + acquired = True + if task.cancel_requested: + reg.finish(task, state=task_registry.STATE_CANCELLED) + return + reg.update(task, total=5, done=0, label="Reprocessing: matching cycles", label_key="task.reprocess.matching") summary["count"] = await store.async_reprocess_all_data() + if task.cancel_requested: + reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary) + return + reg.update(task, done=1, label="Reprocessing: backfilling golden", label_key="task.reprocess.golden") try: @@ -2716,6 +2736,10 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("golden backfill failed for %s: %s", entry_id, exc) + if task.cancel_requested: + reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary) + return + reg.update(task, done=2, label="Reprocessing: suggestions", label_key="task.reprocess.suggestions") learning = getattr(manager, "learning_manager", None) @@ -2739,6 +2763,10 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("ML training failed for %s: %s", entry_id, exc) + if task.cancel_requested: + reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary) + return + reg.update(task, done=4, label="Reprocessing: cycle health", label_key="task.reprocess.health") # Recompute per-cycle health against the (possibly retrained) model. @@ -2755,13 +2783,17 @@ async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None if _get_manager(hass, entry_id) is manager: manager.notify_update() reg.finish(task, state=task_registry.STATE_DONE, result=summary) + except asyncio.CancelledError: + reg.finish(task, state=task_registry.STATE_CANCELLED) + raise except Exception as exc: # pylint: disable=broad-exception-caught # Task-level failure: log at WARNING so it is visible in the default HA log # and the panel Logs view (sub-step failures above stay at debug on purpose). _LOGGER.warning("Reprocess task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) finally: - lock.release() + if acquired: + lock.release() @websocket_api.websocket_command( @@ -2783,7 +2815,9 @@ def ws_reprocess_history( return reg = task_registry.get_registry(hass) task = reg.create(entry_id, "reprocess", "Reprocessing") - hass.async_create_task(_reprocess_task(hass, task, entry_id)) + _raw = hass.async_create_task(_reprocess_task(hass, task, entry_id)) + if _raw is not None: + reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "reprocess_history", {"task_id": task.id}) @@ -3274,9 +3308,11 @@ def ws_trim_cycle( task = reg.create( entry_id, "trim", "Trimming cycle", label_key="task.trim.apply", label_params={}, ) - hass.async_create_task(_trim_task( + _raw = hass.async_create_task(_trim_task( hass, task, entry_id, msg["cycle_id"], float(msg["start_s"]), float(msg["end_s"]), )) + if _raw is not None: + reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "trim_cycle", {"task_id": task.id}) @@ -3293,8 +3329,13 @@ async def _trim_task( return store = manager.profile_store lock = _entry_write_lock(hass, entry_id) - await lock.acquire() + acquired = False try: + await lock.acquire() + acquired = True + if task.cancel_requested: + reg.finish(task, state=task_registry.STATE_CANCELLED) + return 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) @@ -3304,11 +3345,15 @@ async def _trim_task( if _get_manager(hass, entry_id) is manager: manager.notify_update() reg.finish(task, state=task_registry.STATE_DONE, result={"success": True}) + except asyncio.CancelledError: + reg.finish(task, state=task_registry.STATE_CANCELLED) + raise except Exception as exc: # pylint: disable=broad-exception-caught _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() + if acquired: + lock.release() @websocket_api.websocket_command( @@ -3426,7 +3471,9 @@ def ws_apply_split( 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)) + _raw = hass.async_create_task(_apply_split_task(hass, task, entry_id, cycle_id, segments)) + if _raw is not None: + reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "apply_split", {"task_id": task.id}) @@ -3444,8 +3491,13 @@ async def _apply_split_task( return store = manager.profile_store lock = _entry_write_lock(hass, entry_id) - await lock.acquire() + acquired = False try: + await lock.acquire() + acquired = True + if task.cancel_requested: + reg.finish(task, state=task_registry.STATE_CANCELLED) + return reg.update(task, total=1, done=0) new_ids = await store.apply_split_interactive(cycle_id, segments) reg.update(task, done=1) @@ -3455,9 +3507,15 @@ async def _apply_split_task( task, state=task_registry.STATE_DONE, result={"success": True, "new_ids": new_ids}, ) + except asyncio.CancelledError: + reg.finish(task, state=task_registry.STATE_CANCELLED) + raise except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.warning("Apply-split task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) + finally: + if acquired: + lock.release() @websocket_api.websocket_command( @@ -3508,7 +3566,9 @@ def ws_apply_merge( 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)) + _raw = hass.async_create_task(_apply_merge_task(hass, task, entry_id, ids, target, new_name)) + if _raw is not None: + reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "apply_merge", {"task_id": task.id}) @@ -3526,8 +3586,13 @@ async def _apply_merge_task( return store = manager.profile_store lock = _entry_write_lock(hass, entry_id) - await lock.acquire() + acquired = False try: + await lock.acquire() + acquired = True + if task.cancel_requested: + reg.finish(task, state=task_registry.STATE_CANCELLED) + return reg.update(task, total=1, done=0) created_new = False if new_name: @@ -3553,11 +3618,15 @@ async def _apply_merge_task( task, state=task_registry.STATE_DONE, result={"success": True, "new_id": new_id}, ) + except asyncio.CancelledError: + reg.finish(task, state=task_registry.STATE_CANCELLED) + raise except Exception as exc: # pylint: disable=broad-exception-caught _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() + if acquired: + lock.release() # ─── Profile envelope / member cycles ────────────────────────────────────────── @@ -4620,17 +4689,23 @@ async def _ml_training_task(hass: HomeAssistant, task: Any, entry_id: str) -> No # reprocess itself runs ML training): training rewrites the store, so two runs # for the same entry must not interleave. lock = _entry_write_lock(hass, entry_id) - await lock.acquire() + acquired = False try: + await lock.acquire() + acquired = True summary = await manager.async_run_ml_training(force=True) reg.finish(task, state=task_registry.STATE_DONE, result=summary) + except asyncio.CancelledError: + reg.finish(task, state=task_registry.STATE_CANCELLED) + raise except Exception as exc: # pylint: disable=broad-exception-caught # Task-level failure: log at WARNING so it surfaces in the default HA log and # the panel Logs view (not swallowed at debug like a routine sub-step miss). _LOGGER.warning("ML training task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) finally: - lock.release() + if acquired: + lock.release() @websocket_api.websocket_command( @@ -4656,7 +4731,9 @@ def ws_trigger_ml_training( return reg = task_registry.get_registry(hass) task = reg.create(entry_id, "ml_training", "Learning") - hass.async_create_task(_ml_training_task(hass, task, entry_id)) + _raw = hass.async_create_task(_ml_training_task(hass, task, entry_id)) + if _raw is not None: + reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "trigger_ml_training", {"task_id": task.id}) @@ -4838,7 +4915,7 @@ def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig: opts: dict[str, Any] = {} if entry is not None: opts = {**getattr(entry, "data", {}), **getattr(entry, "options", {})} - min_power = float(opts.get(CONF_MIN_POWER, 5.0) or 5.0) + min_power = float(opts.get(CONF_MIN_POWER, DEFAULT_MIN_POWER) or DEFAULT_MIN_POWER) return CycleDetectorConfig( min_power=min_power, off_delay=int(opts.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY)), @@ -4846,7 +4923,7 @@ def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig: completion_min_seconds=int(opts.get(CONF_COMPLETION_MIN_SECONDS, 600)), end_repeat_count=int(opts.get(CONF_END_REPEAT_COUNT, 1)), min_off_gap=int(opts.get(CONF_MIN_OFF_GAP, 60)), - running_dead_zone=int(opts.get(CONF_RUNNING_DEAD_ZONE, 0)), + running_dead_zone=int(opts.get(CONF_RUNNING_DEAD_ZONE, DEFAULT_RUNNING_DEAD_ZONE)), start_threshold_w=float(opts.get(CONF_START_THRESHOLD_W, min_power)), stop_threshold_w=float( opts.get(CONF_STOP_THRESHOLD_W, min_power * 0.6 if min_power else 2.0) @@ -5186,6 +5263,9 @@ async def _pg_history_task( else: ids = [c.get("id") for c in past[-playground.DEFAULT_RECENT_CYCLES:]] ids = [i for i in ids[:playground.MAX_BATCH_CYCLES] if i] + # Build match snapshots once — they are store-derived and identical for + # every chunk; rebuilding per chunk is O(n_profiles) wasted work. + prebuilt = await hass.async_add_executor_job(playground._build_match_snapshots, store) reg.update(task, total=len(ids)) rows: list[dict[str, Any]] = [] base_rows: list[dict[str, Any]] = [] @@ -5195,7 +5275,7 @@ async def _pg_history_task( chunk = ids[i:i + _PG_HISTORY_CHUNK] r = await hass.async_add_executor_job( playground.run_playground_history, - store, chunk, base_config, override, options, price, len(chunk), + store, chunk, base_config, override, options, price, len(chunk), prebuilt, ) rows.extend(r.get("rows") or []) base_rows.extend(r.get("baseline_rows") or []) @@ -5207,6 +5287,9 @@ async def _pg_history_task( state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, result=payload, ) + except asyncio.CancelledError: + reg.finish(task, state=task_registry.STATE_CANCELLED) + raise except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground history task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) @@ -5228,6 +5311,8 @@ async def _pg_sweep_task( ids = [c.get("id") for c in past[-playground.DEFAULT_RECENT_CYCLES:] if isinstance(c, dict)] ids = [i for i in ids[:playground.MAX_BATCH_CYCLES] if i] n = max(1, len(ids)) + # Build match snapshots once — identical for every sweep value/cell. + prebuilt = await hass.async_add_executor_job(playground._build_match_snapshots, store) if param_y and values_y: reg.update(task, total=len(values) * len(values_y)) grid: list[list[float | None]] = [[None] * len(values) for _ in values_y] @@ -5242,7 +5327,7 @@ async def _pg_sweep_task( r = await hass.async_add_executor_job( playground.run_playground_sweep, store, ids, base_config, param, [vx], objective, - options, price, n, param_y, [vy], + options, price, n, param_y, [vy], prebuilt, ) cell = (r.get("grid") or [[None]])[0] grid[j][i] = cell[0] if cell else None @@ -5263,6 +5348,7 @@ async def _pg_sweep_task( r = await hass.async_add_executor_job( playground.run_playground_sweep, store, ids, base_config, param, [vx], objective, options, price, n, + None, None, prebuilt, ) points.extend(r.get("points") or []) if r.get("current_value") is not None: @@ -5275,6 +5361,9 @@ async def _pg_sweep_task( state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, result=payload, ) + except asyncio.CancelledError: + reg.finish(task, state=task_registry.STATE_CANCELLED) + raise except Exception as exc: # pylint: disable=broad-exception-caught _LOGGER.debug("Playground sweep task failed for %s: %s", entry_id, exc) reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc)) @@ -5304,7 +5393,9 @@ def ws_start_playground_history( task = reg.create(entry_id, "pg_history", "Test on history") override = dict(msg.get("settings_override") or {}) or None cycle_ids = list(msg.get("cycle_ids") or []) - hass.async_create_task(_pg_history_task(hass, task, entry_id, cycle_ids, override)) + _raw = hass.async_create_task(_pg_history_task(hass, task, entry_id, cycle_ids, override)) + if _raw is not None: + reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "start_playground_history", {"task_id": task.id}) @@ -5340,10 +5431,12 @@ def ws_start_playground_sweep( entry_id, "pg_sweep", f"Optimize: {msg['param']}", label_key="task.pg_sweep.optimize", label_params={"param": msg["param"]}, ) - hass.async_create_task(_pg_sweep_task( + _raw = hass.async_create_task(_pg_sweep_task( hass, task, entry_id, msg["param"], list(msg.get("values") or []), msg["objective"], param_y, list(values_y) if values_y else None, )) + if _raw is not None: + reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "start_playground_sweep", {"task_id": task.id}) @@ -5393,6 +5486,9 @@ async def _pg_detail_task( state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE, result=payload, ) + except asyncio.CancelledError: + reg.finish(task, state=task_registry.STATE_CANCELLED) + raise 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)) @@ -5426,7 +5522,9 @@ def ws_start_playground_cycle_detail( label_key="task.pg_detail.simulate", label_params={}, ) override = dict(msg.get("settings_override") or {}) - hass.async_create_task( + _raw = hass.async_create_task( _pg_detail_task(hass, task, entry_id, msg["cycle_id"], override) ) + if _raw is not None: + reg.link_asyncio_task(task.id, _raw) _send_result(connection, msg["id"], "start_playground_cycle_detail", {"task_id": task.id}) diff --git a/custom_components/ha_washdata/www/ha-washdata-panel.js b/custom_components/ha_washdata/www/ha-washdata-panel.js index cfbf656..7c5f54d 100644 --- a/custom_components/ha_washdata/www/ha-washdata-panel.js +++ b/custom_components/ha_washdata/www/ha-washdata-panel.js @@ -129,10 +129,6 @@ const _SETTINGS_SECTIONS = [ doc: 'Expected time between sensor readings - used to size the smoothing window and start debounce correctly. Every sensor update is captured regardless of this value; it only calibrates the downstream calculations. The suggestion engine measures your sensor\'s actual cadence from past cycles and sets this automatically.' }, { key: 'smoothing_window', label: 'Smoothing Window', type: 'number', min: 1, def: 2, doc: 'How much the raw power signal is smoothed. Low (2) is responsive but noisy; high (5) smooths spikes but adds lag.' }, - { key: 'abrupt_drop_watts', label: 'Abrupt Drop', unit: 'W', type: 'number', min: 0, def: 500, - doc: 'A power drop larger than this flags the cycle as Interrupted (manual cancel) rather than a natural finish.' }, - { key: 'abrupt_drop_ratio', label: 'Abrupt Drop Ratio', type: 'number', step: 0.05, min: 0, max: 1, def: 0.6, - doc: 'A drop larger than this fraction of current power is also treated as abrupt (0.6 = a 60% drop). Complements the watts threshold across appliance sizes.' }, ] }, ] }, { id: 'matching', label: 'Matching', intro: 'How finished cycles are matched to learned profiles and labelled.', notDeviceTypes: ['other'], groups: [ @@ -1375,7 +1371,7 @@ function _field(f, value, extra) { // Chip/pill multi-picker: existing values as removable pills + a combobox // add-input. Managed by DOM (no re-render) and collected on save. const vals = Array.isArray(value) ? value : (value ? [value] : []); - const pills = vals.map(x => `${_esc(x)}`).join(''); + const pills = vals.map(x => `${_esc(x)}`).join(''); input = `

${pills}` + `
` + `` + @@ -1500,7 +1496,6 @@ const _DIAGRAM_BY_KEY = { min_power: 'min_power', off_delay: 'off_delay', smoothing_window: 'smoothing', start_threshold_w: 'hysteresis', stop_threshold_w: 'hysteresis', start_energy_threshold: 'start_energy', running_dead_zone: 'dead_zone', - abrupt_drop_watts: 'abrupt_drop', abrupt_drop_ratio: 'abrupt_drop', profile_duration_tolerance: 'duration_tolerance', profile_match_min_duration_ratio: 'match_ratios', profile_match_max_duration_ratio: 'match_ratios', progress_reset_delay: 'progress_reset', completion_min_seconds: 'min_duration', @@ -1578,11 +1573,6 @@ function _diagram(id) { -tol+tolprofile`); - case 'abrupt_drop': - return wrap(`${base} - - - abrupt`); case 'match_ratios': return wrap(`${base} @@ -1701,6 +1691,8 @@ class HaWashdataPanel extends HTMLElement { this._toastTimer = null; this._hassUpdateThrottle = null; this._evtUnsubs = []; + this._hoverRafId = null; // rAF handle for chart-hover coalescing + this._hoverPending = null; // last pending hover coords {px, py, id} // Data this._constants = { stateColors: {}, deviceTypes: [], mlLabEnabled: false, mlSuggestionsEnabled: false, mlTrainingAvailable: false, storeOnlineAvailable: false, storeOnlineEnabled: false, storeWebOrigin: '', storePrefs: {}, pgMatchDefaults: {} }; this._constantsLoaded = false; @@ -1866,7 +1858,22 @@ class HaWashdataPanel extends HTMLElement { set narrow(n) { this._narrow = n; } connectedCallback() { - if (this._initialized) this._startPoll(); + if (this._initialized) { + this._startPoll(); + // Restore WS push subscriptions (cycle events + task registry) that + // disconnectedCallback tore down. Without this, navigate-away/back leaves + // the panel relying only on the 30s fallback poll — live cycle transitions + // and task progress no longer update, and modal Escape/Tab are dead. + this._setupSubscriptions(); + // Immediately refresh state so a navigate-away/back shows current data + // instead of waiting up to 30s for the next poll tick. + this._fetchAll(); + // Re-attach the modal keyboard handler (removed on disconnect). + if (this.shadowRoot && !this._kbdHandler) { + this._kbdHandler = (e) => this._onKeydown(e); + this.shadowRoot.addEventListener('keydown', this._kbdHandler); + } + } this._onResize = () => this._resizeLogsPage(); window.addEventListener('resize', this._onResize); } @@ -1907,6 +1914,14 @@ class HaWashdataPanel extends HTMLElement { this._fetchAll(); this._startPoll(); }); + this._setupSubscriptions(); + } + + _setupSubscriptions() { + // Tear down any existing subscriptions first so reconnect is idempotent. + this._evtUnsubs.forEach(u => { try { u(); } catch (_) {} }); + this._evtUnsubs = []; + this._tasksSubscribed = false; // Subscribe to WashData cycle events for immediate push-refresh. // These fire when a cycle starts/ends so the UI updates instantly // instead of waiting for the 30s fallback poll. @@ -2176,6 +2191,11 @@ class HaWashdataPanel extends HTMLElement { // Header activity cluster: one pill per running task (device · action · % · ✕). _htmlTaskPills() { const running = Object.values(this._tasks || {}).filter(t => t.state === 'running'); + // Prune stale cancelling-task IDs: if a task is no longer running (evicted from + // the registry, finished while our WS subscription was down, etc.), its + // "Cancelling…" pill would never clear on its own — clean it up here. + const runningIds = new Set(running.map(t => t.id)); + this._cancellingTasks.forEach(id => { if (!runningIds.has(id)) this._cancellingTasks.delete(id); }); if (!running.length) return ''; return running.map(t => { const cancelling = this._cancellingTasks.has(t.id); @@ -2394,6 +2414,13 @@ class HaWashdataPanel extends HTMLElement { await this._fetchCycles(dev.entry_id); await this._fetchSuggestions(dev.entry_id); await this._fetchProfiles(dev.entry_id); + // Prime the Setup Card on the very first paint so it appears immediately + // without requiring a tab click or device switch. + if (this._tab === 'status') { + try { + this._setupStatus = await this._ws({ type: `${_DOMAIN}/get_setup_status`, entry_id: dev.entry_id }); + } catch (_) { this._setupStatus = null; } + } // The Store tab's visibility depends on this._onlineEnabled(), // which is normally loaded per-tab. Prime it at boot ONLY when the backend // exposes online features, so the tab can appear without visiting Settings. @@ -2813,6 +2840,7 @@ class HaWashdataPanel extends HTMLElement { this._profiles = []; this._profileHealth = {}; this._profileTrends = {}; this._coverageGaps = {}; this._profileAdvisories = []; this._opts = {}; this._suggestions = []; this._cycles = []; this._refCycles = []; this._recState = null; this._diag = null; this._maintenance = null; this._phases = []; this._mlTrainingStatus = null; // per-device; re-fetched by _fetchTabData + this._setupStatus = null; // per-device; re-fetched by _fetchTabData this._deviceAutomations = []; // per-device; re-fetched on the settings tab this._selectMode = false; this._cycleSel = new Set(); this._cycleFilter = { text: '', status: '' }; @@ -3445,7 +3473,7 @@ class HaWashdataPanel extends HTMLElement { `; - const burger = ``; return ` @@ -5190,7 +5218,6 @@ class HaWashdataPanel extends HTMLElement { ['completion_min_seconds', 'Min Cycle Duration', 's', 'Shortest run that counts as a real cycle', 'timing'], ['start_duration_threshold','Start Duration', 's', 'Seconds above threshold to confirm start', 'timing'], ['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', '', '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'], @@ -7021,7 +7048,7 @@ class HaWashdataPanel extends HTMLElement { _htmlLogDrawer() { return `
-
+
${this._t('hdr.logs', {}, 'Logs')}
@@ -7048,7 +7075,10 @@ class HaWashdataPanel extends HTMLElement { const dpr = window.devicePixelRatio || 1; const cw = Math.max(1, Math.round(rect.width * dpr)); const ch = Math.max(1, Math.round((rect.height || 240) * dpr)); - canvas.width = cw; canvas.height = ch; + // Only reallocate the canvas backing store when dimensions actually changed; + // reassigning canvas.width/height unconditionally clears the canvas AND triggers + // a GPU-side realloc on every hover redraw. + if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch; } const ctx = canvas.getContext('2d'); const cs = getComputedStyle(this); const primary = (cs.getPropertyValue('--primary-color') || '#03a9f4').trim() || '#03a9f4'; @@ -7255,12 +7285,25 @@ class HaWashdataPanel extends HTMLElement { } _onGraphHover(e, id) { - const canvas = this.shadowRoot.getElementById(id); + // rAF-coalesce: many pointermove events fire per frame; only do one redraw+overlay + // per animation frame. Capture coordinates immediately (they may be stale by rAF). + const px = e.clientX, py = e.clientY; + if (this._hoverRafId) { this._hoverPending = { px, py, id }; return; } + this._hoverPending = { px, py, id }; + this._hoverRafId = requestAnimationFrame(() => { + this._hoverRafId = null; + const { px: cx, py: cy, id: cid } = this._hoverPending || {}; + if (!cid) return; + this._onGraphHoverInner(cx, cy, cid); + }); + } + _onGraphHoverInner(px, py, id) { + const canvas = this.shadowRoot && this.shadowRoot.getElementById(id); const wd = canvas && canvas._wd; if (!wd) return; const rect = canvas.getBoundingClientRect(); - const x = wd.cssToX(e.clientX - rect.left); - const cursorYdev = (e.clientY - rect.top) * wd.dpr; + const x = wd.cssToX(px - rect.left); + const cursorYdev = (py - rect.top) * wd.dpr; this._redrawCanvas(id); const ctx = canvas.getContext('2d'); const xp = wd.Xpx(x); @@ -7304,7 +7347,7 @@ class HaWashdataPanel extends HTMLElement { }); if (this._canvasZoom[id]) lines.push(`${this._t('lbl.zoom_hint', {}, 'scroll to zoom · dblclick to reset')}`); ctx.restore(); - this._showGraphTip(e.clientX, e.clientY, lines); + this._showGraphTip(px, py, lines); this._syncSpagRowHighlight(this._hoverNearest ? this._hoverNearest.cid : null); } @@ -7321,7 +7364,7 @@ class HaWashdataPanel extends HTMLElement { tip.style.top = Math.max(6, top) + 'px'; } - _hideGraphTip() { if (this._gtip) this._gtip.style.display = 'none'; this._syncSpagRowHighlight(null); } + _hideGraphTip() { if (this._hoverRafId) { cancelAnimationFrame(this._hoverRafId); this._hoverRafId = null; this._hoverPending = null; } if (this._gtip) this._gtip.style.display = 'none'; this._syncSpagRowHighlight(null); } _syncSpagRowHighlight(cid) { if (cid === this._spagHoverCid) return; @@ -7399,8 +7442,8 @@ class HaWashdataPanel extends HTMLElement {
-
Remove this many seconds from the start
-
Remove this many seconds from the end
+
${_esc(this._t('msg.head_trim_hint', {}, 'Remove this many seconds from the start'))}
+
${_esc(this._t('msg.tail_trim_hint', {}, 'Remove this many seconds from the end'))}
`; } else if (m.type === 'correct-feedback') { @@ -7427,7 +7470,7 @@ class HaWashdataPanel extends HTMLElement { body = `

${this._t('modal.merge_cycles', {n: m.ids.length}, `Merge ${m.ids.length} Cycles`)}

${this._t('msg.merge_intro', {}, 'The selected cycles are combined into one (chronological order; gaps filled with 0 W). Pick the resulting profile.')}

-
+
`; @@ -9048,7 +9091,9 @@ class HaWashdataPanel extends HTMLElement { // Click the highlighted curve on the graph to toggle that cycle's selection. const spag = sr.getElementById('wd-spag-canvas'); if (spag) spag.addEventListener('pointerdown', e => { - this._onGraphHover(e, 'wd-spag-canvas'); + // Hit-test synchronously: _onGraphHover defers via rAF, so _hoverNearest + // would still be stale/null on this same tick and the click would no-op. + this._onGraphHoverInner(e.clientX, e.clientY, 'wd-spag-canvas'); const hn = this._hoverNearest; if (hn && hn.cid) { const sel = m.cleanup.selected; diff --git a/custom_components/maintenance_supporter/__pycache__/__init__.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/__init__.cpython-314.pyc index ddf62266498c81ae2a89c216077429c163e9b0c5..dfd5209b28995cbac76bac793fdccc3425afbc5f 100644 GIT binary patch delta 25 fcmaEGljXrp7H(}mUM>b8xEh(%$i0=D@v060ZVv~v delta 25 fcmaEGljXrp7H(}mUM>b8;Hiyob8xEh(XkvljQ099-TvH$=8 delta 22 ccmcb2it*klMs96BUM>b8;Hiz@$Q_&t08q9D3IG5A diff --git a/custom_components/maintenance_supporter/__pycache__/condition.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/condition.cpython-314.pyc index fff914fba2e5a4dd3f52857ad4f1c7480daa1eca..69e3550bc1cc52274e45311c85fda6cef17ab0ee 100644 GIT binary patch delta 20 acmbOrI6;tGn~#@^0SK-}CT-+yb8xEh(Xk$b~O097ytivR!s delta 22 ccmdn}n0fbOW^QdhUM>b8;Hiz@$i3ks08n}c;{X5v 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 bd9951ca49366d2f9db78a78c98ee4149fe0a804..efd91ce17e198d0ef532bac578f4ce1f2bbe5038 100644 GIT binary patch delta 22 ccmX@v$au1mkz1ROmx}=iu0|$pb8xEh(XkvnKH096PE=Kufz delta 22 ccmcb6jOp$%CT?v$UM>b8;Hiz@$Q`s808ml}KL7v# 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 4ba0c807a39c7a1fff0a1eb48bd5a25e50d033a4..a89f17881fb5836aa16921a6ebc869e2d91e0c21 100644 GIT binary patch delta 22 ccmZqq#nkePiCdeGmx}=iu0|$pw_08knST>t<8 delta 22 ccmZqq#nkePiCdeGmx}=icxvM}a{t@|083;BwEzGB diff --git a/custom_components/maintenance_supporter/__pycache__/const.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/const.cpython-314.pyc index accf0637cb3f5c76905b9a83d54c1ce1dad423ac..b70f78a0a040fce348a2320a7d676bbf3c8a5aed 100644 GIT binary patch delta 22 ccmdlqk#W;RMs96BUM>b8xEh(Xk$brZ07g6p9{>OV delta 22 ccmdlqk#W;RMs96BUM>b8;Hiz@$i3VH06~TYcK`qY diff --git a/custom_components/maintenance_supporter/__pycache__/coordinator.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/coordinator.cpython-314.pyc index 84493d09db13af920e2fad3a0f7b3428e6c4cdac..b15b38aa600c9c34beff59ea40cd8dd0c50715ef 100644 GIT binary patch delta 22 ccmdn`hk5HCW^QdhUM>b8xEh(Xkz4B@09OhJH~;_u delta 22 ccmdn`hk5HCW^QdhUM>b8;Hiz@$gTAc08&&2kN^Mx diff --git a/custom_components/maintenance_supporter/__pycache__/diagnostics.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/diagnostics.cpython-314.pyc index 78868a54f2e9f95e04d93d69d6b48a8bd4f51572..3531a700e82467cf517507ed1c93080303da3ed6 100644 GIT binary patch delta 20 acmdlUuswiVn~#@^0SK-}CT--_(EtEA&;<$r delta 20 acmdlUuswiVn~#@^0SI_%<2Q2aXaE2-8U$hh diff --git a/custom_components/maintenance_supporter/__pycache__/intent.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/intent.cpython-314.pyc index fbe77a0c95702f69788c0b8e89a71183095b0ec3..7f28bee098051d434e99eb7e8be4aa4f207a3ce9 100644 GIT binary patch delta 22 ccmX>b8xEh(Xk^4v&08AbR!Tb8;Hiz@$bF;>07qyB8UO$Q diff --git a/custom_components/maintenance_supporter/__pycache__/logbook.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/logbook.cpython-314.pyc index c751d9eb21c930c812a6bc44283b41a3e972e4cc..06d622bd92031b02ef02eb167c64b6ba4a0cb05e 100644 GIT binary patch delta 20 acmdm-vN?rYn~#@^0SK-}CT--_FaiKO3I#9# delta 20 acmdm-vN?rYn~#@^0SI_%<2Q0^7y$q_R0N0s diff --git a/custom_components/maintenance_supporter/__pycache__/panel.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/panel.cpython-314.pyc index 9d2b7cf88c464160229ebb409ff553b41c9f44a0..c5ef250c439f7f0553f17ccf3494560e5a0b1638 100644 GIT binary patch delta 20 acmdn4yJ5(HUKkn1h@bI diff --git a/custom_components/maintenance_supporter/__pycache__/repairs.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/repairs.cpython-314.pyc index cac67359725049228545e58528a4f44e57180eca..7b7d82917246bca36b483cac757a5322ae9d0bcc 100644 GIT binary patch delta 22 ccmbO|mudD~CT?v$UM>b8xEh(Xk$XZP07RY!IsgCw delta 22 ccmbO|mudD~CT?v$UM>b8;Hiz@$UUJC06*vjk^lez diff --git a/custom_components/maintenance_supporter/__pycache__/sensor.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/sensor.cpython-314.pyc index d137c929f76914437b03958172c232e25c5552e2..8a4749169c1c72bd501f41121de8c209a44279c3 100644 GIT binary patch delta 22 ccmZpC$<+9giCdeGmx}=iu0|$pb8xEh(Xk$XuZ08MfRlK=n! delta 22 ccmZ2~m~s7KMs96BUM>b8;Hiz@$h{;H07$$A>i_@% diff --git a/custom_components/maintenance_supporter/__pycache__/templates.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/templates.cpython-314.pyc index 3733002caae2d759085caa0478cdcedd00025590..a9697ec25f743ed60c78b511ad2d6158bc96cee9 100644 GIT binary patch delta 22 ccmZ3!pKb8xEh(Xk$X-&084xZaR2}S delta 22 ccmZ3!pKb8;Hiz@$UP?>07k|I$p8QV 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 8f03214352834823958f79c19c5705e1f17cb475..87f0f50fc52817ddc92dcf14d8d92b3e3d8ca989 100644 GIT binary patch delta 31 lcmbQek8k!qK5lJ3UM>b8xEh(%$lc1#*viecm77^$J^+W62ipJu delta 31 lcmbQek8k!qK5lJ3UM>b8;HiyoJ84+yEu<1Iz#b diff --git a/custom_components/maintenance_supporter/__pycache__/views.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/views.cpython-314.pyc index 5b513364607f34e4eb764297eeaef74100b25b75..f298400228bfdf38c2e0886f693699b9e11c94f3 100644 GIT binary patch delta 22 ccmaE|obkzWMs96BUM>b8xEh(Xk^5#a08$4A>;M1& delta 22 ccmaE|obkzWMs96BUM>b8;Hiz@$bB;y08LQ_L;wH) 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 64c4e2be493dc2f3b50659caa224650afbe18129..4dec561ba8007108518c81c93f80d6f69e8457c9 100644 GIT binary patch delta 20 acmaFM^p=TRn~#@^0SK-}CT-+SVgvv-Aq43F delta 20 acmaFM^p=TRn~#@^0SI_%<2Q0AF#-TFYXm_6 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 8cab05bdbde89607af1ae0f382ff8ab7faf4b321..8a6e3d3f959657ed6a55d6dc3033865f3e17515f 100644 GIT binary patch delta 20 acmeyY@mYggn~#@^0SK-}CT---5CH%@d<9wn delta 20 acmeyY@mYggn~#@^0SI_%<2Q0=hyVaL#ssne 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 726ddad57238ecd94a3ab0b7fd6dda2b2d0e21df..ba413d78edd67e472132f39db61d80d1d71af500 100644 GIT binary patch delta 20 ZcmeCP>9gV1=HumJ0D`NLNgKJD9gV1=HumJ00N%c_>J7masVpW1GNAE 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 9fe523bbe57b798f7f74792189b3964425f6e294..5f1a9cc2ceb4d151342cd3d206d271ec0439165b 100644 GIT binary patch delta 20 acmaFw@ZN!2n~#@^0SK-}CT-+SQ2+oxNCk=j delta 20 acmaFw@ZN!2n~#@^0SI_%<2Q1rC;$LDk_6%a 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 f502fc1f5222f5a6b5a5bf84b1b4f443c801062c..4b6b40d49f878ea17d62dfc18526704df117bb37 100644 GIT binary patch delta 20 acmca#c)yTan~#@^0SK-}CT--tVgUd|#0FFV delta 20 acmca#c)yTan~#@^0SI_%<2Q0&u>b%-4h5_L 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 b0ca57b5e0dc6a0c17658aac5cfca3ef8395e2a0..443e7380e51f7a18fb454f2b66382a501b4fc79d 100644 GIT binary patch delta 20 acmZ2jyRep9n~#@^0SK-}CT--NW(NQ~$_2Op delta 20 acmZ2jyRep9n~#@^0SI_%<2Q0ovjYG)6a^3f 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 5bef85213a02ff1999f4e9d83a6d5b1f6c5a8204..40417dbf1549ca9a8f3e0d084a1da50e58a2a402 100644 GIT binary patch delta 20 acmdnzu+M>8n~#@^0SK-}CT-+4RsaAupak~- delta 20 acmdnzu+M>8n~#@^0SI_%<2P~}D*ym8>I6>! 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 cce3ef91b4e2edd7a78068c27c986f2aee7dda48..05596323945fe8f1e2ae69bc09d8b792f68f4d3b 100644 GIT binary patch delta 22 ccmX@m!+4;Fkz1ROmx}=iu0|$p delta 20 acmcZi_^bb_B-& 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 21f81a8d7bc4c0567518fc3428433be3108d4357..4b6b358429174c511ef182d9510de8e98305bccf 100644 GIT binary patch delta 20 acmbQ^J;$3{n~#@^0SK-}CT--Ns0siy;RP%J delta 20 acmbQ^J;$3{n~#@^0SI_%<2Q0oR0RMqD+Gi9 diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-renderer.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-renderer.test.ts index 2bf951c..76137a9 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-renderer.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-renderer.test.ts @@ -5,8 +5,8 @@ * behaviour the extraction must preserve: * - header renders name / object breadcrumb / status chip / actions * - Complete / Skip route to the panel callbacks with the task - * - operator mode hides archive + the more-menu (read-only surface) - * - the more-menu items (edit/duplicate/reset/snooze/delete) fire callbacks + * - operator mode: only Complete/Skip; the more-menu carries just QR + worksheet + * - the more-menu items (edit/qr/worksheet/duplicate/…/archive/delete) fire callbacks * - tab bar switches via setActiveTab; history tab renders the timeline * - KPI bar shows warning days + currency; user badge resolves names */ @@ -154,16 +154,18 @@ describe("task-detail renderer", () => { expect(skipped).to.equal(1); }); - it("operator mode hides archive button and the more-menu", () => { - const host = mount(task(), ctx({ isOperator: true })); - expect(host.querySelector(".more-menu-wrapper")).to.be.null; - // Only Complete / Skip / QR remain. - const labels = [...host.querySelectorAll(".task-header-actions ha-button")] - .map((b) => b.textContent || ""); - expect(labels.some((l) => /archive/i.test(l))).to.be.false; + it("operator mode: only Complete/Skip buttons; menu carries just QR + worksheet", () => { + const host = mount(task(), ctx({ isOperator: true, moreMenuOpen: true })); + // The ⋮ stays (QR + worksheet moved into it), but write actions don't. + expect(host.querySelector(".more-menu-wrapper"), "menu present").to.exist; + const buttons = [...host.querySelectorAll(".task-header-actions ha-button")]; + expect(buttons.length).to.equal(2); // Complete + Skip only + const items = [...host.querySelectorAll(".popup-menu-item")].map((i) => i.textContent?.trim() || ""); + expect(items.length).to.equal(2); + expect(items.join(" ")).to.not.match(/edit|archive|delete|duplicate|reset/i); }); - it("open more-menu lists edit/duplicate/reset/postpone/snooze/worksheet/delete and fires callbacks", () => { + it("open more-menu lists edit/qr/worksheet/duplicate/reset/postpone/snooze/archive/delete and fires callbacks", () => { const calls: string[] = []; const host = mount(task(), ctx({ moreMenuOpen: true, @@ -172,14 +174,19 @@ describe("task-detail renderer", () => { promptPostpone: () => calls.push("postpone"), snoozeTask: () => calls.push("snooze"), printWorksheet: () => calls.push("worksheet"), + toggleArchive: () => calls.push("archive"), })); const items = [...host.querySelectorAll(".popup-menu-item")]; - expect(items.length).to.equal(7); - (items[3] as HTMLElement).click(); // postpone - (items[4] as HTMLElement).click(); // snooze - (items[5] as HTMLElement).click(); // work sheet (v2.21) - (items[6] as HTMLElement).click(); // delete (danger) - expect(calls).to.deep.equal(["close", "postpone", "close", "snooze", "close", "worksheet", "close", "delete"]); + // edit, qr, worksheet, duplicate, reset, postpone, snooze, archive, delete + expect(items.length).to.equal(9); + (items[5] as HTMLElement).click(); // postpone + (items[6] as HTMLElement).click(); // snooze + (items[2] as HTMLElement).click(); // work sheet (v2.21) + (items[7] as HTMLElement).click(); // archive (demoted from the header) + (items[8] as HTMLElement).click(); // delete (danger) + expect(calls).to.deep.equal([ + "close", "postpone", "close", "snooze", "close", "worksheet", "close", "archive", "close", "delete", + ]); }); it("shows a postponed badge when the task has a due_override, and none otherwise", () => { diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-view.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-view.test.ts index 500e078..12c8c7a 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-view.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-detail-view.test.ts @@ -177,8 +177,12 @@ describe("maintenance-task-detail-view", () => { expect(el.querySelector(".detail-section")).to.equal(null); }); - it("hides the more-menu for operators (read-only surface preserved)", async () => { - const el = await mount(task(), ctx({ isOperator: true })); - expect(el.querySelector(".more-menu-wrapper")).to.equal(null); + it("operators get the more-menu with only the read-safe items (QR + worksheet)", async () => { + const el = await mount(task(), ctx({ isOperator: true, moreMenuOpen: true })); + expect(el.querySelector(".more-menu-wrapper"), "menu present for operators").to.exist; + const labels = [...el.querySelectorAll(".popup-menu-item")].map((i) => i.textContent?.trim()); + expect(labels.length).to.equal(2); + // No write actions leak into the operator menu. + expect(labels.join(" ")).to.not.match(/edit|archive|delete|duplicate|reset/i); }); }); diff --git a/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts b/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts index f14ce8d..2ea699d 100644 --- a/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts +++ b/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts @@ -130,6 +130,7 @@ export class MaintenanceSupporterPanel extends LitElement { @state() private _defaultWarningDays = 7; @state() private _actionLoading = false; @state() private _moreMenuOpen = false; + @state() private _objMenuOpen = false; @state() private _toastMessage = ""; @state() private _toastUndo: (() => void) | null = null; @state() private _toastActionLabel = ""; @@ -3014,34 +3015,34 @@ export class MaintenanceSupporterPanel extends LitElement {

${o.name}

${!isOperator ? html` - { - const dlg = this.shadowRoot!.querySelector("maintenance-object-dialog"); - dlg?.openEdit(obj.entry_id, o); - }}>${t("edit", L)} { const dlg = this.shadowRoot!.querySelector("maintenance-task-dialog"); dlg?.openCreate(obj.entry_id); }}>${t("add_task", L)} - this._duplicateObject(obj.entry_id)}> - ${t("duplicate", L)} - - this._toggleArchiveObject(obj.entry_id, !!o.archived)}> - - ${o.archived ? t("unarchive_object", L) : t("archive_object", L)} - - ${!o.archived ? html` - this._togglePauseObject(obj.entry_id, !!o.paused)}> - - ${o.paused ? t("resume_object", L) : t("pause_object", L)} - - this._replaceObject(obj.entry_id, o.name)}> - ${t("replace_object", L)} - - ` : nothing} - this._deleteObject(obj.entry_id)}>${t("delete", L)} + { + const dlg = this.shadowRoot!.querySelector("maintenance-object-dialog"); + dlg?.openEdit(obj.entry_id, o); + }}>${t("edit", L)} ` : nothing} - this._openQrForObject(obj.entry_id, o.name)}> ${t("qr_code", L)} - this._printObjectReport(obj.entry_id)}> ${t("report_button", L)} +
+ this._toggleObjMenu()}> + ${this._objMenuOpen ? html` + + ` : nothing} +
${o.paused @@ -3141,6 +3142,21 @@ export class MaintenanceSupporterPanel extends LitElement { /** * Render compact task header with status chip and action buttons. */ + // Object-detail ⋮ menu — same click-away pattern as the task-detail menu. + private _toggleObjMenu(): void { + this._objMenuOpen = !this._objMenuOpen; + if (this._objMenuOpen) { + setTimeout(() => { + const handler = () => { this._objMenuOpen = false; document.removeEventListener("click", handler); }; + document.addEventListener("click", handler); + }, 0); + } + } + + private _closeObjMenu(): void { + this._objMenuOpen = false; + } + private _toggleMoreMenu(): void { this._moreMenuOpen = !this._moreMenuOpen; if (this._moreMenuOpen) { diff --git a/custom_components/maintenance_supporter/frontend-src/renderers/task-detail.ts b/custom_components/maintenance_supporter/frontend-src/renderers/task-detail.ts index a18a101..32e3215 100644 --- a/custom_components/maintenance_supporter/frontend-src/renderers/task-detail.ts +++ b/custom_components/maintenance_supporter/frontend-src/renderers/task-detail.ts @@ -121,30 +121,27 @@ function renderTaskHeader(task: MaintenanceTask, ctx: TaskDetailContext) {
ctx.openComplete(task)}>${t("complete", L)} ctx.promptSkip()}>${t("skip", L)} - ${!isOperator ? html` - ctx.toggleArchive(!!task.archived)}> - - ${task.archived ? t("unarchive", L) : t("archive", L)} - - ` : nothing} - ctx.openQr(task.name)}> ${t("qr_code", L)} - ${!isOperator ? html` -
- ctx.toggleMoreMenu()}> - ${ctx.moreMenuOpen ? html` - + ` : nothing} +
`; @@ -373,10 +370,13 @@ export function renderOverviewTab(task: MaintenanceTask, ctx: TaskDetailContext) return html`
+ ${task.battery_fleet_task + ? html`` + : nothing} ${renderKPIBar(task, ctx)} ${renderTaskMeta(task, ctx)} ${task.battery_fleet_task - ? html`` + ? nothing : html` ${renderDaysProgress(task, ctx.lang)} ${renderTriggerSection(task, ctx.sparkline)} diff --git a/custom_components/maintenance_supporter/frontend/__pycache__/__init__.cpython-314.pyc b/custom_components/maintenance_supporter/frontend/__pycache__/__init__.cpython-314.pyc index 06127bf01f4ea9b3cc1de1124ec123836299d82c..278e1b293018995c2edec8d58bb15761e386aed6 100644 GIT binary patch delta 20 acmbOyJ5QEdn~#@^0SK-}CT--N%m)B2q6EJH delta 20 acmbOyJ5QEdn~#@^0SI_%<2Q0o<^upJ>;xA8 diff --git a/custom_components/maintenance_supporter/frontend/maintenance-panel.js b/custom_components/maintenance_supporter/frontend/maintenance-panel.js index 5acc8fb..f39403c 100644 --- a/custom_components/maintenance_supporter/frontend/maintenance-panel.js +++ b/custom_components/maintenance_supporter/frontend/maintenance-panel.js @@ -1,6 +1,6 @@ -var ts=Object.defineProperty;var is=Object.getOwnPropertyDescriptor;var d=(n,r,e,t)=>{for(var i=t>1?void 0:t?is(r,e):r,a=n.length-1,l;a>=0;a--)(l=n[a])&&(i=(t?l(r,e,i):l(i))||i);return t&&i&&ts(r,e,i),i};var nt=globalThis,ot=nt.ShadowRoot&&(nt.ShadyCSS===void 0||nt.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,Mt=Symbol(),ti=new WeakMap,Ne=class{constructor(r,e,t){if(this._$cssResult$=!0,t!==Mt)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=r,this.t=e}get styleSheet(){let r=this.o,e=this.t;if(ot&&r===void 0){let t=e!==void 0&&e.length===1;t&&(r=ti.get(e)),r===void 0&&((this.o=r=new CSSStyleSheet).replaceSync(this.cssText),t&&ti.set(e,r))}return r}toString(){return this.cssText}},ii=n=>new Ne(typeof n=="string"?n:n+"",void 0,Mt),A=(n,...r)=>{let e=n.length===1?n[0]:r.reduce((t,i,a)=>t+(l=>{if(l._$cssResult$===!0)return l.cssText;if(typeof l=="number")return l;throw Error("Value passed to 'css' function must be a 'css' function result: "+l+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+n[a+1],n[0]);return new Ne(e,n,Mt)},si=(n,r)=>{if(ot)n.adoptedStyleSheets=r.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of r){let t=document.createElement("style"),i=nt.litNonce;i!==void 0&&t.setAttribute("nonce",i),t.textContent=e.cssText,n.appendChild(t)}},It=ot?n=>n:n=>n instanceof CSSStyleSheet?(r=>{let e="";for(let t of r.cssRules)e+=t.cssText;return ii(e)})(n):n;var{is:ss,defineProperty:as,getOwnPropertyDescriptor:rs,getOwnPropertyNames:ns,getOwnPropertySymbols:os,getPrototypeOf:ls}=Object,lt=globalThis,ai=lt.trustedTypes,cs=ai?ai.emptyScript:"",ds=lt.reactiveElementPolyfillSupport,Fe=(n,r)=>n,Ue={toAttribute(n,r){switch(r){case Boolean:n=n?cs:null;break;case Object:case Array:n=n==null?n:JSON.stringify(n)}return n},fromAttribute(n,r){let e=n;switch(r){case Boolean:e=n!==null;break;case Number:e=n===null?null:Number(n);break;case Object:case Array:try{e=JSON.parse(n)}catch{e=null}}return e}},ct=(n,r)=>!ss(n,r),ri={attribute:!0,type:String,converter:Ue,reflect:!1,useDefault:!1,hasChanged:ct};Symbol.metadata??=Symbol("metadata"),lt.litPropertyMetadata??=new WeakMap;var ge=class extends HTMLElement{static addInitializer(r){this._$Ei(),(this.l??=[]).push(r)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(r,e=ri){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(r)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(r,e),!e.noAccessor){let t=Symbol(),i=this.getPropertyDescriptor(r,t,e);i!==void 0&&as(this.prototype,r,i)}}static getPropertyDescriptor(r,e,t){let{get:i,set:a}=rs(this.prototype,r)??{get(){return this[e]},set(l){this[e]=l}};return{get:i,set(l){let c=i?.call(this);a?.call(this,l),this.requestUpdate(r,c,t)},configurable:!0,enumerable:!0}}static getPropertyOptions(r){return this.elementProperties.get(r)??ri}static _$Ei(){if(this.hasOwnProperty(Fe("elementProperties")))return;let r=ls(this);r.finalize(),r.l!==void 0&&(this.l=[...r.l]),this.elementProperties=new Map(r.elementProperties)}static finalize(){if(this.hasOwnProperty(Fe("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(Fe("properties"))){let e=this.properties,t=[...ns(e),...os(e)];for(let i of t)this.createProperty(i,e[i])}let r=this[Symbol.metadata];if(r!==null){let e=litPropertyMetadata.get(r);if(e!==void 0)for(let[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(let[e,t]of this.elementProperties){let i=this._$Eu(e,t);i!==void 0&&this._$Eh.set(i,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(r){let e=[];if(Array.isArray(r)){let t=new Set(r.flat(1/0).reverse());for(let i of t)e.unshift(It(i))}else r!==void 0&&e.push(It(r));return e}static _$Eu(r,e){let t=e.attribute;return t===!1?void 0:typeof t=="string"?t:typeof r=="string"?r.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(r=>this.enableUpdating=r),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(r=>r(this))}addController(r){(this._$EO??=new Set).add(r),this.renderRoot!==void 0&&this.isConnected&&r.hostConnected?.()}removeController(r){this._$EO?.delete(r)}_$E_(){let r=new Map,e=this.constructor.elementProperties;for(let t of e.keys())this.hasOwnProperty(t)&&(r.set(t,this[t]),delete this[t]);r.size>0&&(this._$Ep=r)}createRenderRoot(){let r=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return si(r,this.constructor.elementStyles),r}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(r=>r.hostConnected?.())}enableUpdating(r){}disconnectedCallback(){this._$EO?.forEach(r=>r.hostDisconnected?.())}attributeChangedCallback(r,e,t){this._$AK(r,t)}_$ET(r,e){let t=this.constructor.elementProperties.get(r),i=this.constructor._$Eu(r,t);if(i!==void 0&&t.reflect===!0){let a=(t.converter?.toAttribute!==void 0?t.converter:Ue).toAttribute(e,t.type);this._$Em=r,a==null?this.removeAttribute(i):this.setAttribute(i,a),this._$Em=null}}_$AK(r,e){let t=this.constructor,i=t._$Eh.get(r);if(i!==void 0&&this._$Em!==i){let a=t.getPropertyOptions(i),l=typeof a.converter=="function"?{fromAttribute:a.converter}:a.converter?.fromAttribute!==void 0?a.converter:Ue;this._$Em=i;let c=l.fromAttribute(e,a.type);this[i]=c??this._$Ej?.get(i)??c,this._$Em=null}}requestUpdate(r,e,t,i=!1,a){if(r!==void 0){let l=this.constructor;if(i===!1&&(a=this[r]),t??=l.getPropertyOptions(r),!((t.hasChanged??ct)(a,e)||t.useDefault&&t.reflect&&a===this._$Ej?.get(r)&&!this.hasAttribute(l._$Eu(r,t))))return;this.C(r,e,t)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(r,e,{useDefault:t,reflect:i,wrapped:a},l){t&&!(this._$Ej??=new Map).has(r)&&(this._$Ej.set(r,l??e??this[r]),a!==!0||l!==void 0)||(this._$AL.has(r)||(this.hasUpdated||t||(e=void 0),this._$AL.set(r,e)),i===!0&&this._$Em!==r&&(this._$Eq??=new Set).add(r))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let r=this.scheduleUpdate();return r!=null&&await r,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[i,a]of this._$Ep)this[i]=a;this._$Ep=void 0}let t=this.constructor.elementProperties;if(t.size>0)for(let[i,a]of t){let{wrapped:l}=a,c=this[i];l!==!0||this._$AL.has(i)||c===void 0||this.C(i,void 0,a,c)}}let r=!1,e=this._$AL;try{r=this.shouldUpdate(e),r?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(t){throw r=!1,this._$EM(),t}r&&this._$AE(e)}willUpdate(r){}_$AE(r){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(r)),this.updated(r)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(r){return!0}update(r){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(r){}firstUpdated(r){}};ge.elementStyles=[],ge.shadowRootOptions={mode:"open"},ge[Fe("elementProperties")]=new Map,ge[Fe("finalized")]=new Map,ds?.({ReactiveElement:ge}),(lt.reactiveElementVersions??=[]).push("2.1.2");var qt=globalThis,ni=n=>n,dt=qt.trustedTypes,oi=dt?dt.createPolicy("lit-html",{createHTML:n=>n}):void 0,ui="$lit$",$e=`lit$${Math.random().toFixed(9).slice(2)}$`,_i="?"+$e,ps=`<${_i}>`,Se=document,Ve=()=>Se.createComment(""),We=n=>n===null||typeof n!="object"&&typeof n!="function",Nt=Array.isArray,hs=n=>Nt(n)||typeof n?.[Symbol.iterator]=="function",Lt=`[ +var ts=Object.defineProperty;var is=Object.getOwnPropertyDescriptor;var d=(n,r,e,t)=>{for(var i=t>1?void 0:t?is(r,e):r,a=n.length-1,l;a>=0;a--)(l=n[a])&&(i=(t?l(r,e,i):l(i))||i);return t&&i&&ts(r,e,i),i};var nt=globalThis,ot=nt.ShadowRoot&&(nt.ShadyCSS===void 0||nt.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,Rt=Symbol(),ti=new WeakMap,Ne=class{constructor(r,e,t){if(this._$cssResult$=!0,t!==Rt)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=r,this.t=e}get styleSheet(){let r=this.o,e=this.t;if(ot&&r===void 0){let t=e!==void 0&&e.length===1;t&&(r=ti.get(e)),r===void 0&&((this.o=r=new CSSStyleSheet).replaceSync(this.cssText),t&&ti.set(e,r))}return r}toString(){return this.cssText}},ii=n=>new Ne(typeof n=="string"?n:n+"",void 0,Rt),A=(n,...r)=>{let e=n.length===1?n[0]:r.reduce((t,i,a)=>t+(l=>{if(l._$cssResult$===!0)return l.cssText;if(typeof l=="number")return l;throw Error("Value passed to 'css' function must be a 'css' function result: "+l+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+n[a+1],n[0]);return new Ne(e,n,Rt)},si=(n,r)=>{if(ot)n.adoptedStyleSheets=r.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of r){let t=document.createElement("style"),i=nt.litNonce;i!==void 0&&t.setAttribute("nonce",i),t.textContent=e.cssText,n.appendChild(t)}},It=ot?n=>n:n=>n instanceof CSSStyleSheet?(r=>{let e="";for(let t of r.cssRules)e+=t.cssText;return ii(e)})(n):n;var{is:ss,defineProperty:as,getOwnPropertyDescriptor:rs,getOwnPropertyNames:ns,getOwnPropertySymbols:os,getPrototypeOf:ls}=Object,lt=globalThis,ai=lt.trustedTypes,cs=ai?ai.emptyScript:"",ds=lt.reactiveElementPolyfillSupport,Fe=(n,r)=>n,Ue={toAttribute(n,r){switch(r){case Boolean:n=n?cs:null;break;case Object:case Array:n=n==null?n:JSON.stringify(n)}return n},fromAttribute(n,r){let e=n;switch(r){case Boolean:e=n!==null;break;case Number:e=n===null?null:Number(n);break;case Object:case Array:try{e=JSON.parse(n)}catch{e=null}}return e}},ct=(n,r)=>!ss(n,r),ri={attribute:!0,type:String,converter:Ue,reflect:!1,useDefault:!1,hasChanged:ct};Symbol.metadata??=Symbol("metadata"),lt.litPropertyMetadata??=new WeakMap;var ge=class extends HTMLElement{static addInitializer(r){this._$Ei(),(this.l??=[]).push(r)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(r,e=ri){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(r)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(r,e),!e.noAccessor){let t=Symbol(),i=this.getPropertyDescriptor(r,t,e);i!==void 0&&as(this.prototype,r,i)}}static getPropertyDescriptor(r,e,t){let{get:i,set:a}=rs(this.prototype,r)??{get(){return this[e]},set(l){this[e]=l}};return{get:i,set(l){let c=i?.call(this);a?.call(this,l),this.requestUpdate(r,c,t)},configurable:!0,enumerable:!0}}static getPropertyOptions(r){return this.elementProperties.get(r)??ri}static _$Ei(){if(this.hasOwnProperty(Fe("elementProperties")))return;let r=ls(this);r.finalize(),r.l!==void 0&&(this.l=[...r.l]),this.elementProperties=new Map(r.elementProperties)}static finalize(){if(this.hasOwnProperty(Fe("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(Fe("properties"))){let e=this.properties,t=[...ns(e),...os(e)];for(let i of t)this.createProperty(i,e[i])}let r=this[Symbol.metadata];if(r!==null){let e=litPropertyMetadata.get(r);if(e!==void 0)for(let[t,i]of e)this.elementProperties.set(t,i)}this._$Eh=new Map;for(let[e,t]of this.elementProperties){let i=this._$Eu(e,t);i!==void 0&&this._$Eh.set(i,e)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(r){let e=[];if(Array.isArray(r)){let t=new Set(r.flat(1/0).reverse());for(let i of t)e.unshift(It(i))}else r!==void 0&&e.push(It(r));return e}static _$Eu(r,e){let t=e.attribute;return t===!1?void 0:typeof t=="string"?t:typeof r=="string"?r.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(r=>this.enableUpdating=r),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(r=>r(this))}addController(r){(this._$EO??=new Set).add(r),this.renderRoot!==void 0&&this.isConnected&&r.hostConnected?.()}removeController(r){this._$EO?.delete(r)}_$E_(){let r=new Map,e=this.constructor.elementProperties;for(let t of e.keys())this.hasOwnProperty(t)&&(r.set(t,this[t]),delete this[t]);r.size>0&&(this._$Ep=r)}createRenderRoot(){let r=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return si(r,this.constructor.elementStyles),r}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(r=>r.hostConnected?.())}enableUpdating(r){}disconnectedCallback(){this._$EO?.forEach(r=>r.hostDisconnected?.())}attributeChangedCallback(r,e,t){this._$AK(r,t)}_$ET(r,e){let t=this.constructor.elementProperties.get(r),i=this.constructor._$Eu(r,t);if(i!==void 0&&t.reflect===!0){let a=(t.converter?.toAttribute!==void 0?t.converter:Ue).toAttribute(e,t.type);this._$Em=r,a==null?this.removeAttribute(i):this.setAttribute(i,a),this._$Em=null}}_$AK(r,e){let t=this.constructor,i=t._$Eh.get(r);if(i!==void 0&&this._$Em!==i){let a=t.getPropertyOptions(i),l=typeof a.converter=="function"?{fromAttribute:a.converter}:a.converter?.fromAttribute!==void 0?a.converter:Ue;this._$Em=i;let c=l.fromAttribute(e,a.type);this[i]=c??this._$Ej?.get(i)??c,this._$Em=null}}requestUpdate(r,e,t,i=!1,a){if(r!==void 0){let l=this.constructor;if(i===!1&&(a=this[r]),t??=l.getPropertyOptions(r),!((t.hasChanged??ct)(a,e)||t.useDefault&&t.reflect&&a===this._$Ej?.get(r)&&!this.hasAttribute(l._$Eu(r,t))))return;this.C(r,e,t)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(r,e,{useDefault:t,reflect:i,wrapped:a},l){t&&!(this._$Ej??=new Map).has(r)&&(this._$Ej.set(r,l??e??this[r]),a!==!0||l!==void 0)||(this._$AL.has(r)||(this.hasUpdated||t||(e=void 0),this._$AL.set(r,e)),i===!0&&this._$Em!==r&&(this._$Eq??=new Set).add(r))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let r=this.scheduleUpdate();return r!=null&&await r,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[i,a]of this._$Ep)this[i]=a;this._$Ep=void 0}let t=this.constructor.elementProperties;if(t.size>0)for(let[i,a]of t){let{wrapped:l}=a,c=this[i];l!==!0||this._$AL.has(i)||c===void 0||this.C(i,void 0,a,c)}}let r=!1,e=this._$AL;try{r=this.shouldUpdate(e),r?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(t){throw r=!1,this._$EM(),t}r&&this._$AE(e)}willUpdate(r){}_$AE(r){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(r)),this.updated(r)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(r){return!0}update(r){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(r){}firstUpdated(r){}};ge.elementStyles=[],ge.shadowRootOptions={mode:"open"},ge[Fe("elementProperties")]=new Map,ge[Fe("finalized")]=new Map,ds?.({ReactiveElement:ge}),(lt.reactiveElementVersions??=[]).push("2.1.2");var qt=globalThis,ni=n=>n,dt=qt.trustedTypes,oi=dt?dt.createPolicy("lit-html",{createHTML:n=>n}):void 0,ui="$lit$",$e=`lit$${Math.random().toFixed(9).slice(2)}$`,_i="?"+$e,ps=`<${_i}>`,Se=document,Ve=()=>Se.createComment(""),We=n=>n===null||typeof n!="object"&&typeof n!="function",Nt=Array.isArray,hs=n=>Nt(n)||typeof n?.[Symbol.iterator]=="function",Lt=`[ \f\r]`,Be=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,li=/-->/g,ci=/>/g,Ee=RegExp(`>|${Lt}(?:([^\\s"'>=/]+)(${Lt}*=${Lt}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`,"g"),di=/'/g,pi=/"/g,gi=/^(?:script|style|textarea|title)$/i,Ft=n=>(r,...e)=>({_$litType$:n,strings:r,values:e}),o=Ft(1),O=Ft(2),Ma=Ft(3),me=Symbol.for("lit-noChange"),h=Symbol.for("lit-nothing"),hi=new WeakMap,Te=Se.createTreeWalker(Se,129);function mi(n,r){if(!Nt(n)||!n.hasOwnProperty("raw"))throw Error("invalid template strings array");return oi!==void 0?oi.createHTML(r):r}var us=(n,r)=>{let e=n.length-1,t=[],i,a=r===2?"":r===3?"":"",l=Be;for(let c=0;c"?(l=i??Be,m=-1):g[1]===void 0?m=-2:(m=l.lastIndex-g[2].length,u=g[1],l=g[3]===void 0?Ee:g[3]==='"'?pi:di):l===pi||l===di?l=Ee:l===li||l===ci?l=Be:(l=Ee,i=void 0);let b=l===Ee&&n[c+1].startsWith("/>")?" ":"";a+=l===Be?p+ps:m>=0?(t.push(u),p.slice(0,m)+ui+p.slice(m)+$e+b):p+$e+(m===-2?c:b)}return[mi(n,a+(n[e]||"")+(r===2?"":r===3?"":"")),t]},Ke=class n{constructor({strings:r,_$litType$:e},t){let i;this.parts=[];let a=0,l=0,c=r.length-1,p=this.parts,[u,g]=us(r,e);if(this.el=n.createElement(u,t),Te.currentNode=this.el.content,e===2||e===3){let m=this.el.content.firstChild;m.replaceWith(...m.childNodes)}for(;(i=Te.nextNode())!==null&&p.length0){i.textContent=dt?dt.emptyScript:"";for(let b=0;b2||t[0]!==""||t[1]!==""?(this._$AH=Array(t.length-1).fill(new String),this.strings=t):this._$AH=h}_$AI(r,e=this,t,i){let a=this.strings,l=!1;if(a===void 0)r=Ie(this,r,e,0),l=!We(r)||r!==this._$AH&&r!==me,l&&(this._$AH=r);else{let c=r,p,u;for(r=a[0],p=0;p{let t=e?.renderBefore??r,i=t._$litPart$;if(i===void 0){let a=e?.renderBefore??null;t._$litPart$=i=new Ye(r.insertBefore(Ve(),a),a,void 0,e??{})}return i._$AI(n),i};var Ut=globalThis,S=class extends ge{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let r=super.createRenderRoot();return this.renderOptions.renderBefore??=r.firstChild,r}update(r){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(r),this._$Do=vi(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return me}};S._$litElement$=!0,S.finalized=!0,Ut.litElementHydrateSupport?.({LitElement:S});var gs=Ut.litElementPolyfillSupport;gs?.({LitElement:S});(Ut.litElementVersions??=[]).push("4.2.2");function le(n){return!!n&&/^https?:\/\//i.test(n)}var bi=n=>(r,e)=>{e!==void 0?e.addInitializer(()=>{customElements.define(n,r)}):customElements.define(n,r)};var ms={attribute:!0,type:String,converter:Ue,reflect:!1,hasChanged:ct},vs=(n=ms,r,e)=>{let{kind:t,metadata:i}=e,a=globalThis.litPropertyMetadata.get(i);if(a===void 0&&globalThis.litPropertyMetadata.set(i,a=new Map),t==="setter"&&((n=Object.create(n)).wrapped=!0),a.set(e.name,n),t==="accessor"){let{name:l}=e;return{set(c){let p=r.get.call(this);r.set.call(this,c),this.requestUpdate(l,p,n,!0,c)},init(c){return c!==void 0&&this.C(l,void 0,n,c),c}}}if(t==="setter"){let{name:l}=e;return function(c){let p=this[l];r.call(this,c),this.requestUpdate(l,p,n,!0,c)}}throw Error("Unsupported decorator location: "+t)};function y(n){return(r,e)=>typeof e=="object"?vs(n,r,e):((t,i,a)=>{let l=i.hasOwnProperty(a);return i.constructor.createProperty(a,t),l?Object.getOwnPropertyDescriptor(i,a):void 0})(n,r,e)}function _(n){return y({...n,state:!0,attribute:!1})}var fi={maintenance:"Maintenance",objects:"Objects",tasks:"Tasks",overdue:"Overdue",due_soon:"Due Soon",triggered:"Triggered",trigger_replaced:"Trigger replaced",ok:"OK",all:"All",new_object:"+ New Object",templates_from:"From template",templates_title:"Start from a template",templates_task_count:"{n} tasks",template_created:"Created from template",onboard_hint:"Add your first object to start tracking maintenance.",edit:"Edit",duplicate:"Duplicate",task_duplicated:"Task duplicated",object_duplicated:"Object duplicated",delete:"Delete",add_task:"+ Add Task",complete:"Complete",completed:"Completed",skip:"Skip",skipped:"Skipped",missed:"Missed",reset:"Reset",snooze:"Snooze",snoozed:"Snoozed",cancel:"Cancel",bulk_select:"Select",bulk_select_all:"Select all",bulk_n_selected:"{n} selected",bulk_completed:"{n} tasks completed",bulk_archived:"{n} tasks archived",completing:"Completing\u2026",interval:"Interval",warning:"Warning",last_performed:"Last performed",next_due:"Next due",days_until_due:"Days until due",avg_duration:"Avg duration",trigger:"Trigger",trigger_type:"Trigger type",threshold_above:"Upper limit",threshold_below:"Lower limit",threshold:"Threshold",counter:"Counter",state_change:"State change",runtime:"Runtime",runtime_hours:"Target runtime (hours)",target_value:"Target value",baseline:"Baseline",target_changes:"Target changes",for_minutes:"For (minutes)",time_based:"Time-based",sensor_based:"Sensor-based",manual:"Manual",one_time:"One-time",weekdays:"Weekdays",nth_weekday:"Nth weekday of month",day_of_month:"Day of month",recurrence_on_days:"Repeat on",recurrence_occurrence:"Occurrence",recurrence_weekday:"Weekday",recurrence_day:"Day of month (1\u201331)",recurrence_last_day:"Last day of the month",recurrence_business_day:"Business days only (roll back from weekend)",recurrence_offset:"Offset (days, \xB1)",recurrence_offset_help:"Shift the date by \xB1N days, e.g. -2 = two days before.",last_day_month:"Last day of month",last_business_day_month:"Last business day",ord_1:"1st",ord_2:"2nd",ord_3:"3rd",ord_4:"4th",ord_5:"5th",ord_last:"Last",day_word:"Day",interval_value:"Interval",interval_unit:"Unit",unit_days:"Days",unit_weeks:"Weeks",unit_months:"Months",unit_years:"Years",due_date:"Due date",cleaning:"Cleaning",inspection:"Inspection",replacement:"Replacement",calibration:"Calibration",service:"Service",reading:"Reading",custom:"Custom",history:"History",cost:"Cost",report_button:"Report",report_title:"Maintenance report",report_generated:"Generated",report_times_done:"Done",report_total_cost:"Total cost",report_every:"every {n} {unit}",report_notes:"Notes",report_col_type:"Type",report_col_status:"Status",report_col_schedule:"Schedule",duration:"Duration",both:"Both",trigger_val:"Trigger value",complete_title:"Complete: ",checklist:"Checklist",checklist_steps_optional:"Checklist steps (optional)",checklist_placeholder:`Clean filter +\f\r"'\`<>=]|("|')|))|$)`,"g"),di=/'/g,pi=/"/g,gi=/^(?:script|style|textarea|title)$/i,Ft=n=>(r,...e)=>({_$litType$:n,strings:r,values:e}),o=Ft(1),O=Ft(2),Ra=Ft(3),me=Symbol.for("lit-noChange"),h=Symbol.for("lit-nothing"),hi=new WeakMap,Te=Se.createTreeWalker(Se,129);function mi(n,r){if(!Nt(n)||!n.hasOwnProperty("raw"))throw Error("invalid template strings array");return oi!==void 0?oi.createHTML(r):r}var us=(n,r)=>{let e=n.length-1,t=[],i,a=r===2?"":r===3?"":"",l=Be;for(let c=0;c"?(l=i??Be,m=-1):g[1]===void 0?m=-2:(m=l.lastIndex-g[2].length,u=g[1],l=g[3]===void 0?Ee:g[3]==='"'?pi:di):l===pi||l===di?l=Ee:l===li||l===ci?l=Be:(l=Ee,i=void 0);let b=l===Ee&&n[c+1].startsWith("/>")?" ":"";a+=l===Be?p+ps:m>=0?(t.push(u),p.slice(0,m)+ui+p.slice(m)+$e+b):p+$e+(m===-2?c:b)}return[mi(n,a+(n[e]||"")+(r===2?"":r===3?"":"")),t]},Ke=class n{constructor({strings:r,_$litType$:e},t){let i;this.parts=[];let a=0,l=0,c=r.length-1,p=this.parts,[u,g]=us(r,e);if(this.el=n.createElement(u,t),Te.currentNode=this.el.content,e===2||e===3){let m=this.el.content.firstChild;m.replaceWith(...m.childNodes)}for(;(i=Te.nextNode())!==null&&p.length0){i.textContent=dt?dt.emptyScript:"";for(let b=0;b2||t[0]!==""||t[1]!==""?(this._$AH=Array(t.length-1).fill(new String),this.strings=t):this._$AH=h}_$AI(r,e=this,t,i){let a=this.strings,l=!1;if(a===void 0)r=Ie(this,r,e,0),l=!We(r)||r!==this._$AH&&r!==me,l&&(this._$AH=r);else{let c=r,p,u;for(r=a[0],p=0;p{let t=e?.renderBefore??r,i=t._$litPart$;if(i===void 0){let a=e?.renderBefore??null;t._$litPart$=i=new Ye(r.insertBefore(Ve(),a),a,void 0,e??{})}return i._$AI(n),i};var Ut=globalThis,S=class extends ge{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let r=super.createRenderRoot();return this.renderOptions.renderBefore??=r.firstChild,r}update(r){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(r),this._$Do=vi(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return me}};S._$litElement$=!0,S.finalized=!0,Ut.litElementHydrateSupport?.({LitElement:S});var gs=Ut.litElementPolyfillSupport;gs?.({LitElement:S});(Ut.litElementVersions??=[]).push("4.2.2");function le(n){return!!n&&/^https?:\/\//i.test(n)}var bi=n=>(r,e)=>{e!==void 0?e.addInitializer(()=>{customElements.define(n,r)}):customElements.define(n,r)};var ms={attribute:!0,type:String,converter:Ue,reflect:!1,hasChanged:ct},vs=(n=ms,r,e)=>{let{kind:t,metadata:i}=e,a=globalThis.litPropertyMetadata.get(i);if(a===void 0&&globalThis.litPropertyMetadata.set(i,a=new Map),t==="setter"&&((n=Object.create(n)).wrapped=!0),a.set(e.name,n),t==="accessor"){let{name:l}=e;return{set(c){let p=r.get.call(this);r.set.call(this,c),this.requestUpdate(l,p,n,!0,c)},init(c){return c!==void 0&&this.C(l,void 0,n,c),c}}}if(t==="setter"){let{name:l}=e;return function(c){let p=this[l];r.call(this,c),this.requestUpdate(l,p,n,!0,c)}}throw Error("Unsupported decorator location: "+t)};function y(n){return(r,e)=>typeof e=="object"?vs(n,r,e):((t,i,a)=>{let l=i.hasOwnProperty(a);return i.constructor.createProperty(a,t),l?Object.getOwnPropertyDescriptor(i,a):void 0})(n,r,e)}function _(n){return y({...n,state:!0,attribute:!1})}var fi={maintenance:"Maintenance",objects:"Objects",tasks:"Tasks",overdue:"Overdue",due_soon:"Due Soon",triggered:"Triggered",trigger_replaced:"Trigger replaced",ok:"OK",all:"All",new_object:"+ New Object",templates_from:"From template",templates_title:"Start from a template",templates_task_count:"{n} tasks",template_created:"Created from template",onboard_hint:"Add your first object to start tracking maintenance.",edit:"Edit",duplicate:"Duplicate",task_duplicated:"Task duplicated",object_duplicated:"Object duplicated",delete:"Delete",add_task:"+ Add Task",complete:"Complete",completed:"Completed",skip:"Skip",skipped:"Skipped",missed:"Missed",reset:"Reset",snooze:"Snooze",snoozed:"Snoozed",cancel:"Cancel",bulk_select:"Select",bulk_select_all:"Select all",bulk_n_selected:"{n} selected",bulk_completed:"{n} tasks completed",bulk_archived:"{n} tasks archived",completing:"Completing\u2026",interval:"Interval",warning:"Warning",last_performed:"Last performed",next_due:"Next due",days_until_due:"Days until due",avg_duration:"Avg duration",trigger:"Trigger",trigger_type:"Trigger type",threshold_above:"Upper limit",threshold_below:"Lower limit",threshold:"Threshold",counter:"Counter",state_change:"State change",runtime:"Runtime",runtime_hours:"Target runtime (hours)",target_value:"Target value",baseline:"Baseline",target_changes:"Target changes",for_minutes:"For (minutes)",time_based:"Time-based",sensor_based:"Sensor-based",manual:"Manual",one_time:"One-time",weekdays:"Weekdays",nth_weekday:"Nth weekday of month",day_of_month:"Day of month",recurrence_on_days:"Repeat on",recurrence_occurrence:"Occurrence",recurrence_weekday:"Weekday",recurrence_day:"Day of month (1\u201331)",recurrence_last_day:"Last day of the month",recurrence_business_day:"Business days only (roll back from weekend)",recurrence_offset:"Offset (days, \xB1)",recurrence_offset_help:"Shift the date by \xB1N days, e.g. -2 = two days before.",last_day_month:"Last day of month",last_business_day_month:"Last business day",ord_1:"1st",ord_2:"2nd",ord_3:"3rd",ord_4:"4th",ord_5:"5th",ord_last:"Last",day_word:"Day",interval_value:"Interval",interval_unit:"Unit",unit_days:"Days",unit_weeks:"Weeks",unit_months:"Months",unit_years:"Years",due_date:"Due date",cleaning:"Cleaning",inspection:"Inspection",replacement:"Replacement",calibration:"Calibration",service:"Service",reading:"Reading",custom:"Custom",history:"History",cost:"Cost",report_button:"Report",report_title:"Maintenance report",report_generated:"Generated",report_times_done:"Done",report_total_cost:"Total cost",report_every:"every {n} {unit}",report_notes:"Notes",report_col_type:"Type",report_col_status:"Status",report_col_schedule:"Schedule",duration:"Duration",both:"Both",trigger_val:"Trigger value",complete_title:"Complete: ",checklist:"Checklist",checklist_steps_optional:"Checklist steps (optional)",checklist_placeholder:`Clean filter Replace seal Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:"{field}: too long (max {n} characters)",err_too_short:"{field}: too short (min {n} characters)",err_value_too_high:"{field}: too large (max {n})",err_value_too_low:"{field}: too small (min {n})",err_required:"{field}: required",err_wrong_type:"{field}: wrong type (expected: {type})",err_invalid_choice:"{field}: not an allowed value",err_invalid_value:"{field}: invalid value",feat_schedule_time:"Time-of-day scheduling",feat_schedule_time_desc:"Tasks become overdue at a specific time of day instead of midnight.",schedule_time_optional:"Due at time (optional, HH:MM)",schedule_time_help:"Empty = midnight (default). HA timezone.",at_time:"at",notes_optional:"Notes (optional)",cost_optional:"Cost (optional)",duration_minutes:"Duration in minutes (optional)",days:"days",day:"day",today:"Today",d_overdue:"d overdue",no_tasks:"No maintenance tasks yet. Create an object to get started.",no_tasks_short:"No tasks",no_history:"No history entries yet.",show_all:"Show all",cost_duration_chart:"Cost & Duration",installed:"Installed",confirm_delete_object:"Delete this object and all its tasks?",confirm_delete_task:"Delete this task?",min:"Min",max:"Max",save:"Save",saving:"Saving\u2026",edit_task:"Edit Task",new_task:"New Maintenance Task",task_name:"Task name",maintenance_type:"Maintenance type",priority:"Priority",labels:"Labels",labels_placeholder:"e.g. safety, seasonal, tenant-visible",labels_help:"Comma-separated tags for filtering and reporting.",priority_low:"Low",priority_normal:"Normal",priority_high:"High",schedule_type:"Schedule type",interval_days:"Interval (days)",warning_days:"Warning days",earliest_completion_days:"Earliest completion (days before due)",earliest_completion_days_help:"Leave empty to allow completing any time. 0 = only on/after the due date.",last_performed_optional:"Last performed (optional)",interval_anchor:"Interval anchor",anchor_completion:"From completion date",anchor_planned:"From planned date (no drift)",edit_object:"Edit Object",name:"Name",manufacturer_optional:"Manufacturer (optional)",model_optional:"Model (optional)",serial_number_optional:"Serial number (optional)",serial_number_label:"S/N",documentation_url_label:"Manual",object_notes_label:"Notes",sort_due_date:"Due date",sort_object:"Object name",sort_type:"Type",sort_task_name:"Task name",all_objects:"All objects",tasks_lower:"tasks",no_tasks_yet:"No tasks yet",add_first_task:"Add first task",trigger_configuration:"Trigger Configuration",entity_id:"Entity ID",comma_separated:"comma-separated",entity_logic:"Entity logic",entity_logic_any:"Any entity triggers",entity_logic_all:"All entities must trigger",entities:"entities",attribute_optional:"Attribute (optional, blank = state)",use_entity_state:"Use entity state (no attribute)",trigger_above:"Trigger above",trigger_below:"Trigger below",for_at_least_minutes:"For at least (minutes)",safety_interval_days:"Safety interval (days, optional)",safety_interval:"Safety interval (optional)",delta_mode:"Delta mode",from_state_optional:"From state (optional)",to_state_optional:"To state (optional)",documentation_url_optional:"Documentation URL (optional)",object_notes_optional:"Notes (optional)",nfc_tag_id_optional:"NFC Tag ID (optional)",nfc_tags_empty_help:"No NFC tags registered in Home Assistant yet.",nfc_tags_open_settings:"Open Tags settings",nfc_tags_refresh:"Refresh",environmental_entity_optional:"Environmental sensor (optional)",environmental_entity_helper:"e.g. sensor.outdoor_temperature \u2014 adjusts the interval based on environmental conditions",environmental_attribute_optional:"Environmental attribute (optional)",nfc_tag_id:"NFC Tag ID",nfc_linked:"NFC tag linked",nfc_link_hint:"Click to link NFC tag",responsible_user:"Responsible User",shared_with:"Shared with (rotation)",shared_with_help:"Pick multiple people to share this task; the responsible person rotates on each completion.",rotation_strategy:"Rotation",rotation_none:"No rotation",rotation_round_robin:"Round-robin",rotation_least_completed:"Least completed",rotation_random:"Random",no_user_assigned:"(No user assigned)",all_users:"All Users",my_tasks:"My Tasks",tab_calendar:"Calendar",cal_no_events:"No maintenance",cal_window_7:"7 days",cal_window_14:"14 days",cal_window_30:"30 days",cal_window_365:"1 year",cal_every_n_days:"every {n} days",cal_source_time:"Time-based",cal_source_time_adaptive:"Time-based (adaptive)",cal_source_sensor:"Sensor-based",cal_predicted:"predicted",cal_confidence_high:"high confidence",cal_confidence_medium:"medium confidence",cal_confidence_low:"low confidence",budget_monthly:"Monthly budget",budget_yearly:"Yearly budget",groups:"Groups",new_group:"New group",edit_group:"Edit group",no_groups:"No groups yet",delete_group:"Delete group",delete_group_confirm:"Delete group '{name}'?",group_select_tasks:"Select tasks",group_name_required:"Name is required",description_optional:"Description (optional)",selected:"Selected",loading_chart:"Loading chart data...",hide_outliers:"Hide outliers (sensor glitches)",was_maintenance_needed:"Was this maintenance needed?",feedback_needed:"Needed",feedback_not_needed:"Not needed",feedback_not_sure:"Not sure",suggested_interval:"Suggested interval",apply_suggestion:"Apply",reanalyze:"Re-analyze",reanalyze_result:"New analysis",reanalyze_insufficient_data:"Not enough data to produce a recommendation",data_points:"data points",dismiss_suggestion:"Dismiss",confidence_low:"Low",confidence_medium:"Medium",confidence_high:"High",recommended:"recommended",seasonal_awareness:"Seasonal Awareness",edit_seasonal_overrides:"Edit seasonal factors",seasonal_overrides_title:"Seasonal factors (override)",seasonal_overrides_hint:"Factor per month (0.1\u20135.0). Empty = learned automatically.",seasonal_override_invalid:"Invalid value",seasonal_override_range:"Factor must be between 0.1 and 5.0",clear_all:"Clear all",seasonal_chart_title:"Seasonal Factors",seasonal_learned:"Learned",seasonal_manual:"Manual",month_jan:"Jan",month_feb:"Feb",month_mar:"Mar",month_apr:"Apr",month_may:"May",month_jun:"Jun",month_jul:"Jul",month_aug:"Aug",month_sep:"Sep",month_oct:"Oct",month_nov:"Nov",month_dec:"Dec",sensor_prediction:"Sensor Prediction",degradation_trend:"Trend",trend_rising:"Rising",trend_falling:"Falling",trend_stable:"Stable",trend_insufficient_data:"Insufficient data",days_until_threshold:"Days until threshold",threshold_exceeded:"Threshold exceeded",environmental_adjustment:"Environmental factor",sensor_prediction_urgency:"Sensor predicts threshold in ~{days} days",day_short:"day",weibull_reliability_curve:"Reliability Curve",weibull_failure_probability:"Failure Probability",weibull_r_squared:"Fit R\xB2",beta_early_failures:"Early Failures",beta_random_failures:"Random Failures",beta_wear_out:"Wear-out",beta_highly_predictable:"Highly Predictable",confidence_interval:"Confidence Interval",confidence_conservative:"Conservative",confidence_aggressive:"Optimistic",current_interval_marker:"Current interval",recommended_marker:"Recommended",characteristic_life:"Characteristic life",chart_mini_sparkline:"Trend sparkline",chart_history:"Cost and duration history",chart_seasonal:"Seasonal factors, 12 months",chart_weibull:"Weibull reliability curve",chart_sparkline:"Sensor trigger value chart",days_progress:"Days progress",qr_code:"QR Code",qr_generating:"Generating QR code\u2026",qr_error:"Failed to generate QR code.",qr_error_no_url:"No HA URL configured. Please set an external or internal URL in Settings \u2192 System \u2192 Network.",save_error:"Failed to save. Please try again.",qr_print:"Print",qr_download:"Download SVG",qr_action:"Action on scan",qr_action_view:"View maintenance info",qr_action_complete:"Mark maintenance as complete",qr_url_mode:"Link type",qr_mode_companion:"Companion App",qr_mode_local:"Local (mDNS)",qr_mode_server:"Server URL",overview:"Overview",analysis:"Analysis",recent_activities:"Recent Activities",search_notes:"Search notes",avg_cost:"Avg Cost",no_advanced_features:"No advanced features enabled",no_advanced_features_hint:"Enable \u201CAdaptive Intervals\u201D or \u201CSeasonal Patterns\u201D in the integration settings to see analysis data here.",analysis_not_enough_data:"Not enough data for analysis yet.",analysis_not_enough_data_hint:"Weibull analysis requires at least 5 completed maintenances; seasonal patterns become visible after 6+ data points per month.",analysis_manual_task_hint:"Manual tasks without an interval do not generate analysis data.",completions:"completions",current:"Current",shorter:"Shorter",longer:"Longer",normal:"Normal",disabled:"Disabled",compound_logic:"Compound logic",compound:"Compound (multiple conditions)",compound_logic_and:"AND \u2014 all conditions must trigger",compound_logic_or:"OR \u2014 any condition triggers",compound_help:"Combine several sensor conditions into one trigger.",compound_no_conditions:"No conditions yet \u2014 add at least one.",compound_add_condition:"Add condition",compound_condition:"Condition",compound_remove_condition:"Remove condition",card_title:"Title",card_show_header:"Show header with statistics",card_show_actions:"Show action buttons",card_compact:"Compact mode",card_max_items:"Max items (0 = all)",card_filter_status:"Filter by status",card_filter_status_help:"Empty = show all statuses.",card_filter_objects:"Filter by objects",card_filter_objects_help:"Empty = show all objects.",card_filter_entities:"Filter by entities (entity_ids)",card_filter_entities_help:"Pick sensor / binary_sensor entities from this integration. Empty = all.",card_loading_objects:"Loading objects\u2026",card_load_error:"Could not load objects \u2014 check the WebSocket connection.",card_no_tasks_title:"No maintenance tasks yet",card_no_tasks_cta:"\u2192 Create one in the Maintenance panel",no_objects:"No objects yet.",action_error:"Action failed. Please try again.",area_id_optional:"Area (optional)",installation_date_optional:"Installation date (optional)",warranty_expiry_optional:"Warranty expiry (optional)",warranty:"Warranty",warranty_valid_until:"valid until {date}",warranty_expires_in:"expires in {days} days",warranty_expired:"expired",cal_past_windows:"Past windows",cal_forward_windows:"Forward windows",history_edit_title:"Edit history entry",history_edit_timestamp:"Timestamp",manufacturer:"Manufacturer",model:"Model",area:"Area",actions:"Actions",view_mode_label:"View",view_cards:"Card view",view_table:"Table view",objects_table_columns_label:"Objects table columns",objects_table_columns_hint:"Choose which columns appear in the objects table view.",custom_icon_optional:"Icon (optional, e.g. mdi:wrench)",task_enabled:"Task enabled",skip_reason_prompt:"Skip this task?",reason_optional:"Reason (optional)",reset_date_prompt:"Mark task as performed?",reset_date_optional:"Last performed date (optional, defaults to today)",notes_label:"Notes",documentation_label:"Documentation",no_nfc_tag:"\u2014 No tag \u2014",dashboard:"Dashboard",tab_today:"Today",palette_placeholder:"Search objects and tasks\u2026",palette_no_results:"No matches",palette_hint:"\u2191\u2193 to navigate \xB7 Enter to open \xB7 Esc to close",today_all_caught_up:"All caught up! Nothing due this week.",today_overdue:"Overdue",today_due_today:"Due today",today_this_week:"This week",settings:"Settings",settings_features:"Advanced Features",settings_features_desc:"Enable or disable advanced features. Disabling hides them from the UI but does not delete data.",feat_adaptive:"Adaptive Scheduling",feat_adaptive_desc:"Learn optimal intervals from maintenance history",feat_predictions:"Sensor Predictions",feat_predictions_desc:"Predict trigger dates from sensor degradation",feat_seasonal:"Seasonal Adjustments",feat_seasonal_desc:"Adjust intervals based on seasonal patterns",feat_environmental:"Environmental Correlation",feat_environmental_desc:"Correlate intervals with temperature/humidity",feat_budget:"Budget Tracking",feat_budget_desc:"Track monthly and yearly maintenance spending",feat_groups:"Task Groups",feat_groups_desc:"Organize tasks into logical groups",feat_checklists:"Checklists",feat_checklists_desc:"Multi-step procedures for task completion",settings_general:"General",settings_default_warning:"Default warning days",settings_panel_enabled:"Sidebar panel",settings_panel_title:"Sidebar panel title",settings_notifications:"Notifications",settings_notify_service:"Notification service",test_notification:"Test notification",send_test:"Send test",testing:"Sending\u2026",test_notification_success:"Test notification sent",test_notification_failed:"Test notification failed",settings_notify_due_soon:"Notify when due soon",settings_notify_overdue:"Notify when overdue",settings_notify_triggered:"Notify when triggered",settings_interval_hours:"Repeat interval (hours, 0 = once)",settings_quiet_hours:"Quiet hours",settings_quiet_start:"Start",settings_quiet_end:"End",settings_max_per_day:"Max notifications per day (0 = unlimited)",settings_bundling:"Bundle notifications",settings_bundle_threshold:"Bundle threshold",settings_reminder_leads:"Extra reminders (days before due)",settings_reminder_leads_hint:"Comma-separated lead times, e.g. 14, 3, 0 \u2014 one extra reminder fires on each matching day. Empty = off.",settings_actions:"Mobile Action Buttons",settings_action_complete:"Show 'Complete' button",settings_action_skip:"Show 'Skip' button",settings_action_snooze:"Show 'Snooze' button",settings_weekly_digest:"Weekly digest",settings_weekly_digest_hint:"A single summary notification on Monday morning when tasks are due.",settings_warranty_reminder:"Warranty expiry reminder",settings_warranty_reminder_days:"Days before expiry",settings_warranty_reminder_hint:"Notify once when an object's warranty is this many days from expiring.",settings_snooze_hours:"Snooze duration (hours)",settings_budget:"Budget",settings_currency:"Currency",settings_budget_monthly:"Monthly budget",settings_budget_yearly:"Yearly budget",settings_budget_alerts:"Budget alerts",settings_budget_threshold:"Alert threshold (%)",settings_import_export:"Import / Export",settings_export_json:"Export JSON",settings_export_yaml:"Export YAML",settings_export_csv:"Export CSV",settings_import_csv:"Import CSV",settings_import_placeholder:"Paste JSON or CSV content here\u2026",settings_import_btn:"Import",settings_import_success:"{count} objects imported successfully.",settings_export_success:"Export downloaded.",settings_saved:"Setting saved.",settings_include_history:"Include history",settings_export_selection:"Limit to selected objects (optional)",settings_docs_archive:"Documents archive (with files)",settings_docs_archive_hint:"The JSON/YAML/CSV exports carry settings only. This ZIP includes the uploaded file contents so a restore is complete.",settings_docs_export_btn:"Download documents ZIP",settings_docs_import_btn:"Restore documents ZIP",settings_docs_import_success:"Restored: {blobs} files, {docs} documents",sort_alphabetical:"Alphabetical",sort_due_soonest:"Due soonest",sort_task_count:"Task count",sort_area:"Area",sort_assigned_user:"Assigned user",sort_group:"Group",groupby_none:"No grouping",groupby_area:"By area",groupby_group:"By group",groupby_user:"By user",filter_label:"Filter",user_label:"User",sort_label:"Sort",group_by_label:"Group by",state_value_help:'Use the HA state value (usually lowercase, e.g. "on"/"off"). Case is normalised on save.',target_changes_help:"Number of matching transitions before the trigger fires (default: 1).",qr_print_title:"Print QR codes",qr_print_desc:"Generate a printable page of QR codes to cut out and stick on your equipment.",qr_print_load:"Load objects",qr_print_filter:"Filter",qr_print_objects:"Objects",qr_print_actions:"Actions",qr_print_url_mode:"Link type",qr_print_estimate:"Estimated QR codes",qr_print_over_limit:"cap is 200, narrow the filter",qr_print_generate:"Generate QR codes",qr_print_generating:"Generating\u2026",qr_print_ready:"QR codes ready",qr_print_print_button:"Print",qr_print_empty:"Nothing to generate",qr_action_skip:"Skip",vacation_title:"Vacation mode",vacation_active:"active",vacation_ended:"ended",vacation_desc:"Plan a vacation: notifications are paused during the period plus a buffer of days. You can opt specific tasks back in.",vacation_enable:"Enable vacation mode",vacation_start:"Start",vacation_end:"End",vacation_buffer:"Buffer (days)",vacation_exempt_title:"Notify anyway during vacation",vacation_exempt_desc:"Pick tasks that should still notify during vacation (e.g. critical pool chemistry).",vacation_load_tasks:"Load tasks",vacation_preview_btn:"Show preview",vacation_preview_affected:"tasks affected",vacation_event_due_soon:"becomes due soon",vacation_event_overdue:"becomes overdue",vacation_event_triggered_est:"sensor trigger possible",vacation_sensor_based:"(sensor-based)",vacation_action_notify:"Notify anyway",vacation_action_unsilence:"Silence again",vacation_marked_complete:"Marked complete",vacation_marked_skip:"Skipped",vacation_end_now:"End vacation now",add:"Add",show_stats:"Show stats + graphs",hide_stats:"Hide stats",adaptive_no_data:"Not enough completion history yet for adaptive analysis. Complete this task a few more times to unlock interval recommendations and reliability charts.",suggestion_applied:"Suggested interval applied",vacation_mode:"Vacation mode",vacation_status_active:"Active now",vacation_status_scheduled:"Scheduled",vacation_status_inactive:"Inactive",vacation_end_now_confirm:"End vacation immediately?",vacation_exempt_count:"exempt",vacation_advanced:"Advanced\u2026",vacation_open_panel:"Open in panel",enable:"Enable",saved:"Saved",budget_monthly_set:"Set monthly",budget_yearly_set:"Set yearly",budget_advanced:"Currency, alerts\u2026",budget_open_panel:"Open in panel",groups_empty:"No groups yet.",group_new_placeholder:"Add group\u2026",group_delete_confirm:'Delete group "{name}"?',groups_manage_tasks:"Manage task assignments\u2026",groups_open_panel:"Open in panel",unassigned:"Unassigned",no_area:"No area",has_overdue:"Has overdue tasks",object:"Object",settings_panel_access:"Panel access",settings_panel_access_desc:"Admins always have full access. To delegate create, edit and delete to specific non-admins, switch this on and pick them below \u2014 everyone else sees only Complete and Skip.",settings_operator_write:"Allow selected users to create, edit & delete",settings_operator_write_desc:"Off: only admins can change content. On: the selected users below get full access too.",no_non_admin_users:"No non-admin users found. Add some in Settings \u2192 People.",owner_label:"Owner",feat_completion_actions:"Completion actions",feat_completion_actions_desc:"Per-task HA action on complete + quick-complete QR with pre-set values.",on_complete_action_title:"On complete: trigger HA action (optional)",on_complete_action_desc:"Calls an HA service when the task is completed \u2014 e.g. reset a counter on the device.",on_complete_action_service:"Service",on_complete_action_target:"Target entity",on_complete_action_target_hint:"Note: the entity domain must match the service \u2014 e.g. 'button.press' only works on button.*, 'counter.increment' only on counter.*, 'input_button.press' only on input_button.* etc. On a mismatch the action will silently fail (HA logs 'Referenced entities ... missing or not currently available').",on_complete_action_data:"Data (JSON, optional)",on_complete_action_test:"Validate configuration",on_complete_action_test_success:"\u2713 Configuration valid (action will fire only on task completion)",on_complete_action_test_failed:"Failed",quick_complete_defaults_title:"Quick-complete defaults (for QR scans, optional)",quick_complete_defaults_desc:"Pre-set values for quick-complete QR scans. Without these, the QR opens the complete dialog.",quick_complete_defaults_notes:"Notes",quick_complete_defaults_cost:"Cost",quick_complete_defaults_duration:"Duration (minutes)",quick_complete_defaults_feedback_none:"No feedback",quick_complete_defaults_feedback_needed:"Was needed",quick_complete_defaults_feedback_not_needed:"Not needed",quick_complete_success:"Quickly marked complete",show_all_objects:"Show all objects",show_all_tasks:"Clear filter \u2014 show all tasks",filter_to_overdue:"Filter task list to overdue only",filter_to_due_soon:"Filter task list to due-soon only",filter_to_triggered:"Filter task list to triggered only",open_task:"Open task",show_details:"Show history + stats",hide_details:"Hide details",history_empty:"No history yet.",history_edit_button:"Edit entry",total_cost:"Total cost",times_performed:"Performed",older_entries:"older",open_in_panel:"Open in Maintenance panel",skip_reason:"Skip reason (optional)",reset_to_date:"Reset last_performed to",delete_task_confirm:"Delete this task and its history?",delete_object_confirm:"Delete this object and all its tasks?",loading:"Loading\u2026",archive:"Archive",undo:"Undo",task_archived:"Task archived",object_archived:"Object archived",unarchive:"Unarchive",archived:"Archived",show_archived:"Show archived",hide_archived:"Hide archived",confirm_archive_object:"Archive this object and its tasks? They keep their history and can be unarchived later.",settings_archive:"Archive & Retention",settings_archive_desc:"Retire completed one-off tasks without deleting them. Archived items are hidden and inert but keep their history and cost.",settings_archive_oneoff_days:"Auto-archive completed one-off tasks after (days, 0 = off)",settings_delete_archived_oneoff_days:"Auto-delete archived one-off tasks after (days, 0 = never)",archive_object:"Archive object",unarchive_object:"Unarchive object",documents:"Documents",documents_empty:"No documents yet.",doc_upload:"Upload file",doc_uploading:"Uploading\u2026",doc_add_link:"Add link",doc_link_url:"URL (https://\u2026)",doc_link_title:"Title (optional)",doc_open:"Open",doc_delete_confirm:'Delete "{name}"?',doc_too_large:"File is too large (max 25 MB).",doc_upload_failed:"Upload failed.",completion_photo_optional:"Completion photo (optional)",add_photo:"Add photo",uploading:"Uploading\u2026",remove:"Remove",doc_deduped:"Already stored elsewhere \u2014 shared, no extra space used.",doc_dup_in_object:"This file is already attached to this object.",doc_link_invalid:"Only http/https links are allowed.",doc_cat_manual:"Manual",doc_cat_warranty:"Warranty",doc_cat_invoice:"Invoice",doc_cat_spare_parts:"Spare parts",doc_cat_photo:"Photo",doc_cat_other:"Other",doc_link_badge:"Link",doc_storage_title:"Document storage",doc_storage_saved:"Saved via deduplication",doc_storage_refresh:"Refresh",doc_download:"Download",doc_close:"Close",doc_camera:"Take photo",doc_drop_hint:"Drop files here",doc_task_none:"No documents linked to this task.",doc_link_existing:"Link a document\u2026",doc_attach:"Link",doc_unlink:"Unlink",doc_page:"Page",chart_range_7d:"7d",chart_range_30d:"30d",chart_range_90d:"90d",chart_range_1y:"1y",chart_since_service:"since last service",chart_no_stats:"No long-term statistics for this entity \u2014 showing maintenance-event values only",auto_complete_on_recovery:"Auto-complete when the sensor recovers",auto_complete_on_recovery_help:"Records a completion (sets last performed) when the trigger clears itself \u2014 e.g. salt refilled, filter replaced.",doc_search:"Search documents\u2026",doc_search_none:"No matching documents",link_device_optional:"Link to existing device (optional)",parent_object_optional:"Parent object (optional)",parent_none:"(No parent)",paused:"Paused",pause_object:"Pause",resume_object:"Resume",pause_until_prompt:"Freeze this object's schedules \u2014 nothing becomes due and nothing notifies until it is resumed. Optionally set an auto-resume date.",pause_until_label:"Resume on (optional)",object_paused:"Object paused",object_resumed:"Object resumed \u2014 schedules restarted",object_paused_badge:"Paused",paused_until_label:"until",replace_object:"Replace\u2026",replace_object_prompt:"Retire this object and create a successor. History and costs stay archived on the old one; tasks and documents carry over to the new one, counters start fresh.",replace_name_label:"Successor name",object_replaced:"Object replaced \u2014 successor created",reading_unit_label:"Reading unit (e.g. kWh, m\xB3)",reading_unit_help:"Shown next to the recorded value when completing this task.",reading_value_label:"Reading value",reading_label:"Reading",settings_templates_label:"Template gallery",settings_templates_hint:`Untick templates you'll never need \u2014 they disappear from the "From template" pickers (panel and config flow). Nothing else changes; you can re-enable them any time.`,worksheet:"Work sheet",worksheet_scan_view:"Scan to open the task",worksheet_scan_complete:"Scan to complete",worksheet_manual_excerpt:"Manual excerpt",worksheet_pages:"pages",worksheet_printed:"Printed",worksheet_never:"Never",card_all_caught_up:"All caught up \u2014 nothing needs attention",postpone:"Postpone",postpone_date_prompt:"Postpone this occurrence to which date?",postpone_date_label:"New due date",postponed:"Postponed",postponed_to:"Postponed to",season_window_label:"Seasonal window (months)",season_window_hint:"Only due in the selected months; off-season dates roll to the next active month. None = all year.",series_end_label:"Ends",series_end_never:"Never (repeats indefinitely)",series_end_after_count:"After a number of times",series_end_until:"On a date",series_end_count_label:"Number of times",series_end_until_label:"End date",parts_section:"Parts & consumables",part_add:"Add part",part_name:"Name",part_vendor:"Manufacturer",part_storage_location:"Storage location",part_product_url:"Product URL",part_unit:"Unit",part_cost:"Unit price",part_stock:"Stock",part_reorder_threshold:"Reorder at",part_restock_quantity:"Restock quantity",part_auto_buy:"Auto-create buy task when low",part_restock:"Adjust stock",restock_quantity_label:"Quantity bought",consumes_parts_label:"Consumes parts",parts_load_failed:"Couldn't load this object's parts \u2014 the consumes-parts options are unavailable right now.",adopt_problem_button:"Adopt problem sensors",adopt_problem_title:"Adopt problem sensors",adopt_problem_hint:"Turn HA problem sensors (printer errors, filter warnings, low battery) into maintenance tasks that trigger while the problem is active and clear themselves when it resolves.",adopt_problem_none:"No problem sensors found that aren't already tracked.",adopt_problem_active:"active",adopt_problem_ok:"ok",adopt_problem_new_object:"(new)",adopt_problem_adopt:"Adopt selected",adopt_problem_done:"Adopted {tasks} problem sensor(s)",views_label:"Views",views_none:"\u2014 No view \u2014",views_manage:"Save / manage views",views_dialog_title:"Saved views",views_dialog_hint:"Save the current filters as a named view everyone can reuse.",views_name_placeholder:"View name",views_save_current:"Save current filters",views_none_yet:"No saved views yet.",close:"Close",trigger_hint_now:"The sensor reads {value} right now.",trigger_hint_above:"The task triggers once it rises above {target}.",trigger_hint_below:"It triggers once it falls below {target}.",trigger_hint_counter_delta:"Counts from the current reading ({value}): due at {due} (+{target}), and the count restarts after each completion.",trigger_hint_counter_delta_edit:"Counts usage since the last completion: due after +{target}; the count restarts after each completion.",trigger_hint_counter_abs:"The task becomes due once the sensor reaches {target}.",trigger_hint_runtime:"The task becomes due after {hours} h of accumulated on-time; the counter restarts after each completion.",trigger_hint_state_change:"The task becomes due after {count} state change(s).",trigger_hint_state_change_to:"The task becomes due after {count} change(s) to \u201C{state}\u201D.",trigger_hint_state_now:"Current state: {value}.",adopt_problem_part:"Uses part: {name}",label_filter:"Label",all_labels:"All labels",settings_notify_scope:"Notify only for view",settings_notify_scope_all:"All tasks",settings_notify_scope_hint:"Only tasks matching the selected saved view's label/user filters send reminders. Status, sorting and grouping of the view are ignored here.",card_saved_view:"Saved view",card_saved_view_none:"None",card_saved_view_help:"Applies the view's status, user and label filters on top of the filters above. The view's sorting and grouping are panel display settings and are not applied on the card.",doc_part_none:"No documents linked to this part.",settings_templates_toggle_group:"Enable or disable all templates in this group",setups_button:"Suggested setups",setups_title:"Suggested setups (Beta)",setups_hint:"Devices of supported integrations whose consumable sensors can drive maintenance tasks. Adopting creates the object and wires each task to its sensor \u2014 it triggers when the consumable runs low and resolves itself after replacement.",setups_none:"No supported devices with unwired consumable sensors found.",setups_adopt:"Set up selected",setups_done:"{tasks} sensor-wired tasks created.",complete_parts_used:"Parts used this time",part_delete_confirm:"Delete part '{name}'? Its stock tracking, task links and any open buy reminder will be removed.",baseline_start_value:"Start reading (optional)",baseline_start_help:"Counting starts from this reading. Leave empty to count from the current value; enter the reading at the last service so usage since then already counts.",setups_baseline_hint:"reading at last service (optional)",baseline_start_help_edit:"Leave empty to keep the existing counting. Entering a value re-anchors the counting (e.g. the reading at the last service).",baseline_current_effective:"Currently effective start value: {value}",runtime_on_states:"Active states (comma-separated)",runtime_on_states_help:"States that count as running \u2014 default: on. E.g. mowing, cleaning, printing. With an attribute selected, its values are matched instead.",setups_target_new:"Create new: {name}",schedule_preview_title:"Next dates",schedule_preview_ontime:"Assuming on-time completion.",schedule_preview_ends:"(series ends)",adopt_problem_responsible:"Responsible user for all adopted tasks (optional)",adopt_problem_configure:"Configure",history_auto:"Automatic",battery_fleet_title:"Battery fleet",battery_fleet_none_low:"All batteries OK \u2014 nothing to replace.",battery_fleet_buy_now:"Buy now",battery_fleet_soon:"Needed soon",battery_fleet_soon_hint:"Predicted from the last replacement date \u2014 order ahead.",battery_fleet_mark_all:"Mark all replaced",battery_fleet_mark_one:"Mark this battery replaced",battery_fleet_offline:"offline",battery_fleet_trigger_lost:"This task's sensor trigger was lost \u2014 it will not fire or auto-complete.",battery_fleet_repair:"Repair",battery_fleet_total:"{n} batteries tracked",battery_fleet_setup_button:"Battery fleet",battery_fleet_setup_done:"Battery fleet set up \u2014 one task tracks all your batteries."};var Ge={ok:"var(--success-color, #4caf50)",due_soon:"var(--warning-color, #ff9800)",overdue:"var(--error-color, #f44336)",triggered:"var(--deep-orange-color, #ff5722)",archived:"var(--disabled-color, #9e9e9e)",paused:"var(--info-color, #2196f3)"},Qe={ok:"mdi:check-circle",due_soon:"mdi:alert-circle",overdue:"mdi:alert-octagon",triggered:"mdi:bell-alert",archived:"mdi:archive-outline",paused:"mdi:pause-circle-outline",completed:"mdi:check-circle",skipped:"mdi:skip-next",missed:"mdi:calendar-remove",reset:"mdi:refresh"};var je="\u20AC",Bt="en",xi=(()=>{let n=window;return n.__msLocales||(n.__msLocales={store:{},inflight:{}}),n.__msLocales})(),Ae=xi.store;Ae.en||(Ae.en=fi);var fs=new Set(["de","nl","fr","it","es","pt","ru","uk","pl","cs","sv","zh","da","fi","nb","ja","hi"]),ys="/maintenance_supporter_locales",Ze=xi.inflight;function Vt(n){return(n||Bt).substring(0,2).toLowerCase()}function s(n,r){let e=Vt(r);return Ae[e]?.[n]??Ae.en[n]??n}function _t(n){let r=Vt(n);return r===Bt||r in Ae}function V(n){let r=Vt(n);return r===Bt||r in Ae||!fs.has(r)?Promise.resolve():(r in Ze||(Ze[r]=fetch(`${ys}/${r}.json`).then(e=>e.ok?e.json():null).then(e=>{e?Ae[r]=e:delete Ze[r]}).catch(()=>{delete Ze[r]})),Ze[r])}function ht(n){let r=(n||"en").substring(0,2).toLowerCase();return{de:"de-DE",en:"en-US",nl:"nl-NL",fr:"fr-FR",it:"it-IT",es:"es-ES",pt:"pt-PT",ru:"ru-RU",uk:"uk-UA",zh:"zh-CN",da:"da-DK",fi:"fi-FI",nb:"nb-NO",ja:"ja-JP",hi:"hi-IN",pl:"pl-PL",cs:"cs-CZ",sv:"sv-SE"}[r]??"en-US"}var xs=window,ut=xs.__msDateTimePrefs??={};function gt(n){n&&(ut.date=n.date_format,ut.time=n.time_format)}function $i(n,r){let e=String(n.getDate()).padStart(2,"0"),t=String(n.getMonth()+1).padStart(2,"0"),i=String(n.getFullYear());switch(ut.date){case"DMY":return`${e}/${t}/${i}`;case"MDY":return`${t}/${e}/${i}`;case"YMD":return`${i}-${t}-${e}`;case"system":return n.toLocaleDateString(void 0,{day:"2-digit",month:"2-digit",year:"numeric"});default:return n.toLocaleDateString(ht(r),{day:"2-digit",month:"2-digit",year:"numeric"})}}function $s(n,r){switch(ut.time){case"12":return n.toLocaleTimeString(ht(r),{hour:"2-digit",minute:"2-digit",hour12:!0});case"24":return n.toLocaleTimeString(ht(r),{hour:"2-digit",minute:"2-digit",hour12:!1});case"system":return n.toLocaleTimeString(void 0,{hour:"2-digit",minute:"2-digit"});default:return n.toLocaleTimeString(ht(r),{hour:"2-digit",minute:"2-digit"})}}function B(n,r){if(!n)return"\u2014";try{let e=n.includes("T")?n:n+"T00:00:00";return $i(new Date(e),r)}catch{return n}}function mt(n,r){if(!n)return"\u2014";try{let e=new Date(n);return $i(e,r)+" "+$s(e,r)}catch{return n}}function Pe(n,r){if(n==null)return"\u2014";let e=r||"en";return n<0?`${Math.abs(n)} ${s("d_overdue",e)}`:n===0?s("today",e):`${n} ${s(n===1?"day":"days",e)}`}function yi(n,r,e){return n==null?"\u2014":`${n} ${s("unit_"+(r||"days"),e)}`}function Je(n,r,e="long"){let t=(r||"en").substring(0,2);return new Date(Date.UTC(2024,0,1+n)).toLocaleDateString(t,{weekday:e,timeZone:"UTC"})}function Xe(n,r){let e=n.schedule,t=e?.offset?` ${e.offset>0?"+":"\u2212"}${Math.abs(e.offset)}d`:"";switch(e?.kind){case"weekdays":return((e.weekdays||[]).map(i=>Je(i,r,"short")).join(" & ")||"\u2014")+t;case"nth_weekday":return e.weekday==null||e.nth==null?"\u2014":`${e.nth===-1?s("ord_last",r):s("ord_"+e.nth,r)} ${Je(e.weekday,r,"long")}${t}`;case"day_of_month":return e.day==null?"\u2014":(e.day===-1?s(e.business?"last_business_day_month":"last_day_month",r):`${s("day_word",r)} ${e.day}`)+t;case"one_time":return n.due_date?B(n.due_date,r):s("one_time",r);case"manual":return s("manual",r);case"interval":return yi(e.every,e.unit,r)}return n.schedule_type==="one_time"?n.due_date?B(n.due_date,r):s("one_time",r):n.schedule_type==="manual"?s("manual",r):n.schedule_type==="sensor_based"?s("sensor_based",r):n.interval_days!=null?yi(n.interval_days,n.interval_unit,r):"\u2014"}function Ce(n,r){n.currentTarget.dispatchEvent(new CustomEvent("hass-more-info",{detail:{entityId:r},bubbles:!0,composed:!0}))}var vt=A` .field { display: flex; flex-direction: column; gap: 4px; } @@ -3355,7 +3355,7 @@ ${H(n.notes)}
`:""} } .tdoc-btn ha-icon { --mdc-icon-size: 18px; } .tdoc-btn[disabled] { opacity: 0.5; pointer-events: none; } - `,d([y({attribute:!1})],J.prototype,"hass",2),d([y({attribute:!1})],J.prototype,"entryId",2),d([y({attribute:!1})],J.prototype,"taskId",2),d([y({attribute:!1})],J.prototype,"partId",2),d([y({type:Boolean})],J.prototype,"canWrite",2),d([_()],J.prototype,"_docs",2),d([_()],J.prototype,"_loaded",2),d([_()],J.prototype,"_busy",2),d([_()],J.prototype,"_error",2),d([_()],J.prototype,"_attachId",2);customElements.get("maintenance-task-documents")||customElements.define("maintenance-task-documents",J);var Rs={name:"",vendor:"",mpn:"",gtin:"",storage_location:"",product_url:"",unit:"",cost:"",stock:"",reorder_threshold:"",restock_quantity:"",auto_buy_task:!0,notes:""},Q=class extends S{constructor(){super(...arguments);this.parts=[];this.canWrite=!1;this._editing=null;this._busy=!1;this._error="";this._restockFor=null;this._restockQty="";this._restockInvalid=!1;this._docsFor=null}get _lang(){return this.hass?.locale?.language||this.hass?.language||"en"}connectedCallback(){super.connectedCallback(),V(this._lang).then(()=>this.requestUpdate())}_notifyChanged(){this.dispatchEvent(new CustomEvent("parts-changed",{bubbles:!0,composed:!0}))}async _send(e){this._busy=!0,this._error="";try{return await this.hass.connection.sendMessagePromise(e)}catch(t){return this._error=j(t,this._lang),null}finally{this._busy=!1}}_openAdd(){this._editing={...Rs}}_openEdit(e){this._editing={id:e.id,name:e.name,vendor:e.vendor||"",mpn:e.mpn||"",gtin:e.gtin||"",storage_location:e.storage_location||"",product_url:e.product_url||"",unit:e.unit||"",cost:e.cost!=null?String(e.cost):"",stock:e.stock!=null?String(e.stock):"",reorder_threshold:e.reorder_threshold!=null?String(e.reorder_threshold):"",restock_quantity:e.restock_quantity!=null?String(e.restock_quantity):"",auto_buy_task:!!e.auto_buy_task,notes:e.notes||""}}_formValue(e){let t=i=>i.trim()===""?null:Number(i);return{entry_id:this.entryId,name:e.name.trim(),vendor:e.vendor.trim()||null,mpn:e.mpn.trim()||null,gtin:e.gtin.trim()||null,storage_location:e.storage_location.trim()||null,product_url:e.product_url.trim()||null,unit:e.unit.trim()||null,cost:t(e.cost),stock:t(e.stock),reorder_threshold:t(e.reorder_threshold),restock_quantity:t(e.restock_quantity),auto_buy_task:e.auto_buy_task,notes:e.notes.trim()||null}}async _save(){let e=this._editing;if(!e||!e.name.trim())return;let t=this._formValue(e),i=e.id?"maintenance_supporter/part/update":"maintenance_supporter/part/create";await this._send(e.id?{type:i,part_id:e.id,...t}:{type:i,...t})!==null&&(this._editing=null,this._notifyChanged())}async _delete(e){if(!window.confirm(s("part_delete_confirm",this._lang).replace("{name}",e.name)))return;await this._send({type:"maintenance_supporter/part/delete",entry_id:this.entryId,part_id:e.id})!==null&&this._notifyChanged()}async _restock(e){let t=parseFloat(this._restockQty);if(!Number.isFinite(t)||t===0){this._restockInvalid=!0;return}this._restockInvalid=!1;let i=await this._send({type:"maintenance_supporter/part/restock",entry_id:this.entryId,part_id:e.id,delta:t});this._restockFor=null,i!==null&&(e.stock=i.stock,this.requestUpdate(),this._notifyChanged())}_identLine(e){return[e.vendor,e.mpn?`MPN: ${e.mpn}`:"",e.gtin?`GTIN: ${e.gtin}`:""].filter(Boolean).join(" \xB7 ")}_renderRow(e){let t=this._lang,i=e.stock!==null&&e.stock!==void 0,a=this._identLine(e),l=this._docsFor===e.id;return o` + `,d([y({attribute:!1})],J.prototype,"hass",2),d([y({attribute:!1})],J.prototype,"entryId",2),d([y({attribute:!1})],J.prototype,"taskId",2),d([y({attribute:!1})],J.prototype,"partId",2),d([y({type:Boolean})],J.prototype,"canWrite",2),d([_()],J.prototype,"_docs",2),d([_()],J.prototype,"_loaded",2),d([_()],J.prototype,"_busy",2),d([_()],J.prototype,"_error",2),d([_()],J.prototype,"_attachId",2);customElements.get("maintenance-task-documents")||customElements.define("maintenance-task-documents",J);var Ms={name:"",vendor:"",mpn:"",gtin:"",storage_location:"",product_url:"",unit:"",cost:"",stock:"",reorder_threshold:"",restock_quantity:"",auto_buy_task:!0,notes:""},Q=class extends S{constructor(){super(...arguments);this.parts=[];this.canWrite=!1;this._editing=null;this._busy=!1;this._error="";this._restockFor=null;this._restockQty="";this._restockInvalid=!1;this._docsFor=null}get _lang(){return this.hass?.locale?.language||this.hass?.language||"en"}connectedCallback(){super.connectedCallback(),V(this._lang).then(()=>this.requestUpdate())}_notifyChanged(){this.dispatchEvent(new CustomEvent("parts-changed",{bubbles:!0,composed:!0}))}async _send(e){this._busy=!0,this._error="";try{return await this.hass.connection.sendMessagePromise(e)}catch(t){return this._error=j(t,this._lang),null}finally{this._busy=!1}}_openAdd(){this._editing={...Ms}}_openEdit(e){this._editing={id:e.id,name:e.name,vendor:e.vendor||"",mpn:e.mpn||"",gtin:e.gtin||"",storage_location:e.storage_location||"",product_url:e.product_url||"",unit:e.unit||"",cost:e.cost!=null?String(e.cost):"",stock:e.stock!=null?String(e.stock):"",reorder_threshold:e.reorder_threshold!=null?String(e.reorder_threshold):"",restock_quantity:e.restock_quantity!=null?String(e.restock_quantity):"",auto_buy_task:!!e.auto_buy_task,notes:e.notes||""}}_formValue(e){let t=i=>i.trim()===""?null:Number(i);return{entry_id:this.entryId,name:e.name.trim(),vendor:e.vendor.trim()||null,mpn:e.mpn.trim()||null,gtin:e.gtin.trim()||null,storage_location:e.storage_location.trim()||null,product_url:e.product_url.trim()||null,unit:e.unit.trim()||null,cost:t(e.cost),stock:t(e.stock),reorder_threshold:t(e.reorder_threshold),restock_quantity:t(e.restock_quantity),auto_buy_task:e.auto_buy_task,notes:e.notes.trim()||null}}async _save(){let e=this._editing;if(!e||!e.name.trim())return;let t=this._formValue(e),i=e.id?"maintenance_supporter/part/update":"maintenance_supporter/part/create";await this._send(e.id?{type:i,part_id:e.id,...t}:{type:i,...t})!==null&&(this._editing=null,this._notifyChanged())}async _delete(e){if(!window.confirm(s("part_delete_confirm",this._lang).replace("{name}",e.name)))return;await this._send({type:"maintenance_supporter/part/delete",entry_id:this.entryId,part_id:e.id})!==null&&this._notifyChanged()}async _restock(e){let t=parseFloat(this._restockQty);if(!Number.isFinite(t)||t===0){this._restockInvalid=!0;return}this._restockInvalid=!1;let i=await this._send({type:"maintenance_supporter/part/restock",entry_id:this.entryId,part_id:e.id,delta:t});this._restockFor=null,i!==null&&(e.stock=i.stock,this.requestUpdate(),this._notifyChanged())}_identLine(e){return[e.vendor,e.mpn?`MPN: ${e.mpn}`:"",e.gtin?`GTIN: ${e.gtin}`:""].filter(Boolean).join(" \xB7 ")}_renderRow(e){let t=this._lang,i=e.stock!==null&&e.stock!==void 0,a=this._identLine(e),l=this._docsFor===e.id;return o`
@@ -3590,7 +3590,7 @@ ${H(n.notes)}
`:""} font-size: 13px; margin: 4px 0; } - `,d([y({attribute:!1})],Q.prototype,"hass",2),d([y({attribute:!1})],Q.prototype,"entryId",2),d([y({attribute:!1})],Q.prototype,"parts",2),d([y({type:Boolean})],Q.prototype,"canWrite",2),d([_()],Q.prototype,"_editing",2),d([_()],Q.prototype,"_busy",2),d([_()],Q.prototype,"_error",2),d([_()],Q.prototype,"_restockFor",2),d([_()],Q.prototype,"_restockQty",2),d([_()],Q.prototype,"_restockInvalid",2),d([_()],Q.prototype,"_docsFor",2);customElements.define("maintenance-parts-section",Q);var Ms=["cleaning","inspection","replacement","calibration","service","reading","custom"],Is=["low","normal","high"],Ls=["time_based","weekdays","nth_weekday","day_of_month","sensor_based","one_time","manual"],Yt=["weekdays","nth_weekday","day_of_month"],Ti=["threshold","counter","state_change","runtime"],Ps=[...Ti,"compound"];function zs(){return{entityIds:"",type:"threshold",above:"",below:"",forMinutes:"0",targetValue:"",deltaMode:!1,fromState:"",toState:"",targetChanges:"",runtimeHours:"",onStates:"",carry:{}}}var Hs=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"]);function Os(n){return{entityIds:(n.entity_ids||(n.entity_id?[n.entity_id]:[])).join(", "),type:n.type||"threshold",above:n.trigger_above?.toString()??"",below:n.trigger_below?.toString()??"",forMinutes:n.trigger_for_minutes?.toString()??"0",targetValue:n.trigger_target_value?.toString()??"",deltaMode:n.trigger_delta_mode||!1,fromState:n.trigger_from_state||"",toState:n.trigger_to_state||"",targetChanges:n.trigger_target_changes?.toString()??"",runtimeHours:n.trigger_runtime_hours?.toString()??"",onStates:(n.trigger_on_states||[]).join(", "),carry:Object.fromEntries(Object.entries(n).filter(([e])=>!Hs.has(e)&&!e.startsWith("_")))}}function Ds(n){let r=n.entityIds.split(",").map(t=>t.trim()).filter(Boolean);if(r.length===0)return null;let e={...n.carry||{},entity_id:r[0],entity_ids:r,type:n.type};if(n.type==="threshold"){let t=parseFloat(n.above);isNaN(t)||(e.trigger_above=t);let i=parseFloat(n.below);isNaN(i)||(e.trigger_below=i);let a=parseInt(n.forMinutes,10);isNaN(a)||(e.trigger_for_minutes=a)}else if(n.type==="counter"){let t=parseFloat(n.targetValue);isNaN(t)||(e.trigger_target_value=t),e.trigger_delta_mode=n.deltaMode}else if(n.type==="state_change"){n.fromState&&(e.trigger_from_state=n.fromState),n.toState&&(e.trigger_to_state=n.toState);let t=parseInt(n.targetChanges,10);isNaN(t)||(e.trigger_target_changes=t)}else if(n.type==="runtime"){let t=parseFloat(n.runtimeHours);isNaN(t)||(e.trigger_runtime_hours=t);let i=(n.onStates||"").split(",").map(a=>a.trim()).filter(Boolean);i.length>0&&(e.trigger_on_states=i)}return e}function qs(n){return Array.from({length:7},(r,e)=>Je(e,n,"short"))}function Ns(n){let r=new Intl.DateTimeFormat(n||"en",{month:"short"});return Array.from({length:12},(e,t)=>r.format(new Date(2021,t,1)))}var x=class x extends S{constructor(){super(...arguments);this.checklistsEnabled=!1;this.scheduleTimeEnabled=!1;this.completionActionsEnabled=!1;this.defaultWarningDays=7;this.parts=[];this._open=!1;this._loading=!1;this._error="";this._entryId="";this._taskId=null;this._objectChoices=[];this._name="";this._type="custom";this._scheduleType="time_based";this._intervalDays="30";this._intervalUnit="days";this._dueDate="";this._warningDays="7";this._earliestCompletionDays="";this._intervalAnchor="completion";this._weekdays=[];this._nth="1";this._nthWeekday="5";this._domDay="1";this._domLastDay=!1;this._domBusiness=!1;this._calOffset="0";this._seasonMonths=[];this._endsMode="never";this._endsCount="";this._endsUntil="";this._schedulePreview=[];this._schedulePreviewEnded=!1;this._previewSeq=0;this._notes="";this._documentationUrl="";this._customIcon="";this._priority="normal";this._labels="";this._enabled=!0;this._triggerEntityId="";this._triggerEntityIds=[];this._triggerEntityLogic="any";this._triggerAttribute="";this._triggerType="threshold";this._triggerAbove="";this._triggerBelow="";this._triggerForMinutes="0";this._triggerTargetValue="";this._triggerDeltaMode=!1;this._triggerBaselineValue="";this._liveBaselineValue=null;this._autoCompleteOnRecovery=!1;this._triggerFromState="";this._triggerToState="";this._triggerTargetChanges="";this._triggerRuntimeHours="";this._triggerOnStates="";this._compoundLogic="AND";this._compoundConditions=[];this._suggestedAttributes=[];this._availableAttributes=[];this._entityDomain="";this._lastPerformed="";this._nfcTagId="";this._readingUnit="";this._consumesParts={};this._partsLoadFailed=!1;this._availableTags=[];this._responsibleUserId=null;this._assigneePool=[];this._rotationStrategy="";this._availableUsers=[];this._checklistText="";this._scheduleTime="";this._actionService="";this._actionTargetEntity="";this._actionData={};this._actionDataJsonFallback="";this._actionTesting=!1;this._actionTestResult="";this._actionTestError="";this._qcNotes="";this._qcCost="";this._qcDuration="";this._qcFeedback="";this._environmentalEntity="";this._environmentalAttribute="";this._environmentalInitial="";this._environmentalAttributeInitial="";this._userService=null}get _lang(){return this.hass?.language??navigator.language.split("-")[0]??"en"}async openCreate(e,t){this._entryId=e,this._taskId=null,this._error="",!e&&t&&t.length>0?(this._objectChoices=t.map(i=>({entry_id:i.entry_id,name:i.object.name})).sort((i,a)=>i.name.localeCompare(a.name)),this._entryId=this._objectChoices[0].entry_id):this._objectChoices=[],this._resetFields(),await Promise.all([this._loadUsers(),this._loadTags(),this._loadParts()]),this._open=!0}async openEdit(e,t){this._entryId=e,this._taskId=t.id,this._error="",this._name=t.name,this._type=t.type,this._scheduleType=t.schedule_type,this._intervalDays=t.interval_days!=null?String(t.interval_days):"",this._intervalUnit=t.interval_unit||"days",this._dueDate=t.due_date||"";let i=t.schedule;this._weekdays=i?.kind==="weekdays"?[...i.weekdays??[]]:[],this._nth=i?.kind==="nth_weekday"?String(i.nth??1):"1",this._nthWeekday=i?.kind==="nth_weekday"?String(i.weekday??5):"5",this._domDay=i?.kind==="day_of_month"&&(i.day??1)>=1?String(i.day??1):"1",this._domLastDay=i?.kind==="day_of_month"&&i.day===-1,this._domBusiness=i?.kind==="day_of_month"&&i.business===!0,this._calOffset=i?.offset?String(i.offset):"0",this._seasonMonths=Array.isArray(i?.season_months)?[...i.season_months]:[];let a=i?.ends;a&&typeof a.count=="number"?(this._endsMode="count",this._endsCount=String(a.count),this._endsUntil=""):a&&typeof a.until=="string"?(this._endsMode="until",this._endsUntil=a.until,this._endsCount=""):(this._endsMode="never",this._endsCount="",this._endsUntil=""),this._warningDays=t.warning_days.toString(),this._earliestCompletionDays=t.earliest_completion_days!=null?String(t.earliest_completion_days):"",this._intervalAnchor=t.interval_anchor||"completion",this._notes=t.notes||"",this._documentationUrl=t.documentation_url||"",this._customIcon=t.custom_icon||"",this._priority=t.priority||"normal",this._labels=(t.labels||[]).join(", "),this._enabled=t.enabled!==!1,this._lastPerformed=t.last_performed||"",this._nfcTagId=t.nfc_tag_id||"",this._readingUnit=t.reading_unit||"",this._consumesParts=Object.fromEntries((t.consumes_parts||[]).map(u=>[u.part_id,u.quantity])),this._responsibleUserId=t.responsible_user_id||null,this._assigneePool=[...t.assignee_pool||[]],this._rotationStrategy=t.rotation_strategy||"",this._checklistText=(t.checklist||[]).join(` + `,d([y({attribute:!1})],Q.prototype,"hass",2),d([y({attribute:!1})],Q.prototype,"entryId",2),d([y({attribute:!1})],Q.prototype,"parts",2),d([y({type:Boolean})],Q.prototype,"canWrite",2),d([_()],Q.prototype,"_editing",2),d([_()],Q.prototype,"_busy",2),d([_()],Q.prototype,"_error",2),d([_()],Q.prototype,"_restockFor",2),d([_()],Q.prototype,"_restockQty",2),d([_()],Q.prototype,"_restockInvalid",2),d([_()],Q.prototype,"_docsFor",2);customElements.define("maintenance-parts-section",Q);var Rs=["cleaning","inspection","replacement","calibration","service","reading","custom"],Is=["low","normal","high"],Ls=["time_based","weekdays","nth_weekday","day_of_month","sensor_based","one_time","manual"],Yt=["weekdays","nth_weekday","day_of_month"],Ti=["threshold","counter","state_change","runtime"],Ps=[...Ti,"compound"];function zs(){return{entityIds:"",type:"threshold",above:"",below:"",forMinutes:"0",targetValue:"",deltaMode:!1,fromState:"",toState:"",targetChanges:"",runtimeHours:"",onStates:"",carry:{}}}var Hs=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"]);function Os(n){return{entityIds:(n.entity_ids||(n.entity_id?[n.entity_id]:[])).join(", "),type:n.type||"threshold",above:n.trigger_above?.toString()??"",below:n.trigger_below?.toString()??"",forMinutes:n.trigger_for_minutes?.toString()??"0",targetValue:n.trigger_target_value?.toString()??"",deltaMode:n.trigger_delta_mode||!1,fromState:n.trigger_from_state||"",toState:n.trigger_to_state||"",targetChanges:n.trigger_target_changes?.toString()??"",runtimeHours:n.trigger_runtime_hours?.toString()??"",onStates:(n.trigger_on_states||[]).join(", "),carry:Object.fromEntries(Object.entries(n).filter(([e])=>!Hs.has(e)&&!e.startsWith("_")))}}function Ds(n){let r=n.entityIds.split(",").map(t=>t.trim()).filter(Boolean);if(r.length===0)return null;let e={...n.carry||{},entity_id:r[0],entity_ids:r,type:n.type};if(n.type==="threshold"){let t=parseFloat(n.above);isNaN(t)||(e.trigger_above=t);let i=parseFloat(n.below);isNaN(i)||(e.trigger_below=i);let a=parseInt(n.forMinutes,10);isNaN(a)||(e.trigger_for_minutes=a)}else if(n.type==="counter"){let t=parseFloat(n.targetValue);isNaN(t)||(e.trigger_target_value=t),e.trigger_delta_mode=n.deltaMode}else if(n.type==="state_change"){n.fromState&&(e.trigger_from_state=n.fromState),n.toState&&(e.trigger_to_state=n.toState);let t=parseInt(n.targetChanges,10);isNaN(t)||(e.trigger_target_changes=t)}else if(n.type==="runtime"){let t=parseFloat(n.runtimeHours);isNaN(t)||(e.trigger_runtime_hours=t);let i=(n.onStates||"").split(",").map(a=>a.trim()).filter(Boolean);i.length>0&&(e.trigger_on_states=i)}return e}function qs(n){return Array.from({length:7},(r,e)=>Je(e,n,"short"))}function Ns(n){let r=new Intl.DateTimeFormat(n||"en",{month:"short"});return Array.from({length:12},(e,t)=>r.format(new Date(2021,t,1)))}var x=class x extends S{constructor(){super(...arguments);this.checklistsEnabled=!1;this.scheduleTimeEnabled=!1;this.completionActionsEnabled=!1;this.defaultWarningDays=7;this.parts=[];this._open=!1;this._loading=!1;this._error="";this._entryId="";this._taskId=null;this._objectChoices=[];this._name="";this._type="custom";this._scheduleType="time_based";this._intervalDays="30";this._intervalUnit="days";this._dueDate="";this._warningDays="7";this._earliestCompletionDays="";this._intervalAnchor="completion";this._weekdays=[];this._nth="1";this._nthWeekday="5";this._domDay="1";this._domLastDay=!1;this._domBusiness=!1;this._calOffset="0";this._seasonMonths=[];this._endsMode="never";this._endsCount="";this._endsUntil="";this._schedulePreview=[];this._schedulePreviewEnded=!1;this._previewSeq=0;this._notes="";this._documentationUrl="";this._customIcon="";this._priority="normal";this._labels="";this._enabled=!0;this._triggerEntityId="";this._triggerEntityIds=[];this._triggerEntityLogic="any";this._triggerAttribute="";this._triggerType="threshold";this._triggerAbove="";this._triggerBelow="";this._triggerForMinutes="0";this._triggerTargetValue="";this._triggerDeltaMode=!1;this._triggerBaselineValue="";this._liveBaselineValue=null;this._autoCompleteOnRecovery=!1;this._triggerFromState="";this._triggerToState="";this._triggerTargetChanges="";this._triggerRuntimeHours="";this._triggerOnStates="";this._compoundLogic="AND";this._compoundConditions=[];this._suggestedAttributes=[];this._availableAttributes=[];this._entityDomain="";this._lastPerformed="";this._nfcTagId="";this._readingUnit="";this._consumesParts={};this._partsLoadFailed=!1;this._availableTags=[];this._responsibleUserId=null;this._assigneePool=[];this._rotationStrategy="";this._availableUsers=[];this._checklistText="";this._scheduleTime="";this._actionService="";this._actionTargetEntity="";this._actionData={};this._actionDataJsonFallback="";this._actionTesting=!1;this._actionTestResult="";this._actionTestError="";this._qcNotes="";this._qcCost="";this._qcDuration="";this._qcFeedback="";this._environmentalEntity="";this._environmentalAttribute="";this._environmentalInitial="";this._environmentalAttributeInitial="";this._userService=null}get _lang(){return this.hass?.language??navigator.language.split("-")[0]??"en"}async openCreate(e,t){this._entryId=e,this._taskId=null,this._error="",!e&&t&&t.length>0?(this._objectChoices=t.map(i=>({entry_id:i.entry_id,name:i.object.name})).sort((i,a)=>i.name.localeCompare(a.name)),this._entryId=this._objectChoices[0].entry_id):this._objectChoices=[],this._resetFields(),await Promise.all([this._loadUsers(),this._loadTags(),this._loadParts()]),this._open=!0}async openEdit(e,t){this._entryId=e,this._taskId=t.id,this._error="",this._name=t.name,this._type=t.type,this._scheduleType=t.schedule_type,this._intervalDays=t.interval_days!=null?String(t.interval_days):"",this._intervalUnit=t.interval_unit||"days",this._dueDate=t.due_date||"";let i=t.schedule;this._weekdays=i?.kind==="weekdays"?[...i.weekdays??[]]:[],this._nth=i?.kind==="nth_weekday"?String(i.nth??1):"1",this._nthWeekday=i?.kind==="nth_weekday"?String(i.weekday??5):"5",this._domDay=i?.kind==="day_of_month"&&(i.day??1)>=1?String(i.day??1):"1",this._domLastDay=i?.kind==="day_of_month"&&i.day===-1,this._domBusiness=i?.kind==="day_of_month"&&i.business===!0,this._calOffset=i?.offset?String(i.offset):"0",this._seasonMonths=Array.isArray(i?.season_months)?[...i.season_months]:[];let a=i?.ends;a&&typeof a.count=="number"?(this._endsMode="count",this._endsCount=String(a.count),this._endsUntil=""):a&&typeof a.until=="string"?(this._endsMode="until",this._endsUntil=a.until,this._endsCount=""):(this._endsMode="never",this._endsCount="",this._endsUntil=""),this._warningDays=t.warning_days.toString(),this._earliestCompletionDays=t.earliest_completion_days!=null?String(t.earliest_completion_days):"",this._intervalAnchor=t.interval_anchor||"completion",this._notes=t.notes||"",this._documentationUrl=t.documentation_url||"",this._customIcon=t.custom_icon||"",this._priority=t.priority||"normal",this._labels=(t.labels||[]).join(", "),this._enabled=t.enabled!==!1,this._lastPerformed=t.last_performed||"",this._nfcTagId=t.nfc_tag_id||"",this._readingUnit=t.reading_unit||"",this._consumesParts=Object.fromEntries((t.consumes_parts||[]).map(u=>[u.part_id,u.quantity])),this._responsibleUserId=t.responsible_user_id||null,this._assigneePool=[...t.assignee_pool||[]],this._rotationStrategy=t.rotation_strategy||"",this._checklistText=(t.checklist||[]).join(` `),this._scheduleTime=t.schedule_time||"";let l=t.on_complete_action;if(l&&l.service){this._actionService=l.service;let u=l.target?.entity_id;this._actionTargetEntity=Array.isArray(u)?u[0]||"":u||"",this._actionData=l.data&&typeof l.data=="object"?{...l.data}:{},this._actionDataJsonFallback=""}else this._actionService="",this._actionTargetEntity="",this._actionData={},this._actionDataJsonFallback="";let c=t.quick_complete_defaults;this._qcNotes=c?.notes||"",this._qcCost=c?.cost!=null?String(c.cost):"",this._qcDuration=c?.duration!=null?String(c.duration):"",this._qcFeedback=c?.feedback||"";let p=t.adaptive_config||{};if(this._environmentalEntity=p.environmental_entity||"",this._environmentalAttribute=p.environmental_attribute||"",this._environmentalInitial=this._environmentalEntity,this._environmentalAttributeInitial=this._environmentalAttribute,t.trigger_config){let u=t.trigger_config;this._triggerEntityId=u.entity_id||u.entity_ids&&u.entity_ids[0]||"",this._triggerEntityIds=u.entity_ids||(u.entity_id?[u.entity_id]:[]),this._triggerEntityLogic=u.entity_logic||"any",this._triggerAttribute=u.attribute||"",this._triggerType=u.type||"threshold",this._triggerAbove=u.trigger_above?.toString()||"",this._triggerBelow=u.trigger_below?.toString()||"",this._triggerForMinutes=u.trigger_for_minutes?.toString()||"0",this._triggerTargetValue=u.trigger_target_value?.toString()||"",this._triggerDeltaMode=u.trigger_delta_mode||!1,this._triggerBaselineValue=u.trigger_baseline_value?.toString()||"",this._liveBaselineValue=t.trigger_baseline_value??null,this._autoCompleteOnRecovery=u.auto_complete_on_recovery||!1,this._triggerFromState=u.trigger_from_state||"",this._triggerToState=u.trigger_to_state||"",this._triggerTargetChanges=u.trigger_target_changes?.toString()||"",this._triggerRuntimeHours=u.trigger_runtime_hours?.toString()||"",this._triggerOnStates=(u.trigger_on_states||[]).join(", "),u.type==="compound"?(this._compoundLogic=u.compound_logic==="OR"?"OR":"AND",this._compoundConditions=(u.conditions||[]).map(Os)):(this._compoundLogic="AND",this._compoundConditions=[])}else this._resetTriggerFields();this._triggerEntityId&&this._fetchEntityAttributes(this._triggerEntityId),await Promise.all([this._loadUsers(),this._loadTags(),this._loadParts()]),this._open=!0}_resetFields(){this._name="",this._type="custom",this._scheduleType="time_based",this._intervalDays="30",this._intervalUnit="days",this._dueDate="",this._warningDays=String(this.defaultWarningDays),this._earliestCompletionDays="",this._intervalAnchor="completion",this._weekdays=[],this._nth="1",this._nthWeekday="5",this._domDay="1",this._domLastDay=!1,this._domBusiness=!1,this._calOffset="0",this._seasonMonths=[],this._endsMode="never",this._endsCount="",this._endsUntil="",this._notes="",this._documentationUrl="",this._customIcon="",this._priority="normal",this._labels="",this._enabled=!0,this._lastPerformed="",this._nfcTagId="",this._readingUnit="",this._consumesParts={},this._responsibleUserId=null,this._assigneePool=[],this._rotationStrategy="",this._checklistText="",this._scheduleTime="",this._environmentalEntity="",this._environmentalAttribute="",this._environmentalInitial="",this._environmentalAttributeInitial="",this._actionService="",this._actionTargetEntity="",this._actionData={},this._actionDataJsonFallback="",this._actionTesting=!1,this._actionTestResult="",this._qcNotes="",this._qcCost="",this._qcDuration="",this._qcFeedback="",this._resetTriggerFields()}_resetTriggerFields(){this._triggerEntityId="",this._triggerEntityIds=[],this._triggerEntityLogic="any",this._triggerAttribute="",this._suggestedAttributes=[],this._availableAttributes=[],this._entityDomain="",this._triggerType="threshold",this._triggerAbove="",this._triggerBelow="",this._triggerForMinutes="0",this._triggerTargetValue="",this._triggerDeltaMode=!1,this._triggerBaselineValue="",this._liveBaselineValue=null,this._autoCompleteOnRecovery=!1,this._triggerFromState="",this._triggerToState="",this._triggerTargetChanges="",this._triggerRuntimeHours="",this._triggerOnStates="",this._compoundLogic="AND",this._compoundConditions=[]}async _loadUsers(){this._userService||(this._userService=new pe(this.hass));try{this._availableUsers=await this._userService.getUsers()}catch(e){console.error("Failed to load users:",e),this._availableUsers=[]}}_toggleAssignee(e){this._assigneePool=this._assigneePool.includes(e)?this._assigneePool.filter(t=>t!==e):[...this._assigneePool,e]}async _testAction(){let e=this._actionService.trim();if(!e||!/^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(e)){this._actionTestResult="error",this._actionTestError="Invalid service format (expected 'domain.service')",setTimeout(()=>{this._actionTestResult="",this._actionTestError=""},5e3);return}let[t,i]=e.split(".");if(!this.hass?.services?.[t]?.[i]){this._actionTestResult="error",this._actionTestError=`Service "${e}" is not registered in Home Assistant. Check spelling and that the integration providing it is loaded.`,setTimeout(()=>{this._actionTestResult="",this._actionTestError=""},8e3);return}let a=this._actionTargetEntity.trim();if(a){let l=a.split(".")[0];if(l!==t&&!new Set(["homeassistant","scene","notify","persistent_notification"]).has(t)){this._actionTestResult="error",this._actionTestError=`Service "${e}" only works on ${t}.* entities; entity "${a}" is in ${l}.* \u2014 pick a service that matches the entity domain (e.g. ${l}.${i})`,setTimeout(()=>{this._actionTestResult="",this._actionTestError=""},8e3);return}if(!this.hass.states?.[a]){this._actionTestResult="error",this._actionTestError=`Target entity "${a}" not found in Home Assistant \u2014 the entity may have been renamed or its integration removed.`,setTimeout(()=>{this._actionTestResult="",this._actionTestError=""},8e3);return}}this._actionTestResult="ok",setTimeout(()=>{this._actionTestResult="",this._actionTestError=""},5e3)}_buildActionData(){if(this._actionDataJsonFallback.trim())try{let e=JSON.parse(this._actionDataJsonFallback);if(e&&typeof e=="object"&&!Array.isArray(e))return e}catch{}return{...this._actionData}}_serviceSchema(){let e=this._actionService.trim();if(!e||!/^[a-z][a-z0-9_]*\.[a-z0-9_]+$/.test(e))return null;let[t,i]=e.split("."),a=this.hass?.services?.[t]?.[i]?.fields;return!a||Object.keys(a).length===0?null:Object.entries(a).map(([l,c])=>({name:l,required:!!c.required,selector:c.selector||{text:{}}}))}_renderCompletionActionsSection(e){if(!this.completionActionsEnabled)return h;let t=this._serviceSchema();return o`
${s("on_complete_action_title",e)} @@ -4021,7 +4021,7 @@ ${H(n.notes)}
`:""} .value=${this._type} @change=${i=>this._type=i.target.value} > - ${Ms.map(i=>o``)} + ${Rs.map(i=>o``)}
${this._type==="reading"?o` @@ -5535,7 +5535,7 @@ ${p?`
${p}
`:""} font-size: 12px; color: var(--secondary-text-color); } - `,d([y({attribute:!1})],ve.prototype,"hass",2),d([_()],ve.prototype,"_ov",2),d([_()],ve.prototype,"_loading",2),d([_()],ve.prototype,"_marking",2),d([_()],ve.prototype,"_error",2);customElements.get("maintenance-battery-fleet-section")||customElements.define("maintenance-battery-fleet-section",ve);var Ai=5;function tt(n){let r=n.getFullYear(),e=String(n.getMonth()+1).padStart(2,"0"),t=String(n.getDate()).padStart(2,"0");return`${r}-${e}-${t}`}function Us(n,r){let e=[];for(let t=0;te.cost).filter(e=>typeof e=="number");return r.length===0?null:r.reduce((e,t)=>e+t,0)/r.length}function Vs(n){let{windowStart:r,windowEnd:e,task:t,entryId:i,objectName:a}=n,l=[],c=(m,v)=>({date:m,entry_id:i,task_id:t.id,task_name:t.name,object_name:a,status:v&&(t.status==="overdue"||t.status==="triggered")?"ok":t.status,days_until_due:v?null:t.days_until_due??null,projected:v,schedule_type:t.schedule_type,interval_days:t.interval_days??null,interval_unit:t.interval_unit??null,responsible_user_id:t.responsible_user_id??null,avg_cost:Bs(t.history),adaptive_enabled:!!t.adaptive_config?.enabled,prediction_confidence:t.threshold_prediction_confidence??null}),p=Math.max(1,Math.round(Wt(t.interval_days,t.interval_unit)));if(t.status==="overdue"||t.status==="triggered"){if(l.push(c(r,!1)),t.schedule_type==="time_based"&&t.interval_days&&t.interval_days>0){let m=Et(r,p),v=1;for(;m<=e&&v=r&&g<=e)l.push(c(g,!1));else if(g>e)return l;if(t.schedule_type==="time_based"&&t.interval_days&&t.interval_days>0){let m=Et(g,p),v=l.length;for(;m<=e&&v=r&&(l.push(c(m,!0)),v++),m=Et(m,p)}return l}var ji={overdue:0,triggered:1,due_soon:2,ok:3};function Ci(n,r,e,t=null){let i=Us(r,e),a=i[0],l=i[i.length-1],c=[];for(let u of n){let g=u.object?.name||"",m=u.entry_id,v=u.tasks||[];for(let b of v){if(t&&b.responsible_user_id!==t||b.enabled===!1)continue;let $=Vs({windowStart:a,windowEnd:l,task:b,entryId:m,objectName:g});c.push(...$)}}let p=new Map;for(let u of i)p.set(u,[]);for(let u of c){let g=p.get(u.date);g&&g.push(u)}for(let[,u]of p)u.sort((g,m)=>{let v=ji[g.status]??99,b=ji[m.status]??99;if(v!==b)return v-b;if(g.projected!==m.projected)return g.projected?1:-1;let $=g.object_name.localeCompare(m.object_name);return $!==0?$:g.task_name.localeCompare(m.task_name)});return i.map(u=>({date:u,events:p.get(u)??[]}))}var Ws={completed:"ok",reset:"ok",skipped:"due_soon",triggered:"triggered",trigger_replaced:"triggered",trigger_removed:"ok"};function Ks(n,r){let e=[];for(let t=r-1;t>=0;t--){let i=new Date(n);i.setDate(i.getDate()-t),i.setHours(0,0,0,0),e.push(tt(i))}return e}function Ri(n,r,e,t=null){let i=Ks(r,e),a=i[0],l=i[i.length-1],c=new Map;for(let u of i)c.set(u,[]);for(let u of n){let g=u.object?.name||"",m=u.entry_id,v=u.tasks||[];for(let b of v){if(t&&b.responsible_user_id!==t)continue;let $=b.history||[];for(let f of $){if(typeof f?.timestamp!="string")continue;let C=f.timestamp.slice(0,10);if(Cl)continue;let R=c.get(C);if(!R)continue;let L=f.type??"completed";R.push({date:C,entry_id:m,task_id:b.id,task_name:b.name,object_name:g,status:Ws[L]??"ok",days_until_due:null,projected:!1,schedule_type:b.schedule_type,interval_days:b.interval_days??null,responsible_user_id:b.responsible_user_id??null,avg_cost:typeof f.cost=="number"?f.cost:null,adaptive_enabled:!!b.adaptive_config?.enabled,prediction_confidence:null,history_timestamp:f.timestamp,history_type:L,history_cost:typeof f.cost=="number"?f.cost:null,history_notes:typeof f.notes=="string"?f.notes:null,history_duration:typeof f.duration=="number"?f.duration:null})}}}let p={completed:0,reset:1,skipped:2,triggered:3,trigger_replaced:4};for(let[,u]of c)u.sort((g,m)=>{let v=p[g.history_type??""]??99,b=p[m.history_type??""]??99;if(v!==b)return v-b;let $=g.object_name.localeCompare(m.object_name);return $!==0?$:g.task_name.localeCompare(m.task_name)});return i.map(u=>({date:u,events:c.get(u)??[]}))}var Mi=A` + `,d([y({attribute:!1})],ve.prototype,"hass",2),d([_()],ve.prototype,"_ov",2),d([_()],ve.prototype,"_loading",2),d([_()],ve.prototype,"_marking",2),d([_()],ve.prototype,"_error",2);customElements.get("maintenance-battery-fleet-section")||customElements.define("maintenance-battery-fleet-section",ve);var Ai=5;function tt(n){let r=n.getFullYear(),e=String(n.getMonth()+1).padStart(2,"0"),t=String(n.getDate()).padStart(2,"0");return`${r}-${e}-${t}`}function Us(n,r){let e=[];for(let t=0;te.cost).filter(e=>typeof e=="number");return r.length===0?null:r.reduce((e,t)=>e+t,0)/r.length}function Vs(n){let{windowStart:r,windowEnd:e,task:t,entryId:i,objectName:a}=n,l=[],c=(m,v)=>({date:m,entry_id:i,task_id:t.id,task_name:t.name,object_name:a,status:v&&(t.status==="overdue"||t.status==="triggered")?"ok":t.status,days_until_due:v?null:t.days_until_due??null,projected:v,schedule_type:t.schedule_type,interval_days:t.interval_days??null,interval_unit:t.interval_unit??null,responsible_user_id:t.responsible_user_id??null,avg_cost:Bs(t.history),adaptive_enabled:!!t.adaptive_config?.enabled,prediction_confidence:t.threshold_prediction_confidence??null}),p=Math.max(1,Math.round(Wt(t.interval_days,t.interval_unit)));if(t.status==="overdue"||t.status==="triggered"){if(l.push(c(r,!1)),t.schedule_type==="time_based"&&t.interval_days&&t.interval_days>0){let m=Et(r,p),v=1;for(;m<=e&&v=r&&g<=e)l.push(c(g,!1));else if(g>e)return l;if(t.schedule_type==="time_based"&&t.interval_days&&t.interval_days>0){let m=Et(g,p),v=l.length;for(;m<=e&&v=r&&(l.push(c(m,!0)),v++),m=Et(m,p)}return l}var ji={overdue:0,triggered:1,due_soon:2,ok:3};function Ci(n,r,e,t=null){let i=Us(r,e),a=i[0],l=i[i.length-1],c=[];for(let u of n){let g=u.object?.name||"",m=u.entry_id,v=u.tasks||[];for(let b of v){if(t&&b.responsible_user_id!==t||b.enabled===!1)continue;let $=Vs({windowStart:a,windowEnd:l,task:b,entryId:m,objectName:g});c.push(...$)}}let p=new Map;for(let u of i)p.set(u,[]);for(let u of c){let g=p.get(u.date);g&&g.push(u)}for(let[,u]of p)u.sort((g,m)=>{let v=ji[g.status]??99,b=ji[m.status]??99;if(v!==b)return v-b;if(g.projected!==m.projected)return g.projected?1:-1;let $=g.object_name.localeCompare(m.object_name);return $!==0?$:g.task_name.localeCompare(m.task_name)});return i.map(u=>({date:u,events:p.get(u)??[]}))}var Ws={completed:"ok",reset:"ok",skipped:"due_soon",triggered:"triggered",trigger_replaced:"triggered",trigger_removed:"ok"};function Ks(n,r){let e=[];for(let t=r-1;t>=0;t--){let i=new Date(n);i.setDate(i.getDate()-t),i.setHours(0,0,0,0),e.push(tt(i))}return e}function Mi(n,r,e,t=null){let i=Ks(r,e),a=i[0],l=i[i.length-1],c=new Map;for(let u of i)c.set(u,[]);for(let u of n){let g=u.object?.name||"",m=u.entry_id,v=u.tasks||[];for(let b of v){if(t&&b.responsible_user_id!==t)continue;let $=b.history||[];for(let f of $){if(typeof f?.timestamp!="string")continue;let C=f.timestamp.slice(0,10);if(Cl)continue;let M=c.get(C);if(!M)continue;let L=f.type??"completed";M.push({date:C,entry_id:m,task_id:b.id,task_name:b.name,object_name:g,status:Ws[L]??"ok",days_until_due:null,projected:!1,schedule_type:b.schedule_type,interval_days:b.interval_days??null,responsible_user_id:b.responsible_user_id??null,avg_cost:typeof f.cost=="number"?f.cost:null,adaptive_enabled:!!b.adaptive_config?.enabled,prediction_confidence:null,history_timestamp:f.timestamp,history_type:L,history_cost:typeof f.cost=="number"?f.cost:null,history_notes:typeof f.notes=="string"?f.notes:null,history_duration:typeof f.duration=="number"?f.duration:null})}}}let p={completed:0,reset:1,skipped:2,triggered:3,trigger_replaced:4};for(let[,u]of c)u.sort((g,m)=>{let v=p[g.history_type??""]??99,b=p[m.history_type??""]??99;if(v!==b)return v-b;let $=g.object_name.localeCompare(m.object_name);return $!==0?$:g.task_name.localeCompare(m.task_name)});return i.map(u=>({date:u,events:c.get(u)??[]}))}var Ri=A` .cal-controls { display: flex; gap: 12px; @@ -5732,13 +5732,13 @@ ${p?`
${p}
`:""} .cal-pill-day { font-size: 17px; } .cal-user-filter { margin-left: 0; width: 100%; } } -`;var oe=class extends S{constructor(){super(...arguments);this._config={type:"custom:maintenance-supporter-calendar-card"};this._objects=[];this._stats=null;this._windowDays=30;this._pastDays=0;this._userFilter="";this._unsub=null;this._dataLoaded=!1;this._lastConnection=null}static getConfigElement(){return document.createElement("maintenance-supporter-calendar-card-editor")}static getStubConfig(){return{type:"custom:maintenance-supporter-calendar-card",window_days:30,show_window_chips:!0,show_user_filter:!0}}setConfig(e){this._config={...e},e.past_days&&[30,90].includes(e.past_days)?this._pastDays=e.past_days:e.window_days&&[7,14,30,365].includes(e.window_days)&&(this._windowDays=e.window_days,this._pastDays=0),typeof e.user_filter=="string"&&(this._userFilter=e.user_filter)}getCardSize(){return 6}get _lang(){return this.hass?.language||"en"}disconnectedCallback(){if(super.disconnectedCallback(),this._unsub){try{this._unsub()}catch{}this._unsub=null}this._dataLoaded=!1,this._lastConnection=null}updated(e){super.updated(e),e.has("hass")&>(this.hass?.locale);let t=this.hass?.language;if(t&&!_t(t)&&V(t).then(()=>this.requestUpdate()),e.has("hass")&&this.hass){if(!this._dataLoaded)this._dataLoaded=!0,this._lastConnection=this.hass.connection,this._loadData(),this._subscribe();else if(this.hass.connection!==this._lastConnection){if(this._lastConnection=this.hass.connection,this._unsub){try{this._unsub()}catch{}this._unsub=null}this._subscribe(),this._loadData()}}}async _loadData(){try{let[e,t]=await Promise.all([this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"}),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/statistics"})]);this._objects=e.objects,this._stats=t}catch{}}async _subscribe(){try{let e=await this.hass.connection.subscribeMessage(t=>{let i=t;this._objects=i.objects},{type:"maintenance_supporter/subscribe"});if(!this.isConnected){e();return}this._unsub=e}catch{}}_onEventClick(e){if(e.history_timestamp){this.dispatchEvent(new CustomEvent("ll-custom",{detail:{type:"maintenance-supporter:edit-history",entry_id:e.entry_id,task_id:e.task_id,original_timestamp:e.history_timestamp},bubbles:!0,composed:!0}));return}this.dispatchEvent(new CustomEvent("ll-custom",{detail:{type:"maintenance-supporter:open-task",entry_id:e.entry_id,task_id:e.task_id},bubbles:!0,composed:!0}))}render(){if(!this.hass)return h;let e=this._lang,t=this._config.show_window_chips!==!1,i=this._config.show_user_filter!==!1,a=this._config.title,l=null;this._userFilter&&(l=this._userFilter==="current_user"?this.hass?.user?.id??null:this._userFilter);let c=new Date;c.setHours(0,0,0,0);let p=this._pastDays>0,u=p?Ri(this._objects,c,this._pastDays,l):Ci(this._objects,c,this._windowDays,l),g=tt(c),m=this._windowDays===365||p,v=m?u.filter(f=>f.events.length>0):u,b=f=>{let C=`cal-status-${f.status}`,R=f.projected?"cal-event-projected":"",L=f.status==="overdue"&&f.days_until_due!=null?` (${Math.abs(f.days_until_due)}d ${s("overdue",e).toLowerCase()})`:"",I=f.projected&&f.interval_days?o`${f.interval_unit&&f.interval_unit!=="days"?`${f.interval_days} ${s("unit_"+f.interval_unit,e)}`:s("cal_every_n_days",e).replace("{n}",String(f.interval_days))}`:h,T=f.schedule_type==="sensor_based",ee=T?o`this.requestUpdate()),e.has("hass")&&this.hass){if(!this._dataLoaded)this._dataLoaded=!0,this._lastConnection=this.hass.connection,this._loadData(),this._subscribe();else if(this.hass.connection!==this._lastConnection){if(this._lastConnection=this.hass.connection,this._unsub){try{this._unsub()}catch{}this._unsub=null}this._subscribe(),this._loadData()}}}async _loadData(){try{let[e,t]=await Promise.all([this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"}),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/statistics"})]);this._objects=e.objects,this._stats=t}catch{}}async _subscribe(){try{let e=await this.hass.connection.subscribeMessage(t=>{let i=t;this._objects=i.objects},{type:"maintenance_supporter/subscribe"});if(!this.isConnected){e();return}this._unsub=e}catch{}}_onEventClick(e){if(e.history_timestamp){this.dispatchEvent(new CustomEvent("ll-custom",{detail:{type:"maintenance-supporter:edit-history",entry_id:e.entry_id,task_id:e.task_id,original_timestamp:e.history_timestamp},bubbles:!0,composed:!0}));return}this.dispatchEvent(new CustomEvent("ll-custom",{detail:{type:"maintenance-supporter:open-task",entry_id:e.entry_id,task_id:e.task_id},bubbles:!0,composed:!0}))}render(){if(!this.hass)return h;let e=this._lang,t=this._config.show_window_chips!==!1,i=this._config.show_user_filter!==!1,a=this._config.title,l=null;this._userFilter&&(l=this._userFilter==="current_user"?this.hass?.user?.id??null:this._userFilter);let c=new Date;c.setHours(0,0,0,0);let p=this._pastDays>0,u=p?Mi(this._objects,c,this._pastDays,l):Ci(this._objects,c,this._windowDays,l),g=tt(c),m=this._windowDays===365||p,v=m?u.filter(f=>f.events.length>0):u,b=f=>{let C=`cal-status-${f.status}`,M=f.projected?"cal-event-projected":"",L=f.status==="overdue"&&f.days_until_due!=null?` (${Math.abs(f.days_until_due)}d ${s("overdue",e).toLowerCase()})`:"",I=f.projected&&f.interval_days?o`${f.interval_unit&&f.interval_unit!=="days"?`${f.interval_days} ${s("unit_"+f.interval_unit,e)}`:s("cal_every_n_days",e).replace("{n}",String(f.interval_days))}`:h,T=f.schedule_type==="sensor_based",ee=T?o``:o``,te=T&&f.prediction_confidence&&f.status!=="triggered"&&!f.projected?o` ${s("cal_predicted",e)} · ${s(`cal_confidence_${f.prediction_confidence}`,e)} `:h,Z=this._stats?.budget?.currency_symbol||je,U=f.history_type?s(f.history_type,e):s(f.status,e);return o` -
this._onEventClick(f)}> ${ee} ${U} @@ -5749,7 +5749,7 @@ ${p?`
${p}
`:""}
${f.avg_cost!=null&&f.avg_cost>0?o`${f.avg_cost.toFixed(0)} ${Z}`:h}
- `},$=f=>{let[C,R,L]=f.date.split("-").map(Number),I=new Date(C,R-1,L),T=f.date===g,ee=I.toLocaleDateString(e,{weekday:"short"}),te=I.toLocaleDateString(e,{month:"long"});return o` + `},$=f=>{let[C,M,L]=f.date.split("-").map(Number),I=new Date(C,M-1,L),T=f.date===g,ee=I.toLocaleDateString(e,{weekday:"short"}),te=I.toLocaleDateString(e,{month:"long"});return o`
${ee} @@ -5800,7 +5800,7 @@ ${p?`
${p}
`:""} ${v.length===0&&m?o`
${s("cal_no_events",e)}
`:v.map($)}
- `}};oe.styles=[bt,Mi,A` + `}};oe.styles=[bt,Ri,A` :host { display: block; } ha-card { padding: 0; overflow: hidden; } `],d([y({attribute:!1})],oe.prototype,"hass",2),d([_()],oe.prototype,"_config",2),d([_()],oe.prototype,"_objects",2),d([_()],oe.prototype,"_stats",2),d([_()],oe.prototype,"_windowDays",2),d([_()],oe.prototype,"_pastDays",2),d([_()],oe.prototype,"_userFilter",2),d([_()],oe.prototype,"_unsub",2);var Ys=[{value:7,label:"Week (7 days)"},{value:14,label:"Fortnight (14 days)"},{value:30,label:"Month (30 days, default)"},{value:365,label:"Year (365 days, empty days collapsed)"}],Oe=class extends S{constructor(){super(...arguments);this._config={type:"custom:maintenance-supporter-calendar-card"}}setConfig(e){this._config={...e}}_valueChanged(e,t){let i={...this._config,[e]:t};e==="show_window_chips"&&t===!0&&delete i.show_window_chips,e==="show_user_filter"&&t===!0&&delete i.show_user_filter,e==="title"&&(!t||typeof t=="string"&&t.trim()==="")&&delete i.title,e==="user_filter"&&t===""&&delete i.user_filter,this._config=i,this.dispatchEvent(new CustomEvent("config-changed",{detail:{config:i},bubbles:!0,composed:!0}))}render(){let e=this._config.window_days??30,t=this._config.show_window_chips!==!1,i=this._config.show_user_filter!==!1,a=this._config.user_filter??"",l=this._config.title??"";return o` @@ -6050,7 +6050,7 @@ ${p?`
${p}
`:""} ha-button.danger { --mdc-theme-primary: var(--error-color, #f44336); } - `],d([y({attribute:!1})],se.prototype,"hass",2),d([_()],se.prototype,"_open",2),d([_()],se.prototype,"_title",2),d([_()],se.prototype,"_message",2),d([_()],se.prototype,"_confirmText",2),d([_()],se.prototype,"_danger",2),d([_()],se.prototype,"_inputLabel",2),d([_()],se.prototype,"_inputType",2),d([_()],se.prototype,"_inputValue",2);customElements.get("maintenance-confirm-dialog")||customElements.define("maintenance-confirm-dialog",se);var Li={ATTRIBUTE:1,CHILD:2,PROPERTY:3,BOOLEAN_ATTRIBUTE:4,EVENT:5,ELEMENT:6},Pi=n=>(...r)=>({_$litDirective$:n,values:r}),St=class{constructor(r){}get _$AU(){return this._$AM._$AU}_$AT(r,e,t){this._$Ct=r,this._$AM=e,this._$Ci=t}_$AS(r,e){return this.update(r,e)}update(r,e){return this.render(...e)}};var it=class extends St{constructor(r){if(super(r),this.it=h,r.type!==Li.CHILD)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(r){if(r===h||r==null)return this._t=void 0,this.it=r;if(r===me)return r;if(typeof r!="string")throw Error(this.constructor.directiveName+"() called with a non-string value");if(r===this.it)return this._t;this.it=r;let e=[r];return e.raw=e,this._t={_$litType$:this.constructor.resultType,strings:e,values:[]}}};it.directiveName="unsafeHTML",it.resultType=1;var zi=Pi(it);var Qs=["EUR","USD","GBP","JPY","CHF","CAD","AUD","NZD","CNY","INR","BRL","CZK","PLN","RUB","SEK","NOK","DKK","UAH"],M=class extends S{constructor(){super(...arguments);this.budget=null;this._settings=null;this._loading=!0;this._importCsv="";this._importLoading=!1;this._includeHistory=!0;this._toast="";this._testingNotification=!1;this._users=[];this._savedViews=[];this._vacEnabled=!1;this._vacStart="";this._vacEnd="";this._vacBuffer=3;this._vacExempt=new Set;this._vacIsActive=!1;this._vacWindowEnd=null;this._vacAllTasks=[];this._vacPreview=[];this._vacPreviewLoading=!1;this._vacSaving=!1;this._qrObjects=[];this._qrSelectedEntries=new Set;this._qrActions=new Set(["view"]);this._qrUrlMode="companion";this._qrBatchLoading=!1;this._qrBatchResults=[];this._qrObjectsLoaded=!1;this._exportObjects=[];this._exportSelectedEntries=new Set;this._exportObjectsLoaded=!1;this._docArchiveLoading=!1;this._loaded=!1;this._userService=null;this._sendTestNotification=async()=>{this._testingNotification=!0;try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/global/test_notification"}),t=e.message||(e.success?s("test_notification_success",this._lang):s("test_notification_failed",this._lang));this._showToast(t)}catch{this._showToast(s("test_notification_failed",this._lang))}finally{this._testingNotification=!1}};this._allTemplates=[];this._templateCategories={};this._tplOpenGroups=new Set;this._templatesRequested=!1}get _lang(){return this.hass?.language||"en"}updated(e){super.updated(e),e.has("hass")&&this.hass&&!this._loaded?(this._loaded=!0,this._userService=new pe(this.hass),this._loadSettings(),this._loadUsers()):e.has("hass")&&this.hass&&this._userService&&this._userService.updateHass(this.hass)}async _loadUsers(){if(this._userService)try{this._users=await this._userService.getUsers()}catch{this._users=[]}}async _loadSettings(){this._loading=!0;try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/settings"});this._settings=e,this._hydrateVacationFromSettings()}catch{}try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/views/list"});this._savedViews=e.views||[]}catch{}this._loading=!1}_hydrateVacationFromSettings(){let e=this._settings?.vacation;e&&(this._vacEnabled=e.enabled,this._vacStart=e.start||"",this._vacEnd=e.end||"",this._vacBuffer=e.buffer_days,this._vacExempt=new Set(e.exempt_task_ids||[]),this._vacIsActive=e.is_active,this._vacWindowEnd=e.window_end)}async _updateSetting(e,t){try{let i=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/global/update",settings:{[e]:t}});this._settings=i,this._showToast(s("settings_saved",this._lang)),this.dispatchEvent(new CustomEvent("settings-changed"))}catch{this._showToast(s("action_error",this._lang))}}_showToast(e){this._toast=e,setTimeout(()=>{this._toast=""},3e3)}_downloadFile(e,t,i){xt(e,t,i)}render(){let e=this._lang;return this._loading||!this._settings?o`
Loading…
`:o` + `],d([y({attribute:!1})],se.prototype,"hass",2),d([_()],se.prototype,"_open",2),d([_()],se.prototype,"_title",2),d([_()],se.prototype,"_message",2),d([_()],se.prototype,"_confirmText",2),d([_()],se.prototype,"_danger",2),d([_()],se.prototype,"_inputLabel",2),d([_()],se.prototype,"_inputType",2),d([_()],se.prototype,"_inputValue",2);customElements.get("maintenance-confirm-dialog")||customElements.define("maintenance-confirm-dialog",se);var Li={ATTRIBUTE:1,CHILD:2,PROPERTY:3,BOOLEAN_ATTRIBUTE:4,EVENT:5,ELEMENT:6},Pi=n=>(...r)=>({_$litDirective$:n,values:r}),St=class{constructor(r){}get _$AU(){return this._$AM._$AU}_$AT(r,e,t){this._$Ct=r,this._$AM=e,this._$Ci=t}_$AS(r,e){return this.update(r,e)}update(r,e){return this.render(...e)}};var it=class extends St{constructor(r){if(super(r),this.it=h,r.type!==Li.CHILD)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(r){if(r===h||r==null)return this._t=void 0,this.it=r;if(r===me)return r;if(typeof r!="string")throw Error(this.constructor.directiveName+"() called with a non-string value");if(r===this.it)return this._t;this.it=r;let e=[r];return e.raw=e,this._t={_$litType$:this.constructor.resultType,strings:e,values:[]}}};it.directiveName="unsafeHTML",it.resultType=1;var zi=Pi(it);var Qs=["EUR","USD","GBP","JPY","CHF","CAD","AUD","NZD","CNY","INR","BRL","CZK","PLN","RUB","SEK","NOK","DKK","UAH"],R=class extends S{constructor(){super(...arguments);this.budget=null;this._settings=null;this._loading=!0;this._importCsv="";this._importLoading=!1;this._includeHistory=!0;this._toast="";this._testingNotification=!1;this._users=[];this._savedViews=[];this._vacEnabled=!1;this._vacStart="";this._vacEnd="";this._vacBuffer=3;this._vacExempt=new Set;this._vacIsActive=!1;this._vacWindowEnd=null;this._vacAllTasks=[];this._vacPreview=[];this._vacPreviewLoading=!1;this._vacSaving=!1;this._qrObjects=[];this._qrSelectedEntries=new Set;this._qrActions=new Set(["view"]);this._qrUrlMode="companion";this._qrBatchLoading=!1;this._qrBatchResults=[];this._qrObjectsLoaded=!1;this._exportObjects=[];this._exportSelectedEntries=new Set;this._exportObjectsLoaded=!1;this._docArchiveLoading=!1;this._loaded=!1;this._userService=null;this._sendTestNotification=async()=>{this._testingNotification=!0;try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/global/test_notification"}),t=e.message||(e.success?s("test_notification_success",this._lang):s("test_notification_failed",this._lang));this._showToast(t)}catch{this._showToast(s("test_notification_failed",this._lang))}finally{this._testingNotification=!1}};this._allTemplates=[];this._templateCategories={};this._tplOpenGroups=new Set;this._templatesRequested=!1}get _lang(){return this.hass?.language||"en"}updated(e){super.updated(e),e.has("hass")&&this.hass&&!this._loaded?(this._loaded=!0,this._userService=new pe(this.hass),this._loadSettings(),this._loadUsers()):e.has("hass")&&this.hass&&this._userService&&this._userService.updateHass(this.hass)}async _loadUsers(){if(this._userService)try{this._users=await this._userService.getUsers()}catch{this._users=[]}}async _loadSettings(){this._loading=!0;try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/settings"});this._settings=e,this._hydrateVacationFromSettings()}catch{}try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/views/list"});this._savedViews=e.views||[]}catch{}this._loading=!1}_hydrateVacationFromSettings(){let e=this._settings?.vacation;e&&(this._vacEnabled=e.enabled,this._vacStart=e.start||"",this._vacEnd=e.end||"",this._vacBuffer=e.buffer_days,this._vacExempt=new Set(e.exempt_task_ids||[]),this._vacIsActive=e.is_active,this._vacWindowEnd=e.window_end)}async _updateSetting(e,t){try{let i=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/global/update",settings:{[e]:t}});this._settings=i,this._showToast(s("settings_saved",this._lang)),this.dispatchEvent(new CustomEvent("settings-changed"))}catch{this._showToast(s("action_error",this._lang))}}_showToast(e){this._toast=e,setTimeout(()=>{this._toast=""},3e3)}_downloadFile(e,t,i){xt(e,t,i)}render(){let e=this._lang;return this._loading||!this._settings?o`
Loading…
`:o` ${this._renderFeatures(e)} ${this._renderPanelAccess(e)} ${this._renderGeneral(e)} @@ -6675,7 +6675,7 @@ ${p?`
${p}
`:""}
- `}get _selectedEntryIds(){return this._exportSelectedEntries.size?[...this._exportSelectedEntries]:void 0}async _loadExportObjects(){try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"});this._exportObjects=(e.objects||[]).map(t=>({entry_id:t.entry_id,name:t.object.name,task_count:(t.tasks||[]).length})).sort((t,i)=>t.name.localeCompare(i.name)),this._exportObjectsLoaded=!0}catch{this._showToast(s("action_error",this._lang))}}_toggleExportObject(e,t){let i=new Set(this._exportSelectedEntries);if(i.size===0)for(let a of this._exportObjects)i.add(a.entry_id);t?i.add(e):i.delete(e),i.size===this._exportObjects.length&&i.clear(),this._exportSelectedEntries=i}async _exportJson(){try{let e=this._selectedEntryIds,t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/export",format:"json",include_history:this._includeHistory,...e?{entry_ids:e}:{}}),i=new Date().toISOString().slice(0,10);this._downloadFile(t.data,`maintenance_export_${i}.json`,"application/json"),this._showToast(s("settings_export_success",this._lang))}catch{this._showToast(s("action_error",this._lang))}}async _exportYaml(){try{let e=this._selectedEntryIds,t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/export",format:"yaml",include_history:this._includeHistory,...e?{entry_ids:e}:{}}),i=new Date().toISOString().slice(0,10);this._downloadFile(t.data,`maintenance_export_${i}.yaml`,"application/yaml"),this._showToast(s("settings_export_success",this._lang))}catch{this._showToast(s("action_error",this._lang))}}async _exportCsv(){try{let e=this._selectedEntryIds,t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/csv/export",...e?{entry_ids:e}:{}}),i=new Date().toISOString().slice(0,10);this._downloadFile(t.csv,`maintenance_export_${i}.csv`,"text/csv"),this._showToast(s("settings_export_success",this._lang))}catch{this._showToast(s("action_error",this._lang))}}async _importCsvAction(){let e=this._importCsv.trim();if(e){this._importLoading=!0;try{let t=e.startsWith("object_name"),a=(await this.hass.connection.sendMessagePromise(t?{type:"maintenance_supporter/csv/import",csv_content:e}:{type:"maintenance_supporter/json/import",json_content:e})).created??0;this._showToast(s("settings_import_success",this._lang).replace("{count}",String(a))),this._importCsv="",this.dispatchEvent(new CustomEvent("settings-changed"))}catch{this._showToast(s("action_error",this._lang))}this._importLoading=!1}}async _exportDocsArchive(){this._docArchiveLoading=!0;try{let e=this._selectedEntryIds,t=e?`?entry_ids=${encodeURIComponent(e.join(","))}`:"",i=await this.hass.connection.sendMessagePromise({type:"auth/sign_path",path:`/api/maintenance_supporter/documents/archive${t}`,expires:300}),a=document.createElement("a");a.href=i.path,a.download="maintenance-documents.zip",document.body.appendChild(a),a.click(),a.remove()}catch{this._showToast(s("action_error",this._lang))}this._docArchiveLoading=!1}_triggerDocsArchiveImport(){this.renderRoot.querySelector(".docs-archive-file")?.click()}async _importDocsArchive(e){let t=e.target,i=t.files?.[0];if(i){this._docArchiveLoading=!0;try{let a=new FormData;a.append("file",i,i.name);let l=await fetch("/api/maintenance_supporter/documents/archive",{method:"POST",headers:{Authorization:`Bearer ${this.hass.auth?.data?.access_token??""}`},body:a});if(!l.ok)this._showToast(s("action_error",this._lang));else{let c=await l.json();this._showToast(s("settings_docs_import_success",this._lang).replace("{blobs}",String(c.blobs_written??0)).replace("{docs}",String(c.documents_created??0))),this.dispatchEvent(new CustomEvent("settings-changed"))}}catch{this._showToast(s("action_error",this._lang))}t.value="",this._docArchiveLoading=!1}}};M.styles=A` + `}get _selectedEntryIds(){return this._exportSelectedEntries.size?[...this._exportSelectedEntries]:void 0}async _loadExportObjects(){try{let e=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"});this._exportObjects=(e.objects||[]).map(t=>({entry_id:t.entry_id,name:t.object.name,task_count:(t.tasks||[]).length})).sort((t,i)=>t.name.localeCompare(i.name)),this._exportObjectsLoaded=!0}catch{this._showToast(s("action_error",this._lang))}}_toggleExportObject(e,t){let i=new Set(this._exportSelectedEntries);if(i.size===0)for(let a of this._exportObjects)i.add(a.entry_id);t?i.add(e):i.delete(e),i.size===this._exportObjects.length&&i.clear(),this._exportSelectedEntries=i}async _exportJson(){try{let e=this._selectedEntryIds,t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/export",format:"json",include_history:this._includeHistory,...e?{entry_ids:e}:{}}),i=new Date().toISOString().slice(0,10);this._downloadFile(t.data,`maintenance_export_${i}.json`,"application/json"),this._showToast(s("settings_export_success",this._lang))}catch{this._showToast(s("action_error",this._lang))}}async _exportYaml(){try{let e=this._selectedEntryIds,t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/export",format:"yaml",include_history:this._includeHistory,...e?{entry_ids:e}:{}}),i=new Date().toISOString().slice(0,10);this._downloadFile(t.data,`maintenance_export_${i}.yaml`,"application/yaml"),this._showToast(s("settings_export_success",this._lang))}catch{this._showToast(s("action_error",this._lang))}}async _exportCsv(){try{let e=this._selectedEntryIds,t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/csv/export",...e?{entry_ids:e}:{}}),i=new Date().toISOString().slice(0,10);this._downloadFile(t.csv,`maintenance_export_${i}.csv`,"text/csv"),this._showToast(s("settings_export_success",this._lang))}catch{this._showToast(s("action_error",this._lang))}}async _importCsvAction(){let e=this._importCsv.trim();if(e){this._importLoading=!0;try{let t=e.startsWith("object_name"),a=(await this.hass.connection.sendMessagePromise(t?{type:"maintenance_supporter/csv/import",csv_content:e}:{type:"maintenance_supporter/json/import",json_content:e})).created??0;this._showToast(s("settings_import_success",this._lang).replace("{count}",String(a))),this._importCsv="",this.dispatchEvent(new CustomEvent("settings-changed"))}catch{this._showToast(s("action_error",this._lang))}this._importLoading=!1}}async _exportDocsArchive(){this._docArchiveLoading=!0;try{let e=this._selectedEntryIds,t=e?`?entry_ids=${encodeURIComponent(e.join(","))}`:"",i=await this.hass.connection.sendMessagePromise({type:"auth/sign_path",path:`/api/maintenance_supporter/documents/archive${t}`,expires:300}),a=document.createElement("a");a.href=i.path,a.download="maintenance-documents.zip",document.body.appendChild(a),a.click(),a.remove()}catch{this._showToast(s("action_error",this._lang))}this._docArchiveLoading=!1}_triggerDocsArchiveImport(){this.renderRoot.querySelector(".docs-archive-file")?.click()}async _importDocsArchive(e){let t=e.target,i=t.files?.[0];if(i){this._docArchiveLoading=!0;try{let a=new FormData;a.append("file",i,i.name);let l=await fetch("/api/maintenance_supporter/documents/archive",{method:"POST",headers:{Authorization:`Bearer ${this.hass.auth?.data?.access_token??""}`},body:a});if(!l.ok)this._showToast(s("action_error",this._lang));else{let c=await l.json();this._showToast(s("settings_docs_import_success",this._lang).replace("{blobs}",String(c.blobs_written??0)).replace("{docs}",String(c.documents_created??0))),this.dispatchEvent(new CustomEvent("settings-changed"))}}catch{this._showToast(s("action_error",this._lang))}t.value="",this._docArchiveLoading=!1}}};R.styles=A` :host { display: block; } .settings-loading { @@ -7130,7 +7130,7 @@ ${p?`
${p}
`:""} .qr-print-cell .qr-svg { max-width: 48mm; } .qr-label { font-size: 9pt; } } - `,d([y({attribute:!1})],M.prototype,"hass",2),d([y({attribute:!1})],M.prototype,"features",2),d([y({attribute:!1})],M.prototype,"budget",2),d([_()],M.prototype,"_settings",2),d([_()],M.prototype,"_loading",2),d([_()],M.prototype,"_importCsv",2),d([_()],M.prototype,"_importLoading",2),d([_()],M.prototype,"_includeHistory",2),d([_()],M.prototype,"_toast",2),d([_()],M.prototype,"_testingNotification",2),d([_()],M.prototype,"_users",2),d([_()],M.prototype,"_savedViews",2),d([_()],M.prototype,"_vacEnabled",2),d([_()],M.prototype,"_vacStart",2),d([_()],M.prototype,"_vacEnd",2),d([_()],M.prototype,"_vacBuffer",2),d([_()],M.prototype,"_vacExempt",2),d([_()],M.prototype,"_vacIsActive",2),d([_()],M.prototype,"_vacWindowEnd",2),d([_()],M.prototype,"_vacAllTasks",2),d([_()],M.prototype,"_vacPreview",2),d([_()],M.prototype,"_vacPreviewLoading",2),d([_()],M.prototype,"_vacSaving",2),d([_()],M.prototype,"_qrObjects",2),d([_()],M.prototype,"_qrSelectedEntries",2),d([_()],M.prototype,"_qrActions",2),d([_()],M.prototype,"_qrUrlMode",2),d([_()],M.prototype,"_qrBatchLoading",2),d([_()],M.prototype,"_qrBatchResults",2),d([_()],M.prototype,"_qrObjectsLoaded",2),d([_()],M.prototype,"_exportObjects",2),d([_()],M.prototype,"_exportSelectedEntries",2),d([_()],M.prototype,"_exportObjectsLoaded",2),d([_()],M.prototype,"_docArchiveLoading",2),d([_()],M.prototype,"_allTemplates",2),d([_()],M.prototype,"_templateCategories",2),d([_()],M.prototype,"_tplOpenGroups",2);customElements.define("maintenance-settings-view",M);var ae=class extends S{constructor(){super(...arguments);this.objects=[];this._summary=null;this._loaded=!1;this._busy=!1;this._error="";this._query="";this._results=[];this._expanded=!1;this._initiallyLoaded=!1;this._searchTimer=0}get _lang(){return this.hass?.language||"en"}updated(e){super.updated(e),e.has("hass")&&this.hass&&!this._initiallyLoaded&&(this._initiallyLoaded=!0,this._load(),V(this._lang).then(()=>this.requestUpdate()))}async _load(){this._busy=!0;try{this._summary=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/documents/storage"}),this._error=""}catch(e){this._error=j(e,this._lang)}finally{this._loaded=!0,this._busy=!1}}_nameFor(e){return this.objects.find(i=>i.object?.id===e)?.object?.name||e.slice(0,8)}_entryFor(e){return this.objects.find(t=>t.object?.id===e)?.entry_id}_toggle(){this._expanded=!this._expanded}_openObject(e){this.dispatchEvent(new CustomEvent("open-object",{detail:{entry_id:e},bubbles:!0,composed:!0}))}_onSearch(e){this._query=e.target.value,clearTimeout(this._searchTimer),this._searchTimer=window.setTimeout(()=>{this._doSearch()},250)}async _doSearch(){let e=this._query.trim();if(!e){this._results=[];return}try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/documents/search",query:e});this._results=t.results||[]}catch(t){this._error=j(t,this._lang),this._results=[]}}async _openResult(e){if(e.kind==="weblink"){window.open(e.url,"_blank","noopener");return}let t=window.open("about:blank","_blank");try{let i=await this.hass.connection.sendMessagePromise({type:"auth/sign_path",path:`/api/maintenance_supporter/document/${e.id}`,expires:300});t&&(t.location.href=new URL(i.path,window.location.origin).href)}catch(i){t&&t.close(),this._error=j(i,this._lang)}}_renderResult(e,t){return o` + `,d([y({attribute:!1})],R.prototype,"hass",2),d([y({attribute:!1})],R.prototype,"features",2),d([y({attribute:!1})],R.prototype,"budget",2),d([_()],R.prototype,"_settings",2),d([_()],R.prototype,"_loading",2),d([_()],R.prototype,"_importCsv",2),d([_()],R.prototype,"_importLoading",2),d([_()],R.prototype,"_includeHistory",2),d([_()],R.prototype,"_toast",2),d([_()],R.prototype,"_testingNotification",2),d([_()],R.prototype,"_users",2),d([_()],R.prototype,"_savedViews",2),d([_()],R.prototype,"_vacEnabled",2),d([_()],R.prototype,"_vacStart",2),d([_()],R.prototype,"_vacEnd",2),d([_()],R.prototype,"_vacBuffer",2),d([_()],R.prototype,"_vacExempt",2),d([_()],R.prototype,"_vacIsActive",2),d([_()],R.prototype,"_vacWindowEnd",2),d([_()],R.prototype,"_vacAllTasks",2),d([_()],R.prototype,"_vacPreview",2),d([_()],R.prototype,"_vacPreviewLoading",2),d([_()],R.prototype,"_vacSaving",2),d([_()],R.prototype,"_qrObjects",2),d([_()],R.prototype,"_qrSelectedEntries",2),d([_()],R.prototype,"_qrActions",2),d([_()],R.prototype,"_qrUrlMode",2),d([_()],R.prototype,"_qrBatchLoading",2),d([_()],R.prototype,"_qrBatchResults",2),d([_()],R.prototype,"_qrObjectsLoaded",2),d([_()],R.prototype,"_exportObjects",2),d([_()],R.prototype,"_exportSelectedEntries",2),d([_()],R.prototype,"_exportObjectsLoaded",2),d([_()],R.prototype,"_docArchiveLoading",2),d([_()],R.prototype,"_allTemplates",2),d([_()],R.prototype,"_templateCategories",2),d([_()],R.prototype,"_tplOpenGroups",2);customElements.define("maintenance-settings-view",R);var ae=class extends S{constructor(){super(...arguments);this.objects=[];this._summary=null;this._loaded=!1;this._busy=!1;this._error="";this._query="";this._results=[];this._expanded=!1;this._initiallyLoaded=!1;this._searchTimer=0}get _lang(){return this.hass?.language||"en"}updated(e){super.updated(e),e.has("hass")&&this.hass&&!this._initiallyLoaded&&(this._initiallyLoaded=!0,this._load(),V(this._lang).then(()=>this.requestUpdate()))}async _load(){this._busy=!0;try{this._summary=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/documents/storage"}),this._error=""}catch(e){this._error=j(e,this._lang)}finally{this._loaded=!0,this._busy=!1}}_nameFor(e){return this.objects.find(i=>i.object?.id===e)?.object?.name||e.slice(0,8)}_entryFor(e){return this.objects.find(t=>t.object?.id===e)?.entry_id}_toggle(){this._expanded=!this._expanded}_openObject(e){this.dispatchEvent(new CustomEvent("open-object",{detail:{entry_id:e},bubbles:!0,composed:!0}))}_onSearch(e){this._query=e.target.value,clearTimeout(this._searchTimer),this._searchTimer=window.setTimeout(()=>{this._doSearch()},250)}async _doSearch(){let e=this._query.trim();if(!e){this._results=[];return}try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/documents/search",query:e});this._results=t.results||[]}catch(t){this._error=j(t,this._lang),this._results=[]}}async _openResult(e){if(e.kind==="weblink"){window.open(e.url,"_blank","noopener");return}let t=window.open("about:blank","_blank");try{let i=await this.hass.connection.sendMessagePromise({type:"auth/sign_path",path:`/api/maintenance_supporter/document/${e.id}`,expires:300});t&&(t.location.href=new URL(i.path,window.location.origin).href)}catch(i){t&&t.close(),this._error=j(i,this._lang)}}_renderResult(e,t){return o`
this._openResult(e)}>
@@ -7609,7 +7609,7 @@ ${p?`
${p}
`:""}
${a}
- `}function Zt(n,r,e){if(!n.trigger_config?.entity_id)return h;let t=n.trigger_config.entity_id,i=r.get(t)||[],a=[];if(i.length>=2)a=i.map(T=>({ts:T.ts,val:T.val}));else{if(!n.history)return h;for(let T of n.history)T.trigger_value!=null&&a.push({ts:new Date(T.timestamp).getTime(),val:T.trigger_value})}if(n.trigger_current_value!=null&&a.push({ts:Date.now(),val:n.trigger_current_value}),a.length<2)return h;a.sort((T,ee)=>T.ts-ee.ts);let l=Js,c=Xs,p=a.map(T=>T.val),u=Math.min(...p),g=Math.max(...p),m=g-u||1;u-=m*.1,g+=m*.1;let v=a[0].ts,$=a[a.length-1].ts-v||1,f=T=>(T-v)/$*l,C=T=>2+(1-(T-u)/(g-u))*(c-4),R=a;if(R.length>Hi){let T=Math.ceil(R.length/Hi);R=R.filter((ee,te)=>te%T===0||te===R.length-1)}let L=R.map(T=>`${f(T.ts).toFixed(1)},${C(T.val).toFixed(1)}`).join(" "),I=n.trigger_active?"var(--error-color, #f44336)":"var(--primary-color)";return o` + `}function Zt(n,r,e){if(!n.trigger_config?.entity_id)return h;let t=n.trigger_config.entity_id,i=r.get(t)||[],a=[];if(i.length>=2)a=i.map(T=>({ts:T.ts,val:T.val}));else{if(!n.history)return h;for(let T of n.history)T.trigger_value!=null&&a.push({ts:new Date(T.timestamp).getTime(),val:T.trigger_value})}if(n.trigger_current_value!=null&&a.push({ts:Date.now(),val:n.trigger_current_value}),a.length<2)return h;a.sort((T,ee)=>T.ts-ee.ts);let l=Js,c=Xs,p=a.map(T=>T.val),u=Math.min(...p),g=Math.max(...p),m=g-u||1;u-=m*.1,g+=m*.1;let v=a[0].ts,$=a[a.length-1].ts-v||1,f=T=>(T-v)/$*l,C=T=>2+(1-(T-u)/(g-u))*(c-4),M=a;if(M.length>Hi){let T=Math.ceil(M.length/Hi);M=M.filter((ee,te)=>te%T===0||te===M.length-1)}let L=M.map(T=>`${f(T.ts).toFixed(1)},${C(T.val).toFixed(1)}`).join(" "),I=n.trigger_active?"var(--error-color, #f44336)":"var(--primary-color)";return o` @@ -7624,7 +7624,7 @@ ${p?`
${p}
`:""}
${Pe(n.days_until_due,e)}
- `}function st(n,r,e=4){if(!isFinite(n)||!isFinite(r))return{ticks:[],niceMin:0,niceMax:1};if(n===r){let u=Math.abs(n)*.1||1;n-=u,r+=u}let t=r-n,i=Math.pow(10,Math.floor(Math.log10(t/Math.max(1,e)))),a=i;for(let u of[1,2,5,10])if(a=i*u,t/a<=e+.5)break;let l=Math.floor(n/a)*a,c=Math.ceil(r/a)*a,p=[];for(let u=l;u<=c+a*1e-6;u+=a)p.push(Math.abs(u)=1e6?De((n/1e6).toFixed(r>=1e7?0:1))+"M":r>=1e4?De((n/1e3).toFixed(0))+"k":r>=1e3?De((n/1e3).toFixed(1))+"k":r>=100?n.toFixed(0):r>=10||r>=1?De(n.toFixed(1)):r===0?"0":De(n.toFixed(2))}function De(n){return n.replace(/\.0+$/,"").replace(/(\.\d*[1-9])0+$/,"$1")}function Re(n,r,e){let t=n.toLocaleString(e,{maximumFractionDigits:Math.abs(n)>=100?0:1});return r?`${t} ${r}`:t}function qe(n,r,e){let t=new Date(n),i=e?{month:"short",day:"numeric",year:"2-digit"}:{month:"short",day:"numeric"};return t.toLocaleDateString(r,i)}function Jt(n,r){return new Date(n).toLocaleDateString(r,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}function At(n,r){return new Date(n).getFullYear()!==new Date(r).getFullYear()}function jt(n,r,e){if(e<2||r<=n)return[n,r];let t=[];for(let i=0;i{let t=Math.floor(e[0]?.contentRect?.width||0);t&&Math.abs(t-this._width)>2&&(this._width=t)}),this._ro.observe(this)}disconnectedCallback(){super.disconnectedCallback(),this._ro?.disconnect(),this._ro=null}_emitRange(e){e!==this.rangeDays&&this.dispatchEvent(new CustomEvent("range-change",{detail:{days:e},bubbles:!0,composed:!0}))}_toggleOutliers(){this.dispatchEvent(new CustomEvent("outlier-toggle",{detail:{hide:!this.hideOutliers},bubbles:!0,composed:!0}))}render(){let e=this._width||320,t=[...this.points].sort((a,l)=>a.ts-l.ts),i=this.lang;return o` + `}function st(n,r,e=4){if(!isFinite(n)||!isFinite(r))return{ticks:[],niceMin:0,niceMax:1};if(n===r){let u=Math.abs(n)*.1||1;n-=u,r+=u}let t=r-n,i=Math.pow(10,Math.floor(Math.log10(t/Math.max(1,e)))),a=i;for(let u of[1,2,5,10])if(a=i*u,t/a<=e+.5)break;let l=Math.floor(n/a)*a,c=Math.ceil(r/a)*a,p=[];for(let u=l;u<=c+a*1e-6;u+=a)p.push(Math.abs(u)=1e6?De((n/1e6).toFixed(r>=1e7?0:1))+"M":r>=1e4?De((n/1e3).toFixed(0))+"k":r>=1e3?De((n/1e3).toFixed(1))+"k":r>=100?n.toFixed(0):r>=10||r>=1?De(n.toFixed(1)):r===0?"0":De(n.toFixed(2))}function De(n){return n.replace(/\.0+$/,"").replace(/(\.\d*[1-9])0+$/,"$1")}function Me(n,r,e){let t=n.toLocaleString(e,{maximumFractionDigits:Math.abs(n)>=100?0:1});return r?`${t} ${r}`:t}function qe(n,r,e){let t=new Date(n),i=e?{month:"short",day:"numeric",year:"2-digit"}:{month:"short",day:"numeric"};return t.toLocaleDateString(r,i)}function Jt(n,r){return new Date(n).toLocaleDateString(r,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"})}function At(n,r){return new Date(n).getFullYear()!==new Date(r).getFullYear()}function jt(n,r,e){if(e<2||r<=n)return[n,r];let t=[];for(let i=0;i{let t=Math.floor(e[0]?.contentRect?.width||0);t&&Math.abs(t-this._width)>2&&(this._width=t)}),this._ro.observe(this)}disconnectedCallback(){super.disconnectedCallback(),this._ro?.disconnect(),this._ro=null}_emitRange(e){e!==this.rangeDays&&this.dispatchEvent(new CustomEvent("range-change",{detail:{days:e},bubbles:!0,composed:!0}))}_toggleOutliers(){this.dispatchEvent(new CustomEvent("outlier-toggle",{detail:{hide:!this.hideOutliers},bubbles:!0,composed:!0}))}render(){let e=this._width||320,t=[...this.points].sort((a,l)=>a.ts-l.ts),i=this.lang;return o`
${this.showRange?o`
${this.showOutlierToggle?o`
`:""} ${s("loading_chart",i)}
`:this._renderSvg(e,t)} - `}_renderSvg(e,t){let i=this.lang,a=e-de-fe,l=Ct-ea,c=l-ye,p=1/0,u=-1/0;for(let k of t)p=Math.min(p,k.min??k.val),u=Math.max(u,k.max??k.val);this.thresholdAbove!=null&&(p=Math.min(p,this.thresholdAbove),u=Math.max(u,this.thresholdAbove)),this.thresholdBelow!=null&&(p=Math.min(p,this.thresholdBelow),u=Math.max(u,this.thresholdBelow)),this.targetValue!=null&&(p=Math.min(p,this.targetValue),u=Math.max(u,this.targetValue)),this.forceZero&&(p=Math.min(p,0));let g=(u-p||1)*.06,m=this.forceZero&&p>=0?0:p-g,{ticks:v,niceMin:b,niceMax:$}=st(m,u+g,4);this.forceZero&&p>=0&&b<0&&(b=0,v=v.filter(k=>k>=0));let f=t[0].ts,C=t[t.length-1].ts,R=C-f||1,L=At(f,C),I=k=>de+(k-f)/R*a,T=k=>ye+(1-(k-b)/($-b||1))*c,ee=t.map(k=>`${I(k.ts).toFixed(1)},${T(k.val).toFixed(1)}`).join(" "),te=`M${I(t[0].ts).toFixed(1)},${l} `+t.map(k=>`L${I(k.ts).toFixed(1)},${T(k.val).toFixed(1)}`).join(" ")+` L${I(t[t.length-1].ts).toFixed(1)},${l} Z`,Z="",U=t.filter(k=>k.min!=null&&k.max!=null);if(U.length>=2){let k=U.map(z=>`${I(z.ts).toFixed(1)},${T(z.max).toFixed(1)}`),w=[...U].reverse().map(z=>`${I(z.ts).toFixed(1)},${T(z.min).toFixed(1)}`);Z=`M${k[0]} `+k.slice(1).map(z=>`L${z}`).join(" ")+` L${w.join(" L")} Z`}let N=[];if(this.thresholdBelow!=null){let k=T(this.thresholdBelow);N.push({y:k,h:Math.max(0,l-k),lineY:k,label:`\u25BC ${_e(this.thresholdBelow)}`,labelY:Math.min(l-4,k+13)})}if(this.thresholdAbove!=null){let k=T(this.thresholdAbove);N.push({y:ye,h:Math.max(0,k-ye),lineY:k,label:`\u25B2 ${_e(this.thresholdAbove)}`,labelY:Math.max(ye+11,k-5)})}let xe=t[t.length-1],ke=(this.events||[]).filter(k=>k.ts>=f&&k.ts<=C),Me=jt(f,C,Math.max(2,Math.min(5,Math.floor(a/110)+1))),Y=this._hover;return o` + `}_renderSvg(e,t){let i=this.lang,a=e-de-fe,l=Ct-ea,c=l-ye,p=1/0,u=-1/0;for(let k of t)p=Math.min(p,k.min??k.val),u=Math.max(u,k.max??k.val);this.thresholdAbove!=null&&(p=Math.min(p,this.thresholdAbove),u=Math.max(u,this.thresholdAbove)),this.thresholdBelow!=null&&(p=Math.min(p,this.thresholdBelow),u=Math.max(u,this.thresholdBelow)),this.targetValue!=null&&(p=Math.min(p,this.targetValue),u=Math.max(u,this.targetValue)),this.forceZero&&(p=Math.min(p,0));let g=(u-p||1)*.06,m=this.forceZero&&p>=0?0:p-g,{ticks:v,niceMin:b,niceMax:$}=st(m,u+g,4);this.forceZero&&p>=0&&b<0&&(b=0,v=v.filter(k=>k>=0));let f=t[0].ts,C=t[t.length-1].ts,M=C-f||1,L=At(f,C),I=k=>de+(k-f)/M*a,T=k=>ye+(1-(k-b)/($-b||1))*c,ee=t.map(k=>`${I(k.ts).toFixed(1)},${T(k.val).toFixed(1)}`).join(" "),te=`M${I(t[0].ts).toFixed(1)},${l} `+t.map(k=>`L${I(k.ts).toFixed(1)},${T(k.val).toFixed(1)}`).join(" ")+` L${I(t[t.length-1].ts).toFixed(1)},${l} Z`,Z="",U=t.filter(k=>k.min!=null&&k.max!=null);if(U.length>=2){let k=U.map(z=>`${I(z.ts).toFixed(1)},${T(z.max).toFixed(1)}`),w=[...U].reverse().map(z=>`${I(z.ts).toFixed(1)},${T(z.min).toFixed(1)}`);Z=`M${k[0]} `+k.slice(1).map(z=>`L${z}`).join(" ")+` L${w.join(" L")} Z`}let N=[];if(this.thresholdBelow!=null){let k=T(this.thresholdBelow);N.push({y:k,h:Math.max(0,l-k),lineY:k,label:`\u25BC ${_e(this.thresholdBelow)}`,labelY:Math.min(l-4,k+13)})}if(this.thresholdAbove!=null){let k=T(this.thresholdAbove);N.push({y:ye,h:Math.max(0,k-ye),lineY:k,label:`\u25B2 ${_e(this.thresholdAbove)}`,labelY:Math.max(ye+11,k-5)})}let xe=t[t.length-1],ke=(this.events||[]).filter(k=>k.ts>=f&&k.ts<=C),Re=jt(f,C,Math.max(2,Math.min(5,Math.floor(a/110)+1))),Y=this._hover;return o`
${p}
`:""} x2="${Math.min(I(this.projection[1].ts),e-fe).toFixed(1)}" y2="${T(Math.max(b,Math.min($,this.projection[1].val))).toFixed(1)}" stroke="var(--warning-color, #ff9800)" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.8" />`:h} - ${Me.map((k,w)=>{let z=I(k),Rt=w===0?"start":w===Me.length-1?"end":"middle";return O`${qe(k,i,L)}`})} + ${Re.map((k,w)=>{let z=I(k),Mt=w===0?"start":w===Re.length-1?"end":"middle";return O`${qe(k,i,L)}`})} @@ -7718,7 +7718,7 @@ ${p?`
${p}
`:""} >
${Jt(Y.p.ts,i)}
- ${Re(Y.p.val,this.unit,i)} + ${Me(Y.p.val,this.unit,i)} ${Y.p.min!=null&&Y.p.max!=null?o`(${_e(Y.p.min)}–${_e(Y.p.max)})`:h}
`:h} @@ -7779,7 +7779,7 @@ ${p?`
${p}
`:""} ${b?aa(b,t):g!=null?o`
- ${typeof g=="number"?Re(g,"",t):g} + ${typeof g=="number"?Me(g,"",t):g} ${u?o`${u}`:h}
`:h} @@ -7817,17 +7817,17 @@ ${p?`
${p}
`:""} `}function sa(n,r,e){let t=n.trigger_config,i=n.trigger_current_value;if(!t||i==null)return null;switch(t.type||"threshold"){case"counter":{let a=t.trigger_target_value;if(a==null||a<=0)return null;let l=Ni(n,Fi(n,e));return{progress:Math.max(0,i-(l?.value??i)),target:a,unit:r,meter:i}}case"state_change":{let a=t.trigger_target_changes;return a==null||a<=0?null:{progress:Math.max(0,i),target:a,unit:"",meter:null}}case"runtime":{let a=t.trigger_runtime_hours;return a==null||a<=0?null:{progress:Math.max(0,i),target:a,unit:"h",meter:null}}}return null}function Ni(n,r){if(n.trigger_baseline_value!=null)return{value:n.trigger_baseline_value,ts:Xt(n)};if(!r.length)return null;let e=Xt(n);if(e==null)return{value:r[0].val,ts:null};let t=r[0],i=Math.abs(r[0].ts-e);for(let a of r){let l=Math.abs(a.ts-e);le.type==="completed"||e.type==="reset").sort((e,t)=>new Date(t.timestamp).getTime()-new Date(e.timestamp).getTime())[0];return r?new Date(r.timestamp).getTime():null}function aa(n,r){let e=Math.min(999,Math.round(n.progress/n.target*100)),t=e>=100?"over":e>=75?"near":"ok";return o`
- ${Re(n.progress,"",r)} / ${Re(n.target,n.unit,r)} + ${Me(n.progress,"",r)} / ${Me(n.target,n.unit,r)} ${e} %
- ${s("chart_since_service",r)}${n.meter!=null?o` · ${s("current",r)}: ${Re(n.meter,n.unit,r)}`:h} + ${s("chart_since_service",r)}${n.meter!=null?o` · ${s("current",r)}: ${Me(n.meter,n.unit,r)}`:h}
- `}function Fi(n,r){let e=n.trigger_config;if(!e)return[];let t=e.type||"threshold",i=e.entity_id||"",a=t==="runtime"?[]:r.detailStatsData.get(i)||[],l=r.isCounterEntity(e),c=[];if(a.length>=2)for(let p of a){let u={ts:p.ts,val:p.val};!l&&p.min!=null&&p.max!=null&&(u.min=p.min,u.max=p.max),c.push(u)}else for(let p of n.history)p.trigger_value!=null&&c.push({ts:new Date(p.timestamp).getTime(),val:p.trigger_value});return n.trigger_current_value!=null&&c.push({ts:Date.now(),val:n.trigger_current_value}),c.sort((p,u)=>p.ts-u.ts),c}function ra(n,r,e){let t=n.trigger_config;if(!t)return h;let i=t.type||"threshold",a=t.entity_id||"",l=Fi(n,e);i==="runtime"&&t.trigger_runtime_hours&&n.trigger_current_value!=null&&(l=[{ts:Xt(n)??l[0]?.ts??Date.now()-864e5,val:0},{ts:Date.now(),val:Math.max(0,n.trigger_current_value)}]),e.hideOutliers&&(l=ia(l));let c=l.length<2&&!!a&&e.hasStatsService&&!e.detailStatsData.has(a);if(l.length<2&&!c)return h;let p=!!a&&e.detailStatsData.has(a)&&(e.detailStatsData.get(a)?.length??0)<2,u=Date.now()-e.rangeDays*864e5,g=l.filter(f=>f.ts>=u);g.length>=2&&(l=g);let m=null,v=!1;if(i==="counter"&&t.trigger_target_value!=null&&l.length){let f=Ni(n,l);if(f){if(f.ts!=null){let C=l.filter(R=>R.ts>=f.ts);C.length>=2&&(l=C)}l=l.map(C=>({...C,val:Math.max(0,C.val-f.value)}))}m=t.trigger_target_value,v=!0}else i==="state_change"&&t.trigger_target_changes?(m=t.trigger_target_changes,v=!0):i==="runtime"&&t.trigger_runtime_hours&&(m=t.trigger_runtime_hours,v=!0);let b=null;if(m==null&&n.degradation_rate!=null&&(n.degradation_trend!=="stable"||n.days_until_threshold!=null)&&n.degradation_trend!=="insufficient_data"&&l.length>=2){let f=l[l.length-1];b=[f,{ts:f.ts+30*864e5,val:f.val+n.degradation_rate*30}]}let $=n.history.filter(f=>["completed","skipped","reset"].includes(f.type)).map(f=>({ts:new Date(f.timestamp).getTime(),type:f.type}));return o` + `}function Fi(n,r){let e=n.trigger_config;if(!e)return[];let t=e.type||"threshold",i=e.entity_id||"",a=t==="runtime"?[]:r.detailStatsData.get(i)||[],l=r.isCounterEntity(e),c=[];if(a.length>=2)for(let p of a){let u={ts:p.ts,val:p.val};!l&&p.min!=null&&p.max!=null&&(u.min=p.min,u.max=p.max),c.push(u)}else for(let p of n.history)p.trigger_value!=null&&c.push({ts:new Date(p.timestamp).getTime(),val:p.trigger_value});return n.trigger_current_value!=null&&c.push({ts:Date.now(),val:n.trigger_current_value}),c.sort((p,u)=>p.ts-u.ts),c}function ra(n,r,e){let t=n.trigger_config;if(!t)return h;let i=t.type||"threshold",a=t.entity_id||"",l=Fi(n,e);i==="runtime"&&t.trigger_runtime_hours&&n.trigger_current_value!=null&&(l=[{ts:Xt(n)??l[0]?.ts??Date.now()-864e5,val:0},{ts:Date.now(),val:Math.max(0,n.trigger_current_value)}]),e.hideOutliers&&(l=ia(l));let c=l.length<2&&!!a&&e.hasStatsService&&!e.detailStatsData.has(a);if(l.length<2&&!c)return h;let p=!!a&&e.detailStatsData.has(a)&&(e.detailStatsData.get(a)?.length??0)<2,u=Date.now()-e.rangeDays*864e5,g=l.filter(f=>f.ts>=u);g.length>=2&&(l=g);let m=null,v=!1;if(i==="counter"&&t.trigger_target_value!=null&&l.length){let f=Ni(n,l);if(f){if(f.ts!=null){let C=l.filter(M=>M.ts>=f.ts);C.length>=2&&(l=C)}l=l.map(C=>({...C,val:Math.max(0,C.val-f.value)}))}m=t.trigger_target_value,v=!0}else i==="state_change"&&t.trigger_target_changes?(m=t.trigger_target_changes,v=!0):i==="runtime"&&t.trigger_runtime_hours&&(m=t.trigger_runtime_hours,v=!0);let b=null;if(m==null&&n.degradation_rate!=null&&(n.degradation_trend!=="stable"||n.days_until_threshold!=null)&&n.degradation_trend!=="insufficient_data"&&l.length>=2){let f=l[l.length-1];b=[f,{ts:f.ts+30*864e5,val:f.val+n.degradation_rate*30}]}let $=n.history.filter(f=>["completed","skipped","reset"].includes(f.type)).map(f=>({ts:new Date(f.timestamp).getTime(),type:f.type}));return o` ${p}`:""} ${s(i,r)} (\u03B2=${n.toFixed(2)}) - `}function oa(n,r,e,t,i){let b=Math.max(e,t,r,1)*1.3,$=50,f=[];for(let U=0;U<=$;U++){let N=U/$*b,xe=1-Math.exp(-Math.pow(N/r,n)),ke=32+N/b*260,Me=136-xe*128;f.push([ke,Me])}let C=f.map(([U,N])=>`${U.toFixed(1)},${N.toFixed(1)}`).join(" "),R="M32,136 "+f.map(([U,N])=>`L${U.toFixed(1)},${N.toFixed(1)}`).join(" ")+` L${f[$][0].toFixed(1)},136 Z`,L=32+e/b*260,I=1-Math.exp(-Math.pow(e/r,n)),T=136-I*128,ee=((1-I)*100).toFixed(0),te=32+t/b*260,Z=[0,.25,.5,.75,1];return o` + `}function oa(n,r,e,t,i){let b=Math.max(e,t,r,1)*1.3,$=50,f=[];for(let U=0;U<=$;U++){let N=U/$*b,xe=1-Math.exp(-Math.pow(N/r,n)),ke=32+N/b*260,Re=136-xe*128;f.push([ke,Re])}let C=f.map(([U,N])=>`${U.toFixed(1)},${N.toFixed(1)}`).join(" "),M="M32,136 "+f.map(([U,N])=>`L${U.toFixed(1)},${N.toFixed(1)}`).join(" ")+` L${f[$][0].toFixed(1)},136 Z`,L=32+e/b*260,I=1-Math.exp(-Math.pow(e/r,n)),T=136-I*128,ee=((1-I)*100).toFixed(0),te=32+t/b*260,Z=[0,.25,.5,.75,1];return o`
${Z.map(U=>{let N=136-U*128;return O` @@ -7918,7 +7918,7 @@ ${p?`
${p}
`:""} ${Math.round(b/2)} ${Math.round(b)} - + @@ -8020,8 +8020,8 @@ ${p?`
${p}
`:""} - ${e.map(($,f)=>{let C=$/g*u,R=f*m+(m-v)/2,L=c+u-C,I=f===i,T=$<1?"var(--success-color, #4caf50)":$>1?"var(--warning-color, #ff9800)":"var(--secondary-text-color)";return O` - {let C=$/g*u,M=f*m+(m-v)/2,L=c+u-C,I=f===i,T=$<1?"var(--success-color, #4caf50)":$>1?"var(--warning-color, #ff9800)":"var(--secondary-text-color)";return O` + `})} @@ -8054,7 +8054,7 @@ ${p?`
${p}
`:""}
${ua(n,r,e)} - `}function ua(n,r,e){let t=n.history.filter(w=>w.type==="completed"&&(w.cost!=null||w.duration!=null)).map(w=>({ts:new Date(w.timestamp).getTime(),cost:w.cost??0,duration:w.duration??0})).sort((w,z)=>w.ts-z.ts);if(t.length<2)return h;let i=t.some(w=>w.cost>0),a=t.some(w=>w.duration>0);if(!i&&!a)return h;let l=e!=="duration"&&i,c=e!=="cost"&&a,p=l||!c&&i,u=c||!l&&a,g=640,m=pa,v=p?44:12,b=u?44:12,$=g-v-b,f=m-ha,C=f-at,R=t[0].ts,L=t[t.length-1].ts,I=(L-R||864e5)*.05,T=R-I,ee=L+I,te=At(R,L),Z=w=>v+(w-T)/(ee-T)*$,U=st(0,Math.max(...t.map(w=>w.cost))||1,3),N=st(0,Math.max(...t.map(w=>w.duration))||1,3),xe=w=>at+(1-w/(U.niceMax||1))*C,ke=w=>at+(1-w/(N.niceMax||1))*C,Me=t.length>1?Math.min(...t.slice(1).map((w,z)=>Z(w.ts)-Z(t[z].ts))):$,Y=Math.max(6,Math.min(22,Me*.55)),k=jt(R,L,Math.max(2,Math.min(4,t.length)));return o` + `}function ua(n,r,e){let t=n.history.filter(w=>w.type==="completed"&&(w.cost!=null||w.duration!=null)).map(w=>({ts:new Date(w.timestamp).getTime(),cost:w.cost??0,duration:w.duration??0})).sort((w,z)=>w.ts-z.ts);if(t.length<2)return h;let i=t.some(w=>w.cost>0),a=t.some(w=>w.duration>0);if(!i&&!a)return h;let l=e!=="duration"&&i,c=e!=="cost"&&a,p=l||!c&&i,u=c||!l&&a,g=640,m=pa,v=p?44:12,b=u?44:12,$=g-v-b,f=m-ha,C=f-at,M=t[0].ts,L=t[t.length-1].ts,I=(L-M||864e5)*.05,T=M-I,ee=L+I,te=At(M,L),Z=w=>v+(w-T)/(ee-T)*$,U=st(0,Math.max(...t.map(w=>w.cost))||1,3),N=st(0,Math.max(...t.map(w=>w.duration))||1,3),xe=w=>at+(1-w/(U.niceMax||1))*C,ke=w=>at+(1-w/(N.niceMax||1))*C,Re=t.length>1?Math.min(...t.slice(1).map((w,z)=>Z(w.ts)-Z(t[z].ts))):$,Y=Math.max(6,Math.min(22,Re*.55)),k=jt(M,L,Math.max(2,Math.min(4,t.length)));return o`
${p?U.ticks.map(w=>{let z=xe(w);return zf+1?h:O` @@ -8079,7 +8079,7 @@ ${p?`
${p}
`:""} `:h} - ${k.map((w,z)=>{let Rt=z===0?"start":z===k.length-1?"end":"middle";return O`${qe(w,r,te)}`})} + ${k.map((w,z)=>{let Mt=z===0?"start":z===k.length-1?"end":"middle";return O`${qe(w,r,te)}`})}
@@ -8175,30 +8175,27 @@ ${p?`
${p}
`:""}
r.openComplete(n)}>${s("complete",e)} r.promptSkip()}>${s("skip",e)} - ${t?h:o` - r.toggleArchive(!!n.archived)}> - - ${n.archived?s("unarchive",e):s("archive",e)} - - `} - r.openQr(n.name)}> ${s("qr_code",e)} - ${t?h:o` -
- r.toggleMoreMenu()}> - ${r.moreMenuOpen?o` - + `:h} +
`}function va(n){let r=n.lang;return o` @@ -8322,9 +8319,10 @@ ${p?`
${p}
`:""} `}function wa(n,r){let e=r.lang,t=r.features.adaptive&&n.suggested_interval&&n.suggested_interval!==n.interval_days,i=r.features.seasonal&&n.seasonal_factor&&n.seasonal_factor!==1,a=t||i,l=r.features.adaptive&&n.interval_analysis?.weibull_beta!=null&&n.interval_analysis?.weibull_eta!=null,c=r.features.seasonal&&(n.seasonal_factors?.length===12||n.interval_analysis?.seasonal_factors?.length===12);return o`
+ ${n.battery_fleet_task?o``:h} ${ya(n,r)} ${fa(n,r)} - ${n.battery_fleet_task?o``:o` + ${n.battery_fleet_task?h:o` ${Oi(n,r.lang)} ${qi(n,r.sparkline)} ${Ui(n,e,r.features)} @@ -8369,7 +8367,7 @@ ${p?`
${p}
`:""} .canWrite=${!r.isOperator} >
- `}var rt=class extends S{createRenderRoot(){return this}render(){return!this.task||!this.ctx?h:o`${Xi(this.task,this.ctx)}`}};d([y({attribute:!1})],rt.prototype,"task",2),d([y({attribute:!1})],rt.prototype,"ctx",2);customElements.get("maintenance-task-detail-view")||customElements.define("maintenance-task-detail-view",rt);function es(n){if(n.total<=0)return{start:0,end:0,padTop:0,padBottom:0};let r=n.overscan??12,e=Math.max(1,n.step??6),t=Math.max(1,n.rowHeight),i=Math.floor((n.scrollTop-n.listTop)/t),a=Math.ceil(n.viewportHeight/t)+1,l=Math.max(0,i-r);l=Math.floor(l/e)*e;let c=Math.min(n.total,Math.max(i,0)+a+r);return c=Math.min(n.total,Math.ceil(c/e)*e),l>=c&&(l=Math.min(l,Math.max(0,n.total-1)),c=Math.min(n.total,l+Math.max(a,1))),{start:l,end:c,padTop:l*t,padBottom:(n.total-c)*t}}var E=class extends S{constructor(){super(...arguments);this.narrow=!1;this.panel={};this._objects=[];this._stats=null;this._view="overview";this._selectedEntryId=null;this._selectedTaskId=null;this._filterStatus="";this._filterUser=null;this._filterLabel=null;this._savedViews=[];this._activeViewId="";this._unsub=null;this._chartRangeDays=(()=>{try{let e=parseInt(localStorage.getItem(W.chartRange)||"",10);return[7,30,90,365].includes(e)?e:30}catch{return 30}})();this._hideOutliers=(()=>{try{return localStorage.getItem(W.chartHideOutliers)==="1"}catch{return!1}})();this._historyFilter=null;this._budget=null;this._groups={};this._detailStatsData=new Map;this._miniStatsData=new Map;this._features={adaptive:!1,predictions:!1,seasonal:!1,environmental:!1,budget:!1,groups:!1,checklists:!1,schedule_time:!1,completion_actions:!1};this._adminPanelUserIds=[];this._operatorWriteEnabled=!1;this._defaultWarningDays=7;this._actionLoading=!1;this._moreMenuOpen=!1;this._toastMessage="";this._toastUndo=null;this._toastActionLabel="";this._filtersOpen=!1;this._actionsMenuOpen=!1;this._batteryFleetSetupAvailable=!1;this._toastTimer=null;this._dismissedSuggestions=new Set;this._overviewTab=(()=>{try{let e=localStorage.getItem(W.overviewTab);return e==="today"||e==="calendar"?e:"dashboard"}catch{return"dashboard"}})();this._activeTab="overview";this._costDurationToggle="both";this._historySearch="";this._sortMode="due_date";this._objectSortMode="alphabetical";this._groupByMode="none";this._objectViewMode="cards";this._objectsTableColumns=yt;this._showArchived=!1;this._bulkMode=!1;this._bulkSelected=new Set;this._virtStart=0;this._virtEnd=0;this._virtRowHeight=53;this._virtTotalRows=0;this._virtScrollAttached=!1;this._virtRaf=0;this._collapsedSections=(()=>{try{return new Set(JSON.parse(localStorage.getItem(W.collapsedSections)||"[]"))}catch{return new Set}})();this._paletteOpen=!1;this._paletteQuery="";this._paletteActive=0;this._templateGalleryOpen=!1;this._templates=[];this._templateCategories={};this._templateBusy=!1;this._statsService=null;this._userService=null;this._dataLoaded=!1;this._lastConnection=null;this._popstateHandler=e=>this._onPopState(e);this._onVirtualScroll=()=>{this._virtRaf||(this._virtRaf=requestAnimationFrame(()=>{this._virtRaf=0,this._updateVirtualWindow()}))};this._deepLinkHandled=!1;this._paletteKeydown=e=>{if(e.key==="/"&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&!this._paletteOpen){let i=e.composedPath()[0];if(i instanceof HTMLElement&&(i.tagName==="INPUT"||i.tagName==="TEXTAREA"||i.tagName==="SELECT"||i.isContentEditable))return;e.preventDefault(),this._openPalette();return}if(!this._paletteOpen)return;let t=this._paletteResults;if(e.key==="Escape")e.preventDefault(),this._closePalette();else if(e.key==="ArrowDown")e.preventDefault(),this._paletteActive=Math.min(this._paletteActive+1,t.length-1);else if(e.key==="ArrowUp")e.preventDefault(),this._paletteActive=Math.max(this._paletteActive-1,0);else if(e.key==="Enter"){e.preventDefault();let i=t[this._paletteActive];i&&this._selectPaletteResult(i)}};this._onDialogEvent=async()=>{try{await this._loadData()}catch{}};this._onCalendarLlCustom=e=>{let t=e.detail;t?.type==="maintenance-supporter:open-task"&&t.entry_id&&t.task_id&&(e.stopPropagation(),this._showTask(t.entry_id,t.task_id))};this._onHistoryEntrySaved=async()=>{await this._loadData()}}get _lang(){return this.hass?.language||"en"}get _isOperator(){let e=this.hass?.user;return e?e.is_admin?!1:!(this._operatorWriteEnabled&&this._adminPanelUserIds.includes(e.id)):!0}connectedCallback(){super.connectedCallback(),window.addEventListener("popstate",this._popstateHandler),window.addEventListener("keydown",this._paletteKeydown),window.addEventListener("resize",this._onVirtualScroll,{passive:!0});try{let e=localStorage.getItem(W.taskSort);e&&["due_date","object","type","task_name","area","assigned_user","group"].includes(e)&&(this._sortMode=e);let t=localStorage.getItem(W.objectSort);t&&["alphabetical","due_soonest","task_count"].includes(t)&&(this._objectSortMode=t);let i=localStorage.getItem(W.groupBy);i&&["none","area","group","user"].includes(i)&&(this._groupByMode=i);let a=localStorage.getItem(W.objectView);(a==="cards"||a==="table")&&(this._objectViewMode=a)}catch{}}disconnectedCallback(){super.disconnectedCallback(),window.removeEventListener("popstate",this._popstateHandler),window.removeEventListener("keydown",this._paletteKeydown),window.removeEventListener("resize",this._onVirtualScroll),this.shadowRoot?.querySelector(".content")?.removeEventListener("scroll",this._onVirtualScroll),this._virtScrollAttached=!1,this._virtRaf&&cancelAnimationFrame(this._virtRaf),this._unsub&&(this._unsub(),this._unsub=null),this._dataLoaded=!1,this._lastConnection=null,this._deepLinkHandled=!1,this._statsService?.clearCache(),this._statsService=null}updated(e){super.updated(e),e.has("hass")&>(this.hass?.locale);let t=this.hass?.language;if(t&&!_t(t)&&V(t).then(()=>this.requestUpdate()),e.has("hass")&&this.hass){if(!this._dataLoaded)this._dataLoaded=!0,this._lastConnection=this.hass.connection,history.replaceState({msp_view:"overview",msp_entry:null,msp_task:null},""),this._loadData(),this._subscribe();else if(this.hass.connection!==this._lastConnection){if(this._lastConnection=this.hass.connection,this._unsub){try{this._unsub()}catch{}this._unsub=null}this._subscribe(),this._loadData()}this._statsService?this._statsService.updateHass(this.hass):(this._statsService=new wt(this.hass),this._fetchMiniStatsForOverview()),this._userService?this._userService.updateHass(this.hass):(this._userService=new pe(this.hass),this._userService.getUsers())}let i=this.shadowRoot?.querySelector(".content");i&&!this._virtScrollAttached&&(i.addEventListener("scroll",this._onVirtualScroll,{passive:!0}),this._virtScrollAttached=!0),this._updateVirtualWindow()}_updateVirtualWindow(){let e=this.shadowRoot?.querySelector(".content"),t=this.shadowRoot?.querySelector(".task-table.virtual");if(!e||!t)return;let i=t.querySelector(".task-row:not(.virt-sizer)");i&&i.offsetHeight>20&&(this._virtRowHeight=i.offsetHeight);let a=t.getBoundingClientRect().top-e.getBoundingClientRect().top+e.scrollTop,l=es({scrollTop:e.scrollTop,viewportHeight:e.clientHeight,listTop:a,rowHeight:this._virtRowHeight,total:this._virtTotalRows});(l.start!==this._virtStart||l.end!==this._virtEnd)&&(this._virtStart=l.start,this._virtEnd=l.end)}async _loadData(){let[e,t,i,a,l,c]=await Promise.all([this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"}).catch(()=>null),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/statistics"}).catch(()=>null),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/budget_status"}).catch(()=>null),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/groups"}).catch(()=>null),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/settings"}).catch(()=>null),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/views/list"}).catch(()=>null)]);if(c&&(this._savedViews=c.views||[]),e&&(this._objects=e.objects),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/battery_fleet/overview"}).then(p=>{this._batteryFleetSetupAvailable=!!p.available&&!p.configured}).catch(()=>{this._batteryFleetSetupAvailable=!1}),t&&(this._stats=t),i&&(this._budget=i),a&&(this._groups=a.groups||{}),l){let p=l;this._features=p.features,this._adminPanelUserIds=p.admin_panel_user_ids||[],this._operatorWriteEnabled=p.operator_write_enabled??!1;let u=p.general?.default_warning_days;typeof u=="number"&&u>=0&&u<=365&&(this._defaultWarningDays=u),this._objectsTableColumns=et(p.objects_table_columns)}this._fetchMiniStatsForOverview(),this._handleDeepLink()}_handleDeepLink(){if(this._deepLinkHandled)return;let e=new URLSearchParams(window.location.search),t=e.get("ms_action"),i=()=>{let g=window.location.pathname+window.location.hash;history.replaceState(history.state,"",g)};if(t==="add_object"){this._deepLinkHandled=!0,i(),requestAnimationFrame(()=>{this.shadowRoot?.querySelector("maintenance-object-dialog")?.openCreate()});return}if(t==="open_vacation"||t==="open_budget"||t==="open_groups"||t==="open_settings"){this._deepLinkHandled=!0,i(),this._overviewTab="settings",requestAnimationFrame(()=>{let g=this.shadowRoot?.querySelector("maintenance-settings-view"),m=t.replace("open_","");g?.scrollToSection?.(m)});return}let a=e.get("entry_id");if(!a)return;this._deepLinkHandled=!0;let l=e.get("task_id"),c=e.get("action"),p=window.location.pathname+window.location.hash;history.replaceState(history.state,"",p);let u=this._getObject(a);if(!u){this._showOverview();return}if(l){let g=u.tasks.find(m=>m.id===l);if(!g){this._showObject(a);return}this._showTask(a,l),c==="complete"?requestAnimationFrame(()=>{this._openCompleteDialog(a,l,g.name,this._features.checklists?g.checklist:void 0,this._features.adaptive&&!!g.adaptive_config?.enabled)}):c==="quick_complete"&&requestAnimationFrame(()=>{this._handleQuickComplete(a,l,g)})}else this._showObject(a)}_isCounterEntity(e){if(!e)return!1;let t=e.type||"threshold";return t==="counter"||t==="state_change"}async _fetchDetailStats(e,t){if(!this._statsService)return;let i=await this._statsService.getDetailStats(e,t,this._chartRangeDays),a=new Map(this._detailStatsData);a.set(e,i),this._detailStatsData=a}_setChartRange(e){if(e===this._chartRangeDays)return;this._chartRangeDays=e;try{localStorage.setItem(W.chartRange,String(e))}catch{}let t=this._selectedEntryId&&this._selectedTaskId?this._getTask(this._selectedEntryId,this._selectedTaskId):null,i=t?.trigger_config?.entity_id;if(i){let a=new Map(this._detailStatsData);a.delete(i),this._detailStatsData=a,this._fetchDetailStats(i,this._isCounterEntity(t.trigger_config))}}_setHideOutliers(e){if(e!==this._hideOutliers){this._hideOutliers=e;try{localStorage.setItem(W.chartHideOutliers,e?"1":"0")}catch{}}}async _fetchMiniStatsForOverview(){if(!this._statsService)return;let e=[];for(let i of this._objects)for(let a of i.tasks){let l=a.trigger_config?.entity_id;l&&e.push({entityId:l,isCounter:this._isCounterEntity(a.trigger_config)})}if(e.length===0)return;let t=await this._statsService.getBatchMiniStats(e);this._miniStatsData=new Map([...this._miniStatsData,...t])}async _subscribe(){try{let e=await this.hass.connection.subscribeMessage(t=>{let i=t;this._objects=i.objects},{type:"maintenance_supporter/subscribe"});if(!this.isConnected){e();return}this._unsub=e}catch{}}get _taskRows(){let e=[];for(let m of this._objects)for(let v of m.tasks){if(!this._showArchived&&v.archived||this._filterStatus&&v.status!==this._filterStatus)continue;if(this._filterUser){let $=this._filterUser==="current_user"?this._userService?.getCurrentUserId():this._filterUser;if(v.responsible_user_id!==$)continue}if(this._filterLabel&&!(v.labels||[]).includes(this._filterLabel))continue;let b=[];for(let $ of Object.values(this._groups))$.task_refs?.some(f=>f.entry_id===m.entry_id&&f.task_id===v.id)&&b.push($.name);e.push({entry_id:m.entry_id,task_id:v.id,object_name:m.object.name,task_name:v.name,type:v.type,schedule_type:v.schedule_type,status:v.status,days_until_due:v.days_until_due??null,next_due:v.next_due??null,trigger_active:v.trigger_active,trigger_current_value:v.trigger_current_value??null,trigger_current_delta:v.trigger_current_delta??null,trigger_config:v.trigger_config??null,trigger_entity_info:v.trigger_entity_info??null,times_performed:v.times_performed,total_cost:v.total_cost,interval_days:v.interval_days??null,interval_unit:v.interval_unit??null,interval_anchor:v.interval_anchor??null,is_done:v.is_done??!1,archived:v.archived??!1,history:v.history||[],enabled:v.enabled,nfc_tag_id:v.nfc_tag_id??null,priority:v.priority??"normal",labels:v.labels??[],area_id:m.object.area_id??null,responsible_user_id:v.responsible_user_id??null,group_names:b})}let t={overdue:0,triggered:1,due_soon:2,ok:3},i=(m,v)=>(t[m.status]??9)-(t[v.status]??9),a=(m,v)=>(m.days_until_due??99999)-(v.days_until_due??99999),l=(m,v)=>i(m,v)||a(m,v),c=m=>m.area_id&&this.hass?.areas?.[m.area_id]?.name||"",p=m=>m.responsible_user_id&&this._userService?.getUserName(m.responsible_user_id)||"",u=m=>m.group_names[0]||"",g={due_date:l,object:(m,v)=>m.object_name.localeCompare(v.object_name)||l(m,v),type:(m,v)=>m.type.localeCompare(v.type)||l(m,v),task_name:(m,v)=>m.task_name.localeCompare(v.task_name),area:(m,v)=>{let b=c(m),$=c(v);return!b&&$?1:b&&!$?-1:b.localeCompare($)||l(m,v)},assigned_user:(m,v)=>{let b=p(m),$=p(v);return!b&&$?1:b&&!$?-1:b.localeCompare($)||l(m,v)},group:(m,v)=>{let b=u(m),$=u(v);return!b&&$?1:b&&!$?-1:b.localeCompare($)||l(m,v)}};return e.sort(g[this._sortMode]),e}_getObject(e){return this._objects.find(t=>t.entry_id===e)}_getTask(e,t){return this._getObject(e)?.tasks.find(a=>a.id===t)}_pushPanelState(e,t,i){let a={msp_view:e,msp_entry:t||null,msp_task:i||null};history.pushState(a,"")}_onPopState(e){let t=e.state;if(t?.msp_view&&(this._view=t.msp_view,this._selectedEntryId=t.msp_entry||null,this._selectedTaskId=t.msp_task||null,this._moreMenuOpen=!1,t.msp_view==="task"&&t.msp_entry&&t.msp_task)){this._historyFilter=null;let i=this._getTask(t.msp_entry,t.msp_task);i?.trigger_config?.entity_id&&this._fetchDetailStats(i.trigger_config.entity_id,this._isCounterEntity(i.trigger_config))}}_showOverview(){this._pushPanelState("overview"),this._view="overview",this._selectedEntryId=null,this._selectedTaskId=null,this._moreMenuOpen=!1,this._scrollContentToTop()}_showAllObjects(){this._pushPanelState("all_objects"),this._view="all_objects",this._selectedEntryId=null,this._selectedTaskId=null,this._scrollContentToTop()}_filterByStatus(e){this._filterStatus=e,this._activeViewId="",this._overviewTab!=="dashboard"&&(this._overviewTab="dashboard"),this._scrollContentToTop()}get _allLabels(){let e=new Set;for(let t of this._objects)for(let i of t.tasks)for(let a of i.labels||[])e.add(a);return[...e].sort((t,i)=>t.localeCompare(i))}get _currentFilters(){return{status:this._filterStatus,user_id:this._filterUser,label:this._filterLabel,archived:this._showArchived,sort_mode:this._sortMode,group_by:this._groupByMode}}_applyView(e){if(this._activeViewId=e,!e)return;let t=this._savedViews.find(a=>a.id===e);if(!t)return;let i=t.filters;this._filterStatus=i.status||"",this._filterUser=i.user_id||null,this._filterLabel=i.label||null,this._showArchived=!!i.archived,["due_date","object","type","task_name","area","assigned_user","group"].includes(i.sort_mode)&&(this._sortMode=i.sort_mode),["none","area","group","user"].includes(i.group_by)&&(this._groupByMode=i.group_by);try{localStorage.setItem(W.taskSort,this._sortMode),localStorage.setItem(W.groupBy,this._groupByMode)}catch{}this._overviewTab!=="dashboard"&&(this._overviewTab="dashboard")}_openSavedViewsDialog(){this.shadowRoot.querySelector("maintenance-saved-views-dialog")?.open(this._currentFilters,this._savedViews)}_onSavedViewsChanged(e){this._savedViews=e.detail.views||[],this._activeViewId&&!this._savedViews.some(t=>t.id===this._activeViewId)&&(this._activeViewId="")}_scrollContentToTop(){requestAnimationFrame(()=>{let e=this.shadowRoot?.querySelector(".content");e&&e.scrollTo({top:0,behavior:"smooth"})})}_showObject(e){this._pushPanelState("object",e),this._view="object",this._selectedEntryId=e,this._selectedTaskId=null,this._scrollContentToTop()}_showTask(e,t){this._pushPanelState("task",e,t),this._view="task",this._selectedEntryId=e,this._selectedTaskId=t,this._activeTab="overview",this._historyFilter=null,this._scrollContentToTop();let i=this._getTask(e,t);if(i?.trigger_config?.entity_id){let a=i.trigger_config.entity_id,l=this._isCounterEntity(i.trigger_config);this._fetchDetailStats(a,l)}}_showToast(e){this._toastTimer&&clearTimeout(this._toastTimer),this._toastUndo=null,this._toastActionLabel="",this._toastMessage=e,this._toastTimer=setTimeout(()=>{this._toastMessage="",this._toastTimer=null},4e3)}_showActionToast(e,t,i){this._showUndoToast(e,i),this._toastActionLabel=t}_showUndoToast(e,t){this._toastTimer&&clearTimeout(this._toastTimer),this._toastActionLabel="",this._toastMessage=e,this._toastUndo=t,this._toastTimer=setTimeout(()=>{this._toastMessage="",this._toastUndo=null,this._toastTimer=null},7e3)}_runToastUndo(){let e=this._toastUndo;this._toastTimer&&clearTimeout(this._toastTimer),this._toastMessage="",this._toastUndo=null,this._toastTimer=null,e?.()}_openPalette(){this._paletteQuery="",this._paletteActive=0,this._paletteOpen=!0,this.updateComplete.then(()=>{this.shadowRoot?.querySelector(".palette-input")?.focus()})}_closePalette(){this._paletteOpen=!1,this._paletteQuery=""}get _paletteResults(){let e=this._paletteQuery.trim().toLowerCase(),t=[];for(let i of this._objects){let a=i.object.name||"";(!e||a.toLowerCase().includes(e))&&t.push({kind:"object",entryId:i.entry_id,label:a,sub:s("object",this._lang)});for(let l of i.tasks){if(l.archived)continue;let c=l.name||"",p=(l.labels||[]).some(u=>u.toLowerCase().includes(e));if(!e||c.toLowerCase().includes(e)||a.toLowerCase().includes(e)||p){let u=(l.labels||[]).length?` #${(l.labels||[]).join(" #")}`:"";t.push({kind:"task",entryId:i.entry_id,taskId:l.id,label:c,sub:a+u})}}if(t.length>60)break}return t.slice(0,40)}_selectPaletteResult(e){this._closePalette(),e.kind==="task"&&e.taskId?this._showTask(e.entryId,e.taskId):this._showObject(e.entryId)}_renderPalette(){if(!this._paletteOpen)return h;let e=this._lang,t=this._paletteResults;return o` + `}var rt=class extends S{createRenderRoot(){return this}render(){return!this.task||!this.ctx?h:o`${Xi(this.task,this.ctx)}`}};d([y({attribute:!1})],rt.prototype,"task",2),d([y({attribute:!1})],rt.prototype,"ctx",2);customElements.get("maintenance-task-detail-view")||customElements.define("maintenance-task-detail-view",rt);function es(n){if(n.total<=0)return{start:0,end:0,padTop:0,padBottom:0};let r=n.overscan??12,e=Math.max(1,n.step??6),t=Math.max(1,n.rowHeight),i=Math.floor((n.scrollTop-n.listTop)/t),a=Math.ceil(n.viewportHeight/t)+1,l=Math.max(0,i-r);l=Math.floor(l/e)*e;let c=Math.min(n.total,Math.max(i,0)+a+r);return c=Math.min(n.total,Math.ceil(c/e)*e),l>=c&&(l=Math.min(l,Math.max(0,n.total-1)),c=Math.min(n.total,l+Math.max(a,1))),{start:l,end:c,padTop:l*t,padBottom:(n.total-c)*t}}var E=class extends S{constructor(){super(...arguments);this.narrow=!1;this.panel={};this._objects=[];this._stats=null;this._view="overview";this._selectedEntryId=null;this._selectedTaskId=null;this._filterStatus="";this._filterUser=null;this._filterLabel=null;this._savedViews=[];this._activeViewId="";this._unsub=null;this._chartRangeDays=(()=>{try{let e=parseInt(localStorage.getItem(W.chartRange)||"",10);return[7,30,90,365].includes(e)?e:30}catch{return 30}})();this._hideOutliers=(()=>{try{return localStorage.getItem(W.chartHideOutliers)==="1"}catch{return!1}})();this._historyFilter=null;this._budget=null;this._groups={};this._detailStatsData=new Map;this._miniStatsData=new Map;this._features={adaptive:!1,predictions:!1,seasonal:!1,environmental:!1,budget:!1,groups:!1,checklists:!1,schedule_time:!1,completion_actions:!1};this._adminPanelUserIds=[];this._operatorWriteEnabled=!1;this._defaultWarningDays=7;this._actionLoading=!1;this._moreMenuOpen=!1;this._objMenuOpen=!1;this._toastMessage="";this._toastUndo=null;this._toastActionLabel="";this._filtersOpen=!1;this._actionsMenuOpen=!1;this._batteryFleetSetupAvailable=!1;this._toastTimer=null;this._dismissedSuggestions=new Set;this._overviewTab=(()=>{try{let e=localStorage.getItem(W.overviewTab);return e==="today"||e==="calendar"?e:"dashboard"}catch{return"dashboard"}})();this._activeTab="overview";this._costDurationToggle="both";this._historySearch="";this._sortMode="due_date";this._objectSortMode="alphabetical";this._groupByMode="none";this._objectViewMode="cards";this._objectsTableColumns=yt;this._showArchived=!1;this._bulkMode=!1;this._bulkSelected=new Set;this._virtStart=0;this._virtEnd=0;this._virtRowHeight=53;this._virtTotalRows=0;this._virtScrollAttached=!1;this._virtRaf=0;this._collapsedSections=(()=>{try{return new Set(JSON.parse(localStorage.getItem(W.collapsedSections)||"[]"))}catch{return new Set}})();this._paletteOpen=!1;this._paletteQuery="";this._paletteActive=0;this._templateGalleryOpen=!1;this._templates=[];this._templateCategories={};this._templateBusy=!1;this._statsService=null;this._userService=null;this._dataLoaded=!1;this._lastConnection=null;this._popstateHandler=e=>this._onPopState(e);this._onVirtualScroll=()=>{this._virtRaf||(this._virtRaf=requestAnimationFrame(()=>{this._virtRaf=0,this._updateVirtualWindow()}))};this._deepLinkHandled=!1;this._paletteKeydown=e=>{if(e.key==="/"&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&!this._paletteOpen){let i=e.composedPath()[0];if(i instanceof HTMLElement&&(i.tagName==="INPUT"||i.tagName==="TEXTAREA"||i.tagName==="SELECT"||i.isContentEditable))return;e.preventDefault(),this._openPalette();return}if(!this._paletteOpen)return;let t=this._paletteResults;if(e.key==="Escape")e.preventDefault(),this._closePalette();else if(e.key==="ArrowDown")e.preventDefault(),this._paletteActive=Math.min(this._paletteActive+1,t.length-1);else if(e.key==="ArrowUp")e.preventDefault(),this._paletteActive=Math.max(this._paletteActive-1,0);else if(e.key==="Enter"){e.preventDefault();let i=t[this._paletteActive];i&&this._selectPaletteResult(i)}};this._onDialogEvent=async()=>{try{await this._loadData()}catch{}};this._onCalendarLlCustom=e=>{let t=e.detail;t?.type==="maintenance-supporter:open-task"&&t.entry_id&&t.task_id&&(e.stopPropagation(),this._showTask(t.entry_id,t.task_id))};this._onHistoryEntrySaved=async()=>{await this._loadData()}}get _lang(){return this.hass?.language||"en"}get _isOperator(){let e=this.hass?.user;return e?e.is_admin?!1:!(this._operatorWriteEnabled&&this._adminPanelUserIds.includes(e.id)):!0}connectedCallback(){super.connectedCallback(),window.addEventListener("popstate",this._popstateHandler),window.addEventListener("keydown",this._paletteKeydown),window.addEventListener("resize",this._onVirtualScroll,{passive:!0});try{let e=localStorage.getItem(W.taskSort);e&&["due_date","object","type","task_name","area","assigned_user","group"].includes(e)&&(this._sortMode=e);let t=localStorage.getItem(W.objectSort);t&&["alphabetical","due_soonest","task_count"].includes(t)&&(this._objectSortMode=t);let i=localStorage.getItem(W.groupBy);i&&["none","area","group","user"].includes(i)&&(this._groupByMode=i);let a=localStorage.getItem(W.objectView);(a==="cards"||a==="table")&&(this._objectViewMode=a)}catch{}}disconnectedCallback(){super.disconnectedCallback(),window.removeEventListener("popstate",this._popstateHandler),window.removeEventListener("keydown",this._paletteKeydown),window.removeEventListener("resize",this._onVirtualScroll),this.shadowRoot?.querySelector(".content")?.removeEventListener("scroll",this._onVirtualScroll),this._virtScrollAttached=!1,this._virtRaf&&cancelAnimationFrame(this._virtRaf),this._unsub&&(this._unsub(),this._unsub=null),this._dataLoaded=!1,this._lastConnection=null,this._deepLinkHandled=!1,this._statsService?.clearCache(),this._statsService=null}updated(e){super.updated(e),e.has("hass")&>(this.hass?.locale);let t=this.hass?.language;if(t&&!_t(t)&&V(t).then(()=>this.requestUpdate()),e.has("hass")&&this.hass){if(!this._dataLoaded)this._dataLoaded=!0,this._lastConnection=this.hass.connection,history.replaceState({msp_view:"overview",msp_entry:null,msp_task:null},""),this._loadData(),this._subscribe();else if(this.hass.connection!==this._lastConnection){if(this._lastConnection=this.hass.connection,this._unsub){try{this._unsub()}catch{}this._unsub=null}this._subscribe(),this._loadData()}this._statsService?this._statsService.updateHass(this.hass):(this._statsService=new wt(this.hass),this._fetchMiniStatsForOverview()),this._userService?this._userService.updateHass(this.hass):(this._userService=new pe(this.hass),this._userService.getUsers())}let i=this.shadowRoot?.querySelector(".content");i&&!this._virtScrollAttached&&(i.addEventListener("scroll",this._onVirtualScroll,{passive:!0}),this._virtScrollAttached=!0),this._updateVirtualWindow()}_updateVirtualWindow(){let e=this.shadowRoot?.querySelector(".content"),t=this.shadowRoot?.querySelector(".task-table.virtual");if(!e||!t)return;let i=t.querySelector(".task-row:not(.virt-sizer)");i&&i.offsetHeight>20&&(this._virtRowHeight=i.offsetHeight);let a=t.getBoundingClientRect().top-e.getBoundingClientRect().top+e.scrollTop,l=es({scrollTop:e.scrollTop,viewportHeight:e.clientHeight,listTop:a,rowHeight:this._virtRowHeight,total:this._virtTotalRows});(l.start!==this._virtStart||l.end!==this._virtEnd)&&(this._virtStart=l.start,this._virtEnd=l.end)}async _loadData(){let[e,t,i,a,l,c]=await Promise.all([this.hass.connection.sendMessagePromise({type:"maintenance_supporter/objects"}).catch(()=>null),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/statistics"}).catch(()=>null),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/budget_status"}).catch(()=>null),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/groups"}).catch(()=>null),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/settings"}).catch(()=>null),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/views/list"}).catch(()=>null)]);if(c&&(this._savedViews=c.views||[]),e&&(this._objects=e.objects),this.hass.connection.sendMessagePromise({type:"maintenance_supporter/battery_fleet/overview"}).then(p=>{this._batteryFleetSetupAvailable=!!p.available&&!p.configured}).catch(()=>{this._batteryFleetSetupAvailable=!1}),t&&(this._stats=t),i&&(this._budget=i),a&&(this._groups=a.groups||{}),l){let p=l;this._features=p.features,this._adminPanelUserIds=p.admin_panel_user_ids||[],this._operatorWriteEnabled=p.operator_write_enabled??!1;let u=p.general?.default_warning_days;typeof u=="number"&&u>=0&&u<=365&&(this._defaultWarningDays=u),this._objectsTableColumns=et(p.objects_table_columns)}this._fetchMiniStatsForOverview(),this._handleDeepLink()}_handleDeepLink(){if(this._deepLinkHandled)return;let e=new URLSearchParams(window.location.search),t=e.get("ms_action"),i=()=>{let g=window.location.pathname+window.location.hash;history.replaceState(history.state,"",g)};if(t==="add_object"){this._deepLinkHandled=!0,i(),requestAnimationFrame(()=>{this.shadowRoot?.querySelector("maintenance-object-dialog")?.openCreate()});return}if(t==="open_vacation"||t==="open_budget"||t==="open_groups"||t==="open_settings"){this._deepLinkHandled=!0,i(),this._overviewTab="settings",requestAnimationFrame(()=>{let g=this.shadowRoot?.querySelector("maintenance-settings-view"),m=t.replace("open_","");g?.scrollToSection?.(m)});return}let a=e.get("entry_id");if(!a)return;this._deepLinkHandled=!0;let l=e.get("task_id"),c=e.get("action"),p=window.location.pathname+window.location.hash;history.replaceState(history.state,"",p);let u=this._getObject(a);if(!u){this._showOverview();return}if(l){let g=u.tasks.find(m=>m.id===l);if(!g){this._showObject(a);return}this._showTask(a,l),c==="complete"?requestAnimationFrame(()=>{this._openCompleteDialog(a,l,g.name,this._features.checklists?g.checklist:void 0,this._features.adaptive&&!!g.adaptive_config?.enabled)}):c==="quick_complete"&&requestAnimationFrame(()=>{this._handleQuickComplete(a,l,g)})}else this._showObject(a)}_isCounterEntity(e){if(!e)return!1;let t=e.type||"threshold";return t==="counter"||t==="state_change"}async _fetchDetailStats(e,t){if(!this._statsService)return;let i=await this._statsService.getDetailStats(e,t,this._chartRangeDays),a=new Map(this._detailStatsData);a.set(e,i),this._detailStatsData=a}_setChartRange(e){if(e===this._chartRangeDays)return;this._chartRangeDays=e;try{localStorage.setItem(W.chartRange,String(e))}catch{}let t=this._selectedEntryId&&this._selectedTaskId?this._getTask(this._selectedEntryId,this._selectedTaskId):null,i=t?.trigger_config?.entity_id;if(i){let a=new Map(this._detailStatsData);a.delete(i),this._detailStatsData=a,this._fetchDetailStats(i,this._isCounterEntity(t.trigger_config))}}_setHideOutliers(e){if(e!==this._hideOutliers){this._hideOutliers=e;try{localStorage.setItem(W.chartHideOutliers,e?"1":"0")}catch{}}}async _fetchMiniStatsForOverview(){if(!this._statsService)return;let e=[];for(let i of this._objects)for(let a of i.tasks){let l=a.trigger_config?.entity_id;l&&e.push({entityId:l,isCounter:this._isCounterEntity(a.trigger_config)})}if(e.length===0)return;let t=await this._statsService.getBatchMiniStats(e);this._miniStatsData=new Map([...this._miniStatsData,...t])}async _subscribe(){try{let e=await this.hass.connection.subscribeMessage(t=>{let i=t;this._objects=i.objects},{type:"maintenance_supporter/subscribe"});if(!this.isConnected){e();return}this._unsub=e}catch{}}get _taskRows(){let e=[];for(let m of this._objects)for(let v of m.tasks){if(!this._showArchived&&v.archived||this._filterStatus&&v.status!==this._filterStatus)continue;if(this._filterUser){let $=this._filterUser==="current_user"?this._userService?.getCurrentUserId():this._filterUser;if(v.responsible_user_id!==$)continue}if(this._filterLabel&&!(v.labels||[]).includes(this._filterLabel))continue;let b=[];for(let $ of Object.values(this._groups))$.task_refs?.some(f=>f.entry_id===m.entry_id&&f.task_id===v.id)&&b.push($.name);e.push({entry_id:m.entry_id,task_id:v.id,object_name:m.object.name,task_name:v.name,type:v.type,schedule_type:v.schedule_type,status:v.status,days_until_due:v.days_until_due??null,next_due:v.next_due??null,trigger_active:v.trigger_active,trigger_current_value:v.trigger_current_value??null,trigger_current_delta:v.trigger_current_delta??null,trigger_config:v.trigger_config??null,trigger_entity_info:v.trigger_entity_info??null,times_performed:v.times_performed,total_cost:v.total_cost,interval_days:v.interval_days??null,interval_unit:v.interval_unit??null,interval_anchor:v.interval_anchor??null,is_done:v.is_done??!1,archived:v.archived??!1,history:v.history||[],enabled:v.enabled,nfc_tag_id:v.nfc_tag_id??null,priority:v.priority??"normal",labels:v.labels??[],area_id:m.object.area_id??null,responsible_user_id:v.responsible_user_id??null,group_names:b})}let t={overdue:0,triggered:1,due_soon:2,ok:3},i=(m,v)=>(t[m.status]??9)-(t[v.status]??9),a=(m,v)=>(m.days_until_due??99999)-(v.days_until_due??99999),l=(m,v)=>i(m,v)||a(m,v),c=m=>m.area_id&&this.hass?.areas?.[m.area_id]?.name||"",p=m=>m.responsible_user_id&&this._userService?.getUserName(m.responsible_user_id)||"",u=m=>m.group_names[0]||"",g={due_date:l,object:(m,v)=>m.object_name.localeCompare(v.object_name)||l(m,v),type:(m,v)=>m.type.localeCompare(v.type)||l(m,v),task_name:(m,v)=>m.task_name.localeCompare(v.task_name),area:(m,v)=>{let b=c(m),$=c(v);return!b&&$?1:b&&!$?-1:b.localeCompare($)||l(m,v)},assigned_user:(m,v)=>{let b=p(m),$=p(v);return!b&&$?1:b&&!$?-1:b.localeCompare($)||l(m,v)},group:(m,v)=>{let b=u(m),$=u(v);return!b&&$?1:b&&!$?-1:b.localeCompare($)||l(m,v)}};return e.sort(g[this._sortMode]),e}_getObject(e){return this._objects.find(t=>t.entry_id===e)}_getTask(e,t){return this._getObject(e)?.tasks.find(a=>a.id===t)}_pushPanelState(e,t,i){let a={msp_view:e,msp_entry:t||null,msp_task:i||null};history.pushState(a,"")}_onPopState(e){let t=e.state;if(t?.msp_view&&(this._view=t.msp_view,this._selectedEntryId=t.msp_entry||null,this._selectedTaskId=t.msp_task||null,this._moreMenuOpen=!1,t.msp_view==="task"&&t.msp_entry&&t.msp_task)){this._historyFilter=null;let i=this._getTask(t.msp_entry,t.msp_task);i?.trigger_config?.entity_id&&this._fetchDetailStats(i.trigger_config.entity_id,this._isCounterEntity(i.trigger_config))}}_showOverview(){this._pushPanelState("overview"),this._view="overview",this._selectedEntryId=null,this._selectedTaskId=null,this._moreMenuOpen=!1,this._scrollContentToTop()}_showAllObjects(){this._pushPanelState("all_objects"),this._view="all_objects",this._selectedEntryId=null,this._selectedTaskId=null,this._scrollContentToTop()}_filterByStatus(e){this._filterStatus=e,this._activeViewId="",this._overviewTab!=="dashboard"&&(this._overviewTab="dashboard"),this._scrollContentToTop()}get _allLabels(){let e=new Set;for(let t of this._objects)for(let i of t.tasks)for(let a of i.labels||[])e.add(a);return[...e].sort((t,i)=>t.localeCompare(i))}get _currentFilters(){return{status:this._filterStatus,user_id:this._filterUser,label:this._filterLabel,archived:this._showArchived,sort_mode:this._sortMode,group_by:this._groupByMode}}_applyView(e){if(this._activeViewId=e,!e)return;let t=this._savedViews.find(a=>a.id===e);if(!t)return;let i=t.filters;this._filterStatus=i.status||"",this._filterUser=i.user_id||null,this._filterLabel=i.label||null,this._showArchived=!!i.archived,["due_date","object","type","task_name","area","assigned_user","group"].includes(i.sort_mode)&&(this._sortMode=i.sort_mode),["none","area","group","user"].includes(i.group_by)&&(this._groupByMode=i.group_by);try{localStorage.setItem(W.taskSort,this._sortMode),localStorage.setItem(W.groupBy,this._groupByMode)}catch{}this._overviewTab!=="dashboard"&&(this._overviewTab="dashboard")}_openSavedViewsDialog(){this.shadowRoot.querySelector("maintenance-saved-views-dialog")?.open(this._currentFilters,this._savedViews)}_onSavedViewsChanged(e){this._savedViews=e.detail.views||[],this._activeViewId&&!this._savedViews.some(t=>t.id===this._activeViewId)&&(this._activeViewId="")}_scrollContentToTop(){requestAnimationFrame(()=>{let e=this.shadowRoot?.querySelector(".content");e&&e.scrollTo({top:0,behavior:"smooth"})})}_showObject(e){this._pushPanelState("object",e),this._view="object",this._selectedEntryId=e,this._selectedTaskId=null,this._scrollContentToTop()}_showTask(e,t){this._pushPanelState("task",e,t),this._view="task",this._selectedEntryId=e,this._selectedTaskId=t,this._activeTab="overview",this._historyFilter=null,this._scrollContentToTop();let i=this._getTask(e,t);if(i?.trigger_config?.entity_id){let a=i.trigger_config.entity_id,l=this._isCounterEntity(i.trigger_config);this._fetchDetailStats(a,l)}}_showToast(e){this._toastTimer&&clearTimeout(this._toastTimer),this._toastUndo=null,this._toastActionLabel="",this._toastMessage=e,this._toastTimer=setTimeout(()=>{this._toastMessage="",this._toastTimer=null},4e3)}_showActionToast(e,t,i){this._showUndoToast(e,i),this._toastActionLabel=t}_showUndoToast(e,t){this._toastTimer&&clearTimeout(this._toastTimer),this._toastActionLabel="",this._toastMessage=e,this._toastUndo=t,this._toastTimer=setTimeout(()=>{this._toastMessage="",this._toastUndo=null,this._toastTimer=null},7e3)}_runToastUndo(){let e=this._toastUndo;this._toastTimer&&clearTimeout(this._toastTimer),this._toastMessage="",this._toastUndo=null,this._toastTimer=null,e?.()}_openPalette(){this._paletteQuery="",this._paletteActive=0,this._paletteOpen=!0,this.updateComplete.then(()=>{this.shadowRoot?.querySelector(".palette-input")?.focus()})}_closePalette(){this._paletteOpen=!1,this._paletteQuery=""}get _paletteResults(){let e=this._paletteQuery.trim().toLowerCase(),t=[];for(let i of this._objects){let a=i.object.name||"";(!e||a.toLowerCase().includes(e))&&t.push({kind:"object",entryId:i.entry_id,label:a,sub:s("object",this._lang)});for(let l of i.tasks){if(l.archived)continue;let c=l.name||"",p=(l.labels||[]).some(u=>u.toLowerCase().includes(e));if(!e||c.toLowerCase().includes(e)||a.toLowerCase().includes(e)||p){let u=(l.labels||[]).length?` #${(l.labels||[]).join(" #")}`:"";t.push({kind:"task",entryId:i.entry_id,taskId:l.id,label:c,sub:a+u})}}if(t.length>60)break}return t.slice(0,40)}_selectPaletteResult(e){this._closePalette(),e.kind==="task"&&e.taskId?this._showTask(e.entryId,e.taskId):this._showObject(e.entryId)}_renderPalette(){if(!this._paletteOpen)return h;let e=this._lang,t=this._paletteResults;return o`
this._closePalette()}>
i.stopPropagation()}> ${p}
`:""}
- `}_bulkKey(e){return`${e.entry_id}:${e.task_id}`}_toggleBulkMode(){this._bulkMode=!this._bulkMode,this._bulkMode||(this._bulkSelected=new Set)}_toggleBulkRow(e){let t=this._bulkKey(e),i=new Set(this._bulkSelected);i.has(t)?i.delete(t):i.add(t),this._bulkSelected=i}_bulkSelectAll(e){let t=e.map(a=>this._bulkKey(a)),i=t.every(a=>this._bulkSelected.has(a));this._bulkSelected=i?new Set:new Set(t)}async _runBulk(e,t,i,a){let l=e.filter(p=>this._bulkSelected.has(this._bulkKey(p)));if(l.length===0)return;this._actionLoading=!0;let c=0;for(let p of l)try{await this.hass.connection.sendMessagePromise(t(p)),c++}catch{}this._actionLoading=!1,this._bulkSelected=new Set,this._bulkMode=!1,await this._loadData(),a&&c>0?this._showUndoToast(i(c),a):this._showToast(i(c))}_bulkComplete(e){this._runBulk(e,t=>({type:"maintenance_supporter/task/complete",entry_id:t.entry_id,task_id:t.task_id}),t=>s("bulk_completed",this._lang).replace("{n}",String(t)))}_bulkArchive(e){let t=e.filter(i=>this._bulkSelected.has(this._bulkKey(i))).map(i=>({entry_id:i.entry_id,task_id:i.task_id}));this._runBulk(e,i=>({type:"maintenance_supporter/task/archive",entry_id:i.entry_id,task_id:i.task_id}),i=>s("bulk_archived",this._lang).replace("{n}",String(i)),async()=>{for(let i of t)try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/unarchive",entry_id:i.entry_id,task_id:i.task_id})}catch{}await this._loadData()})}async _deleteObject(e){if(await this.shadowRoot.querySelector("maintenance-confirm-dialog")?.confirm({title:s("delete",this._lang),message:s("confirm_delete_object",this._lang),confirmText:s("delete",this._lang),danger:!0}))try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/delete",entry_id:e}),this._showOverview(),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}}_printObjectReport(e){let t=this._getObject(e);if(!t)return;let i=this._lang,a={title:s("report_title",i),generated:s("report_generated",i),manufacturer:s("manufacturer",i),model:s("model",i),serial:s("serial_number_label",i),installed:s("installed",i),warranty:s("warranty",i),area:s("area",i),notes:s("report_notes",i),tasksHeading:s("tasks",i),colTask:s("task_name",i),colType:s("report_col_type",i),colStatus:s("report_col_status",i),colSchedule:s("report_col_schedule",i),colLastDone:s("last_performed",i),colNextDue:s("next_due",i),colCost:s("cost",i),colTimes:s("report_times_done",i),totalCost:s("report_total_cost",i),scheduleLabel:p=>Xe(p,i),none:"\u2014",statusLabel:p=>s(p,i),typeLabel:p=>s(p,i)},l=wi(t.object,t.tasks,a,p=>p?B(p,i):"",this._budget?.currency_symbol||je,new Date().toISOString()),c=URL.createObjectURL(new Blob([l],{type:"text/html"}));window.open(c,"_blank"),setTimeout(()=>URL.revokeObjectURL(c),6e4)}async _duplicateObject(e){this._actionLoading=!0;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/duplicate",entry_id:e});await this._loadData(),this._showToast(s("object_duplicated",this._lang)),t?.entry_id&&this._showObject(t.entry_id)}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _deleteTask(e,t){if(await this.shadowRoot.querySelector("maintenance-confirm-dialog")?.confirm({title:s("delete",this._lang),message:s("confirm_delete_task",this._lang),confirmText:s("delete",this._lang),danger:!0}))try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/delete",entry_id:e,task_id:t}),this._showObject(e),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}}async _duplicateTask(e,t){this._moreMenuOpen=!1,this._actionLoading=!0;try{let i=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/duplicate",entry_id:e,task_id:t});await this._loadData(),this._showToast(s("task_duplicated",this._lang)),i?.task_id&&this._showTask(e,i.task_id)}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _toggleArchiveTask(e,t,i){this._actionLoading=!0;try{await this.hass.connection.sendMessagePromise({type:i?"maintenance_supporter/task/unarchive":"maintenance_supporter/task/archive",entry_id:e,task_id:t}),await this._loadData(),i||this._showUndoToast(s("task_archived",this._lang),()=>this._toggleArchiveTask(e,t,!0))}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _toggleArchiveObject(e,t){try{await this.hass.connection.sendMessagePromise({type:t?"maintenance_supporter/object/unarchive":"maintenance_supporter/object/archive",entry_id:e}),await this._loadData(),t||this._showUndoToast(s("object_archived",this._lang),()=>this._toggleArchiveObject(e,!0))}catch{this._showToast(s("action_error",this._lang))}}async _togglePauseObject(e,t){if(!t){let a=await this.shadowRoot.querySelector("maintenance-confirm-dialog")?.prompt({title:s("pause_object",this._lang),message:s("pause_until_prompt",this._lang),confirmText:s("pause_object",this._lang),inputLabel:s("pause_until_label",this._lang),inputType:"date"});if(!a?.confirmed)return;try{let l={type:"maintenance_supporter/object/pause",entry_id:e};a.value&&(l.until=a.value),await this.hass.connection.sendMessagePromise(l),await this._loadData(),this._showUndoToast(s("object_paused",this._lang),()=>this._togglePauseObject(e,!0))}catch(l){this._showToast(j(l,this._lang))}return}try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/resume",entry_id:e}),await this._loadData(),this._showToast(s("object_resumed",this._lang))}catch(i){this._showToast(j(i,this._lang))}}async _replaceObject(e,t){let a=await this.shadowRoot.querySelector("maintenance-confirm-dialog")?.prompt({title:s("replace_object",this._lang),message:s("replace_object_prompt",this._lang),confirmText:s("replace_object",this._lang),inputLabel:s("replace_name_label",this._lang),inputType:"text",inputValue:t});if(a?.confirmed){this._actionLoading=!0;try{let l=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/replace",entry_id:e,name:a.value||t});await this._loadData(),this._showToast(s("object_replaced",this._lang)),l?.entry_id&&this._showObject(l.entry_id)}catch(l){this._showToast(j(l,this._lang))}finally{this._actionLoading=!1}}}async _skipTask(e,t,i){this._actionLoading=!0;try{let a={type:"maintenance_supporter/task/skip",entry_id:e,task_id:t};i&&(a.reason=i),await this.hass.connection.sendMessagePromise(a),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _resetTask(e,t,i){this._actionLoading=!0;try{let a={type:"maintenance_supporter/task/reset",entry_id:e,task_id:t};i&&(a.date=i),await this.hass.connection.sendMessagePromise(a),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _applySuggestion(e,t,i){try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/apply_suggestion",entry_id:e,task_id:t,interval:i}),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}}_openSeasonalOverrides(e){let t=this.shadowRoot.querySelector("maintenance-seasonal-overrides-dialog");if(!t||!this._selectedEntryId)return;let i=e.adaptive_config?.seasonal_overrides;t.open(this._selectedEntryId,e.id,i)}async _reanalyzeInterval(e,t){try{let i=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/analyze_interval",entry_id:e,task_id:t});i.recommended_interval?this._showToast(`${s("reanalyze_result",this._lang)}: ${i.recommended_interval} ${s("days",this._lang)} (${s(`confidence_${i.confidence}`,this._lang)}, ${i.data_points} ${s("data_points",this._lang)})`):this._showToast(s("reanalyze_insufficient_data",this._lang)),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}}async _promptSkipTask(e,t){let i=this.shadowRoot.querySelector("maintenance-confirm-dialog");if(!i)return;let a=await i.prompt({title:s("skip",this._lang),message:s("skip_reason_prompt",this._lang),confirmText:s("skip",this._lang),inputLabel:s("reason_optional",this._lang),inputType:"text"});a.confirmed&&this._skipTask(e,t,a.value||void 0)}async _promptResetTask(e,t){let i=this.shadowRoot.querySelector("maintenance-confirm-dialog");if(!i)return;let a=await i.prompt({title:s("reset",this._lang),message:s("reset_date_prompt",this._lang),confirmText:s("reset",this._lang),inputLabel:s("reset_date_optional",this._lang),inputType:"date"});a.confirmed&&this._resetTask(e,t,a.value||void 0)}async _postponeTask(e,t,i){this._actionLoading=!0;try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/postpone",entry_id:e,task_id:t,until:i}),this._showToast(s("postponed",this._lang)),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _promptPostponeTask(e,t){let i=this.shadowRoot.querySelector("maintenance-confirm-dialog");if(!i)return;let a=await i.prompt({title:s("postpone",this._lang),message:s("postpone_date_prompt",this._lang),confirmText:s("postpone",this._lang),inputLabel:s("postpone_date_label",this._lang),inputType:"date"});!a.confirmed||!a.value||this._postponeTask(e,t,a.value)}async _snoozeTask(e,t){this._actionLoading=!0;try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/snooze",entry_id:e,task_id:t}),this._showToast(s("snoozed",this._lang))}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}_dismissSuggestion(e,t){e&&t&&this._dismissedSuggestions.add(`${e}_${t}`),this.requestUpdate()}async _handleQuickComplete(e,t,i){try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/quick_complete",entry_id:e,task_id:t}),this._showToast(s("quick_complete_success",this._lang))}catch(a){(a?.code||"")==="no_defaults"?this._openCompleteDialog(e,t,i.name,this._features.checklists?i.checklist:void 0,this._features.adaptive&&!!i.adaptive_config?.enabled):this._showToast(s("action_error",this._lang))}}async _printTaskWorksheet(e,t){let i=this._getObject(e),a=i?.tasks.find(l=>l.id===t);if(!(!i||!a)){this._actionLoading=!0;try{let l={type:"maintenance_supporter/qr/generate",entry_id:e,task_id:t,url_mode:"server"},[c,p]=await Promise.all([this.hass.connection.sendMessagePromise({...l,action:"view"}).catch(()=>null),this.hass.connection.sendMessagePromise({...l,action:"complete"}).catch(()=>null)]),u=null;try{let R=((await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/documents/list",entry_id:e})).documents||[]).find(L=>L.kind==="file"&&L.mime==="application/pdf"&&(L.task_ids||[]).includes(t)&&L.task_pages?.[t]);if(R){let L=R.task_pages[t],I=4,T=await this.hass.connection.sendMessagePromise({type:"auth/sign_path",path:`/api/maintenance_supporter/document/${R.id}/excerpt?start=${L}&count=${I}`,expires:3600});u={title:R.title||R.filename||"Manual",startPage:L,endPage:L+I-1,url:new URL(T.path,window.location.origin).toString(),vendorBase:new URL("/maintenance_supporter_vendor",window.location.origin).toString()}}}catch{}let g=this._lang,m={title:s("worksheet",g),object:s("object",g),type:s("maintenance_type",g),interval:s("interval",g),nextDue:s("next_due",g),lastDone:s("last_performed",g),priority:s("priority",g),checklist:s("checklist",g),notes:s("notes_label",g),scanView:s("worksheet_scan_view",g),scanComplete:s("worksheet_scan_complete",g),manualExcerpt:s("worksheet_manual_excerpt",g),pages:s("worksheet_pages",g),printedOn:s("worksheet_printed",g),never:s("worksheet_never",g),typeLabel:C=>s(C,g),statusLabel:C=>s(C,g),parts:s("consumes_parts_label",g)},v=i.parts||[],b=(a.consumes_parts||[]).map(C=>{let R=v.find(T=>T.id===C.part_id);if(!R)return"";let L=R.stock!==null&&R.stock!==void 0?` (${R.stock}${R.unit?" "+R.unit:""})`:"",I=R.storage_location?` \u2014 ${R.storage_location}`:"";return`${C.quantity}\xD7 ${R.name}${L}${I}`}).filter(Boolean),$=ki(a,i.object.name,m,C=>B(C,g),C=>Xe(C,g),c?.svg_data_uri||null,p?.svg_data_uri||null,u,new Date().toISOString(),b),f=URL.createObjectURL(new Blob([$],{type:"text/html"}));window.open(f,"_blank"),setTimeout(()=>URL.revokeObjectURL(f),6e4)}finally{this._actionLoading=!1}}}_openCompleteDialog(e,t,i,a,l){let c=this.shadowRoot.querySelector("maintenance-complete-dialog");if(!c)return;c.entryId=e,c.taskId=t,c.taskName=i,c.lang=this._lang,c.checklist=a||[],c.adaptiveEnabled=!!l;let p=this._objects.find(v=>v.entry_id===e)?.tasks.find(v=>v.id===t);c.taskType=p?.type||"",c.readingUnit=p?.reading_unit||"";let u=this._objects.find(v=>v.entry_id===e)?.parts||[],g=new Map(u.map(v=>[v.id,v])),m=p?.part_ref?g.get(p.part_ref.part_id):void 0;c.restockDefault=p?.part_ref?m?.restock_quantity??1:null,c.consumesInfo=(p?.consumes_parts||[]).map(v=>{let b=g.get(v.part_id);if(!b)return"";let $=b.storage_location?` \u2014 ${b.storage_location}`:"",f=b.stock!==null&&b.stock!==void 0?` (${b.stock}${b.unit?" "+b.unit:""})`:"";return`${v.quantity}\xD7 ${b.name}${f}${$}`}).filter(Boolean),c.parts=p?.part_ref?[]:u,c.consumesParts=p?.part_ref?[]:p?.consumes_parts||[],c.open()}_openQrForObject(e,t){this.shadowRoot.querySelector("maintenance-qr-dialog")?.openForObject(e,t)}_openQrForTask(e,t,i,a){this.shadowRoot.querySelector("maintenance-qr-dialog")?.openForTask(e,t,i,a)}render(){return o` + `}_bulkKey(e){return`${e.entry_id}:${e.task_id}`}_toggleBulkMode(){this._bulkMode=!this._bulkMode,this._bulkMode||(this._bulkSelected=new Set)}_toggleBulkRow(e){let t=this._bulkKey(e),i=new Set(this._bulkSelected);i.has(t)?i.delete(t):i.add(t),this._bulkSelected=i}_bulkSelectAll(e){let t=e.map(a=>this._bulkKey(a)),i=t.every(a=>this._bulkSelected.has(a));this._bulkSelected=i?new Set:new Set(t)}async _runBulk(e,t,i,a){let l=e.filter(p=>this._bulkSelected.has(this._bulkKey(p)));if(l.length===0)return;this._actionLoading=!0;let c=0;for(let p of l)try{await this.hass.connection.sendMessagePromise(t(p)),c++}catch{}this._actionLoading=!1,this._bulkSelected=new Set,this._bulkMode=!1,await this._loadData(),a&&c>0?this._showUndoToast(i(c),a):this._showToast(i(c))}_bulkComplete(e){this._runBulk(e,t=>({type:"maintenance_supporter/task/complete",entry_id:t.entry_id,task_id:t.task_id}),t=>s("bulk_completed",this._lang).replace("{n}",String(t)))}_bulkArchive(e){let t=e.filter(i=>this._bulkSelected.has(this._bulkKey(i))).map(i=>({entry_id:i.entry_id,task_id:i.task_id}));this._runBulk(e,i=>({type:"maintenance_supporter/task/archive",entry_id:i.entry_id,task_id:i.task_id}),i=>s("bulk_archived",this._lang).replace("{n}",String(i)),async()=>{for(let i of t)try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/unarchive",entry_id:i.entry_id,task_id:i.task_id})}catch{}await this._loadData()})}async _deleteObject(e){if(await this.shadowRoot.querySelector("maintenance-confirm-dialog")?.confirm({title:s("delete",this._lang),message:s("confirm_delete_object",this._lang),confirmText:s("delete",this._lang),danger:!0}))try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/delete",entry_id:e}),this._showOverview(),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}}_printObjectReport(e){let t=this._getObject(e);if(!t)return;let i=this._lang,a={title:s("report_title",i),generated:s("report_generated",i),manufacturer:s("manufacturer",i),model:s("model",i),serial:s("serial_number_label",i),installed:s("installed",i),warranty:s("warranty",i),area:s("area",i),notes:s("report_notes",i),tasksHeading:s("tasks",i),colTask:s("task_name",i),colType:s("report_col_type",i),colStatus:s("report_col_status",i),colSchedule:s("report_col_schedule",i),colLastDone:s("last_performed",i),colNextDue:s("next_due",i),colCost:s("cost",i),colTimes:s("report_times_done",i),totalCost:s("report_total_cost",i),scheduleLabel:p=>Xe(p,i),none:"\u2014",statusLabel:p=>s(p,i),typeLabel:p=>s(p,i)},l=wi(t.object,t.tasks,a,p=>p?B(p,i):"",this._budget?.currency_symbol||je,new Date().toISOString()),c=URL.createObjectURL(new Blob([l],{type:"text/html"}));window.open(c,"_blank"),setTimeout(()=>URL.revokeObjectURL(c),6e4)}async _duplicateObject(e){this._actionLoading=!0;try{let t=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/duplicate",entry_id:e});await this._loadData(),this._showToast(s("object_duplicated",this._lang)),t?.entry_id&&this._showObject(t.entry_id)}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _deleteTask(e,t){if(await this.shadowRoot.querySelector("maintenance-confirm-dialog")?.confirm({title:s("delete",this._lang),message:s("confirm_delete_task",this._lang),confirmText:s("delete",this._lang),danger:!0}))try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/delete",entry_id:e,task_id:t}),this._showObject(e),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}}async _duplicateTask(e,t){this._moreMenuOpen=!1,this._actionLoading=!0;try{let i=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/duplicate",entry_id:e,task_id:t});await this._loadData(),this._showToast(s("task_duplicated",this._lang)),i?.task_id&&this._showTask(e,i.task_id)}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _toggleArchiveTask(e,t,i){this._actionLoading=!0;try{await this.hass.connection.sendMessagePromise({type:i?"maintenance_supporter/task/unarchive":"maintenance_supporter/task/archive",entry_id:e,task_id:t}),await this._loadData(),i||this._showUndoToast(s("task_archived",this._lang),()=>this._toggleArchiveTask(e,t,!0))}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _toggleArchiveObject(e,t){try{await this.hass.connection.sendMessagePromise({type:t?"maintenance_supporter/object/unarchive":"maintenance_supporter/object/archive",entry_id:e}),await this._loadData(),t||this._showUndoToast(s("object_archived",this._lang),()=>this._toggleArchiveObject(e,!0))}catch{this._showToast(s("action_error",this._lang))}}async _togglePauseObject(e,t){if(!t){let a=await this.shadowRoot.querySelector("maintenance-confirm-dialog")?.prompt({title:s("pause_object",this._lang),message:s("pause_until_prompt",this._lang),confirmText:s("pause_object",this._lang),inputLabel:s("pause_until_label",this._lang),inputType:"date"});if(!a?.confirmed)return;try{let l={type:"maintenance_supporter/object/pause",entry_id:e};a.value&&(l.until=a.value),await this.hass.connection.sendMessagePromise(l),await this._loadData(),this._showUndoToast(s("object_paused",this._lang),()=>this._togglePauseObject(e,!0))}catch(l){this._showToast(j(l,this._lang))}return}try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/resume",entry_id:e}),await this._loadData(),this._showToast(s("object_resumed",this._lang))}catch(i){this._showToast(j(i,this._lang))}}async _replaceObject(e,t){let a=await this.shadowRoot.querySelector("maintenance-confirm-dialog")?.prompt({title:s("replace_object",this._lang),message:s("replace_object_prompt",this._lang),confirmText:s("replace_object",this._lang),inputLabel:s("replace_name_label",this._lang),inputType:"text",inputValue:t});if(a?.confirmed){this._actionLoading=!0;try{let l=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/object/replace",entry_id:e,name:a.value||t});await this._loadData(),this._showToast(s("object_replaced",this._lang)),l?.entry_id&&this._showObject(l.entry_id)}catch(l){this._showToast(j(l,this._lang))}finally{this._actionLoading=!1}}}async _skipTask(e,t,i){this._actionLoading=!0;try{let a={type:"maintenance_supporter/task/skip",entry_id:e,task_id:t};i&&(a.reason=i),await this.hass.connection.sendMessagePromise(a),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _resetTask(e,t,i){this._actionLoading=!0;try{let a={type:"maintenance_supporter/task/reset",entry_id:e,task_id:t};i&&(a.date=i),await this.hass.connection.sendMessagePromise(a),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _applySuggestion(e,t,i){try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/apply_suggestion",entry_id:e,task_id:t,interval:i}),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}}_openSeasonalOverrides(e){let t=this.shadowRoot.querySelector("maintenance-seasonal-overrides-dialog");if(!t||!this._selectedEntryId)return;let i=e.adaptive_config?.seasonal_overrides;t.open(this._selectedEntryId,e.id,i)}async _reanalyzeInterval(e,t){try{let i=await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/analyze_interval",entry_id:e,task_id:t});i.recommended_interval?this._showToast(`${s("reanalyze_result",this._lang)}: ${i.recommended_interval} ${s("days",this._lang)} (${s(`confidence_${i.confidence}`,this._lang)}, ${i.data_points} ${s("data_points",this._lang)})`):this._showToast(s("reanalyze_insufficient_data",this._lang)),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}}async _promptSkipTask(e,t){let i=this.shadowRoot.querySelector("maintenance-confirm-dialog");if(!i)return;let a=await i.prompt({title:s("skip",this._lang),message:s("skip_reason_prompt",this._lang),confirmText:s("skip",this._lang),inputLabel:s("reason_optional",this._lang),inputType:"text"});a.confirmed&&this._skipTask(e,t,a.value||void 0)}async _promptResetTask(e,t){let i=this.shadowRoot.querySelector("maintenance-confirm-dialog");if(!i)return;let a=await i.prompt({title:s("reset",this._lang),message:s("reset_date_prompt",this._lang),confirmText:s("reset",this._lang),inputLabel:s("reset_date_optional",this._lang),inputType:"date"});a.confirmed&&this._resetTask(e,t,a.value||void 0)}async _postponeTask(e,t,i){this._actionLoading=!0;try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/postpone",entry_id:e,task_id:t,until:i}),this._showToast(s("postponed",this._lang)),await this._loadData()}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}async _promptPostponeTask(e,t){let i=this.shadowRoot.querySelector("maintenance-confirm-dialog");if(!i)return;let a=await i.prompt({title:s("postpone",this._lang),message:s("postpone_date_prompt",this._lang),confirmText:s("postpone",this._lang),inputLabel:s("postpone_date_label",this._lang),inputType:"date"});!a.confirmed||!a.value||this._postponeTask(e,t,a.value)}async _snoozeTask(e,t){this._actionLoading=!0;try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/snooze",entry_id:e,task_id:t}),this._showToast(s("snoozed",this._lang))}catch{this._showToast(s("action_error",this._lang))}finally{this._actionLoading=!1}}_dismissSuggestion(e,t){e&&t&&this._dismissedSuggestions.add(`${e}_${t}`),this.requestUpdate()}async _handleQuickComplete(e,t,i){try{await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/task/quick_complete",entry_id:e,task_id:t}),this._showToast(s("quick_complete_success",this._lang))}catch(a){(a?.code||"")==="no_defaults"?this._openCompleteDialog(e,t,i.name,this._features.checklists?i.checklist:void 0,this._features.adaptive&&!!i.adaptive_config?.enabled):this._showToast(s("action_error",this._lang))}}async _printTaskWorksheet(e,t){let i=this._getObject(e),a=i?.tasks.find(l=>l.id===t);if(!(!i||!a)){this._actionLoading=!0;try{let l={type:"maintenance_supporter/qr/generate",entry_id:e,task_id:t,url_mode:"server"},[c,p]=await Promise.all([this.hass.connection.sendMessagePromise({...l,action:"view"}).catch(()=>null),this.hass.connection.sendMessagePromise({...l,action:"complete"}).catch(()=>null)]),u=null;try{let M=((await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/documents/list",entry_id:e})).documents||[]).find(L=>L.kind==="file"&&L.mime==="application/pdf"&&(L.task_ids||[]).includes(t)&&L.task_pages?.[t]);if(M){let L=M.task_pages[t],I=4,T=await this.hass.connection.sendMessagePromise({type:"auth/sign_path",path:`/api/maintenance_supporter/document/${M.id}/excerpt?start=${L}&count=${I}`,expires:3600});u={title:M.title||M.filename||"Manual",startPage:L,endPage:L+I-1,url:new URL(T.path,window.location.origin).toString(),vendorBase:new URL("/maintenance_supporter_vendor",window.location.origin).toString()}}}catch{}let g=this._lang,m={title:s("worksheet",g),object:s("object",g),type:s("maintenance_type",g),interval:s("interval",g),nextDue:s("next_due",g),lastDone:s("last_performed",g),priority:s("priority",g),checklist:s("checklist",g),notes:s("notes_label",g),scanView:s("worksheet_scan_view",g),scanComplete:s("worksheet_scan_complete",g),manualExcerpt:s("worksheet_manual_excerpt",g),pages:s("worksheet_pages",g),printedOn:s("worksheet_printed",g),never:s("worksheet_never",g),typeLabel:C=>s(C,g),statusLabel:C=>s(C,g),parts:s("consumes_parts_label",g)},v=i.parts||[],b=(a.consumes_parts||[]).map(C=>{let M=v.find(T=>T.id===C.part_id);if(!M)return"";let L=M.stock!==null&&M.stock!==void 0?` (${M.stock}${M.unit?" "+M.unit:""})`:"",I=M.storage_location?` \u2014 ${M.storage_location}`:"";return`${C.quantity}\xD7 ${M.name}${L}${I}`}).filter(Boolean),$=ki(a,i.object.name,m,C=>B(C,g),C=>Xe(C,g),c?.svg_data_uri||null,p?.svg_data_uri||null,u,new Date().toISOString(),b),f=URL.createObjectURL(new Blob([$],{type:"text/html"}));window.open(f,"_blank"),setTimeout(()=>URL.revokeObjectURL(f),6e4)}finally{this._actionLoading=!1}}}_openCompleteDialog(e,t,i,a,l){let c=this.shadowRoot.querySelector("maintenance-complete-dialog");if(!c)return;c.entryId=e,c.taskId=t,c.taskName=i,c.lang=this._lang,c.checklist=a||[],c.adaptiveEnabled=!!l;let p=this._objects.find(v=>v.entry_id===e)?.tasks.find(v=>v.id===t);c.taskType=p?.type||"",c.readingUnit=p?.reading_unit||"";let u=this._objects.find(v=>v.entry_id===e)?.parts||[],g=new Map(u.map(v=>[v.id,v])),m=p?.part_ref?g.get(p.part_ref.part_id):void 0;c.restockDefault=p?.part_ref?m?.restock_quantity??1:null,c.consumesInfo=(p?.consumes_parts||[]).map(v=>{let b=g.get(v.part_id);if(!b)return"";let $=b.storage_location?` \u2014 ${b.storage_location}`:"",f=b.stock!==null&&b.stock!==void 0?` (${b.stock}${b.unit?" "+b.unit:""})`:"";return`${v.quantity}\xD7 ${b.name}${f}${$}`}).filter(Boolean),c.parts=p?.part_ref?[]:u,c.consumesParts=p?.part_ref?[]:p?.consumes_parts||[],c.open()}_openQrForObject(e,t){this.shadowRoot.querySelector("maintenance-qr-dialog")?.openForObject(e,t)}_openQrForTask(e,t,i,a){this.shadowRoot.querySelector("maintenance-qr-dialog")?.openForTask(e,t,i,a)}render(){return o`
${this.narrow||this._view!=="overview"?this._renderHeader():h}
@@ -9064,28 +9062,28 @@ ${p?`
${p}
`:""}

${t.name}

${a?h:o` - {this.shadowRoot.querySelector("maintenance-object-dialog")?.openEdit(e.entry_id,t)}}>${s("edit",i)} {this.shadowRoot.querySelector("maintenance-task-dialog")?.openCreate(e.entry_id)}}>${s("add_task",i)} - this._duplicateObject(e.entry_id)}> - ${s("duplicate",i)} - - this._toggleArchiveObject(e.entry_id,!!t.archived)}> - - ${t.archived?s("unarchive_object",i):s("archive_object",i)} - - ${t.archived?h:o` - this._togglePauseObject(e.entry_id,!!t.paused)}> - - ${t.paused?s("resume_object",i):s("pause_object",i)} - - this._replaceObject(e.entry_id,t.name)}> - ${s("replace_object",i)} - - `} - this._deleteObject(e.entry_id)}>${s("delete",i)} + {this.shadowRoot.querySelector("maintenance-object-dialog")?.openEdit(e.entry_id,t)}}>${s("edit",i)} `} - this._openQrForObject(e.entry_id,t.name)}> ${s("qr_code",i)} - this._printObjectReport(e.entry_id)}> ${s("report_button",i)} +
+ this._toggleObjMenu()}> + ${this._objMenuOpen?o` + + `:h} +
${t.paused?o`

@@ -9157,10 +9155,10 @@ ${p?`

${p}
`:""} @parts-changed=${()=>this._loadData()} >
- `}_toggleMoreMenu(){if(this._moreMenuOpen=!this._moreMenuOpen,this._moreMenuOpen){let e=()=>{this._moreMenuOpen=!1,document.removeEventListener("click",e)};setTimeout(()=>document.addEventListener("click",e,{once:!0}),0)}}_closeMoreMenu(){this._moreMenuOpen=!1}get _sparklineCtx(){return{lang:this._lang,detailStatsData:this._detailStatsData,hasStatsService:!!this._statsService,isCounterEntity:e=>this._isCounterEntity(e),rangeDays:this._chartRangeDays,setRangeDays:e=>this._setChartRange(e),hideOutliers:this._hideOutliers,setHideOutliers:e=>this._setHideOutliers(e)}}_toggleSection(e){let t=new Set(this._collapsedSections);t.has(e)?t.delete(e):t.add(e),this._collapsedSections=t;try{localStorage.setItem(W.collapsedSections,JSON.stringify([...t]))}catch{}}_historyCtx(){let e=this._selectedEntryId&&this._selectedTaskId?this._getObject(this._selectedEntryId)?.tasks.find(i=>i.id===this._selectedTaskId):void 0,t=(e?.history||[]).filter(i=>i.reading_value!=null).sort((i,a)=>i.timestamp.localeCompare(a.timestamp));return{lang:this._lang,hass:this.hass,filter:this._historyFilter,search:this._historySearch,currencySymbol:this._budget?.currency_symbol||je,setFilter:i=>{this._historyFilter=i},setSearch:i=>{this._historySearch=i},openEdit:i=>this._openHistoryEdit(i),readingUnit:e?.reading_unit??null,readingDelta:i=>{let a=t.findIndex(l=>l.timestamp===i.timestamp);return a<=0?null:i.reading_value-t[a-1].reading_value}}}_taskDetailCtx(){let e=this._selectedEntryId,t=this._selectedTaskId,i=this._getObject(e);return{lang:this._lang,hass:this.hass,entryId:e,taskId:t,objectName:i?.object.name||"",objectDocUrl:i?.object?.documentation_url??null,isOperator:this._isOperator,actionLoading:this._actionLoading,moreMenuOpen:this._moreMenuOpen,activeTab:this._activeTab,features:this._features,currencySymbol:this._budget?.currency_symbol||je,collapsedSections:this._collapsedSections,costDurationToggle:this._costDurationToggle,suggestionDismissed:this._dismissedSuggestions.has(`${e}_${t}`),sparkline:this._sparklineCtx,history:this._historyCtx(),getUserName:a=>this._userService?.getUserName(a)??null,setActiveTab:a=>{this._activeTab=a},toggleSection:a=>this._toggleSection(a),setCostDurationToggle:a=>{this._costDurationToggle=a},showTaskView:()=>{this._view="task"},showObject:()=>this._showObject(e),toggleMoreMenu:()=>this._toggleMoreMenu(),closeMoreMenu:()=>this._closeMoreMenu(),openEdit:a=>{this.shadowRoot.querySelector("maintenance-task-dialog")?.openEdit(e,a)},openComplete:a=>this._openCompleteDialog(e,t,a.name,this._features.checklists?a.checklist:void 0,this._features.adaptive&&!!a.adaptive_config?.enabled),promptSkip:()=>this._promptSkipTask(e,t),toggleArchive:a=>this._toggleArchiveTask(e,t,a),openQr:a=>this._openQrForTask(e,t,i?.object.name||"",a),duplicateTask:()=>this._duplicateTask(e,t),promptReset:()=>this._promptResetTask(e,t),promptPostpone:()=>this._promptPostponeTask(e,t),snoozeTask:()=>this._snoozeTask(e,t),printWorksheet:()=>this._printTaskWorksheet(e,t),deleteTask:()=>this._deleteTask(e,t),applySuggestion:a=>this._applySuggestion(e,t,a),reanalyze:()=>this._reanalyzeInterval(e,t),dismissSuggestion:()=>this._dismissSuggestion(e,t),openSeasonalOverrides:a=>this._openSeasonalOverrides(a)}}_renderTaskDetail(){if(!this._selectedEntryId||!this._selectedTaskId)return h;let e=this._getTask(this._selectedEntryId,this._selectedTaskId);return e?o`{let e=()=>{this._objMenuOpen=!1,document.removeEventListener("click",e)};document.addEventListener("click",e)},0)}_closeObjMenu(){this._objMenuOpen=!1}_toggleMoreMenu(){if(this._moreMenuOpen=!this._moreMenuOpen,this._moreMenuOpen){let e=()=>{this._moreMenuOpen=!1,document.removeEventListener("click",e)};setTimeout(()=>document.addEventListener("click",e,{once:!0}),0)}}_closeMoreMenu(){this._moreMenuOpen=!1}get _sparklineCtx(){return{lang:this._lang,detailStatsData:this._detailStatsData,hasStatsService:!!this._statsService,isCounterEntity:e=>this._isCounterEntity(e),rangeDays:this._chartRangeDays,setRangeDays:e=>this._setChartRange(e),hideOutliers:this._hideOutliers,setHideOutliers:e=>this._setHideOutliers(e)}}_toggleSection(e){let t=new Set(this._collapsedSections);t.has(e)?t.delete(e):t.add(e),this._collapsedSections=t;try{localStorage.setItem(W.collapsedSections,JSON.stringify([...t]))}catch{}}_historyCtx(){let e=this._selectedEntryId&&this._selectedTaskId?this._getObject(this._selectedEntryId)?.tasks.find(i=>i.id===this._selectedTaskId):void 0,t=(e?.history||[]).filter(i=>i.reading_value!=null).sort((i,a)=>i.timestamp.localeCompare(a.timestamp));return{lang:this._lang,hass:this.hass,filter:this._historyFilter,search:this._historySearch,currencySymbol:this._budget?.currency_symbol||je,setFilter:i=>{this._historyFilter=i},setSearch:i=>{this._historySearch=i},openEdit:i=>this._openHistoryEdit(i),readingUnit:e?.reading_unit??null,readingDelta:i=>{let a=t.findIndex(l=>l.timestamp===i.timestamp);return a<=0?null:i.reading_value-t[a-1].reading_value}}}_taskDetailCtx(){let e=this._selectedEntryId,t=this._selectedTaskId,i=this._getObject(e);return{lang:this._lang,hass:this.hass,entryId:e,taskId:t,objectName:i?.object.name||"",objectDocUrl:i?.object?.documentation_url??null,isOperator:this._isOperator,actionLoading:this._actionLoading,moreMenuOpen:this._moreMenuOpen,activeTab:this._activeTab,features:this._features,currencySymbol:this._budget?.currency_symbol||je,collapsedSections:this._collapsedSections,costDurationToggle:this._costDurationToggle,suggestionDismissed:this._dismissedSuggestions.has(`${e}_${t}`),sparkline:this._sparklineCtx,history:this._historyCtx(),getUserName:a=>this._userService?.getUserName(a)??null,setActiveTab:a=>{this._activeTab=a},toggleSection:a=>this._toggleSection(a),setCostDurationToggle:a=>{this._costDurationToggle=a},showTaskView:()=>{this._view="task"},showObject:()=>this._showObject(e),toggleMoreMenu:()=>this._toggleMoreMenu(),closeMoreMenu:()=>this._closeMoreMenu(),openEdit:a=>{this.shadowRoot.querySelector("maintenance-task-dialog")?.openEdit(e,a)},openComplete:a=>this._openCompleteDialog(e,t,a.name,this._features.checklists?a.checklist:void 0,this._features.adaptive&&!!a.adaptive_config?.enabled),promptSkip:()=>this._promptSkipTask(e,t),toggleArchive:a=>this._toggleArchiveTask(e,t,a),openQr:a=>this._openQrForTask(e,t,i?.object.name||"",a),duplicateTask:()=>this._duplicateTask(e,t),promptReset:()=>this._promptResetTask(e,t),promptPostpone:()=>this._promptPostponeTask(e,t),snoozeTask:()=>this._snoozeTask(e,t),printWorksheet:()=>this._printTaskWorksheet(e,t),deleteTask:()=>this._deleteTask(e,t),applySuggestion:a=>this._applySuggestion(e,t,a),reanalyze:()=>this._reanalyzeInterval(e,t),dismissSuggestion:()=>this._dismissSuggestion(e,t),openSeasonalOverrides:a=>this._openSeasonalOverrides(a)}}_renderTaskDetail(){if(!this._selectedEntryId||!this._selectedTaskId)return h;let e=this._getTask(this._selectedEntryId,this._selectedTaskId);return e?o``:o`

Task not found.

`}_openHistoryEdit(e){if(!this._selectedEntryId||!this._selectedTaskId)return;let t={entry_id:this._selectedEntryId,task_id:this._selectedTaskId,original_timestamp:e.timestamp,type:e.type,timestamp:e.timestamp,notes:e.notes??null,cost:e.cost??null,duration:e.duration??null,completed_by:e.completed_by??null};this.shadowRoot?.querySelector("maintenance-history-edit-dialog")?.openEdit(t)}};E.styles=[bt,Ei],d([y({attribute:!1})],E.prototype,"hass",2),d([y({type:Boolean,reflect:!0})],E.prototype,"narrow",2),d([y({attribute:!1})],E.prototype,"panel",2),d([_()],E.prototype,"_objects",2),d([_()],E.prototype,"_stats",2),d([_()],E.prototype,"_view",2),d([_()],E.prototype,"_selectedEntryId",2),d([_()],E.prototype,"_selectedTaskId",2),d([_()],E.prototype,"_filterStatus",2),d([_()],E.prototype,"_filterUser",2),d([_()],E.prototype,"_filterLabel",2),d([_()],E.prototype,"_savedViews",2),d([_()],E.prototype,"_activeViewId",2),d([_()],E.prototype,"_unsub",2),d([_()],E.prototype,"_chartRangeDays",2),d([_()],E.prototype,"_hideOutliers",2),d([_()],E.prototype,"_historyFilter",2),d([_()],E.prototype,"_budget",2),d([_()],E.prototype,"_groups",2),d([_()],E.prototype,"_detailStatsData",2),d([_()],E.prototype,"_miniStatsData",2),d([_()],E.prototype,"_features",2),d([_()],E.prototype,"_adminPanelUserIds",2),d([_()],E.prototype,"_operatorWriteEnabled",2),d([_()],E.prototype,"_defaultWarningDays",2),d([_()],E.prototype,"_actionLoading",2),d([_()],E.prototype,"_moreMenuOpen",2),d([_()],E.prototype,"_toastMessage",2),d([_()],E.prototype,"_toastUndo",2),d([_()],E.prototype,"_toastActionLabel",2),d([_()],E.prototype,"_filtersOpen",2),d([_()],E.prototype,"_actionsMenuOpen",2),d([_()],E.prototype,"_batteryFleetSetupAvailable",2),d([_()],E.prototype,"_overviewTab",2),d([_()],E.prototype,"_activeTab",2),d([_()],E.prototype,"_costDurationToggle",2),d([_()],E.prototype,"_historySearch",2),d([_()],E.prototype,"_sortMode",2),d([_()],E.prototype,"_objectSortMode",2),d([_()],E.prototype,"_groupByMode",2),d([_()],E.prototype,"_objectViewMode",2),d([_()],E.prototype,"_objectsTableColumns",2),d([_()],E.prototype,"_showArchived",2),d([_()],E.prototype,"_bulkMode",2),d([_()],E.prototype,"_bulkSelected",2),d([_()],E.prototype,"_virtStart",2),d([_()],E.prototype,"_virtEnd",2),d([_()],E.prototype,"_collapsedSections",2),d([_()],E.prototype,"_paletteOpen",2),d([_()],E.prototype,"_paletteQuery",2),d([_()],E.prototype,"_paletteActive",2),d([_()],E.prototype,"_templateGalleryOpen",2),d([_()],E.prototype,"_templates",2),d([_()],E.prototype,"_templateCategories",2),d([_()],E.prototype,"_templateBusy",2),E=d([bi("maintenance-supporter-panel")],E);export{E as MaintenanceSupporterPanel}; + >
`:o`

Task not found.

`}_openHistoryEdit(e){if(!this._selectedEntryId||!this._selectedTaskId)return;let t={entry_id:this._selectedEntryId,task_id:this._selectedTaskId,original_timestamp:e.timestamp,type:e.type,timestamp:e.timestamp,notes:e.notes??null,cost:e.cost??null,duration:e.duration??null,completed_by:e.completed_by??null};this.shadowRoot?.querySelector("maintenance-history-edit-dialog")?.openEdit(t)}};E.styles=[bt,Ei],d([y({attribute:!1})],E.prototype,"hass",2),d([y({type:Boolean,reflect:!0})],E.prototype,"narrow",2),d([y({attribute:!1})],E.prototype,"panel",2),d([_()],E.prototype,"_objects",2),d([_()],E.prototype,"_stats",2),d([_()],E.prototype,"_view",2),d([_()],E.prototype,"_selectedEntryId",2),d([_()],E.prototype,"_selectedTaskId",2),d([_()],E.prototype,"_filterStatus",2),d([_()],E.prototype,"_filterUser",2),d([_()],E.prototype,"_filterLabel",2),d([_()],E.prototype,"_savedViews",2),d([_()],E.prototype,"_activeViewId",2),d([_()],E.prototype,"_unsub",2),d([_()],E.prototype,"_chartRangeDays",2),d([_()],E.prototype,"_hideOutliers",2),d([_()],E.prototype,"_historyFilter",2),d([_()],E.prototype,"_budget",2),d([_()],E.prototype,"_groups",2),d([_()],E.prototype,"_detailStatsData",2),d([_()],E.prototype,"_miniStatsData",2),d([_()],E.prototype,"_features",2),d([_()],E.prototype,"_adminPanelUserIds",2),d([_()],E.prototype,"_operatorWriteEnabled",2),d([_()],E.prototype,"_defaultWarningDays",2),d([_()],E.prototype,"_actionLoading",2),d([_()],E.prototype,"_moreMenuOpen",2),d([_()],E.prototype,"_objMenuOpen",2),d([_()],E.prototype,"_toastMessage",2),d([_()],E.prototype,"_toastUndo",2),d([_()],E.prototype,"_toastActionLabel",2),d([_()],E.prototype,"_filtersOpen",2),d([_()],E.prototype,"_actionsMenuOpen",2),d([_()],E.prototype,"_batteryFleetSetupAvailable",2),d([_()],E.prototype,"_overviewTab",2),d([_()],E.prototype,"_activeTab",2),d([_()],E.prototype,"_costDurationToggle",2),d([_()],E.prototype,"_historySearch",2),d([_()],E.prototype,"_sortMode",2),d([_()],E.prototype,"_objectSortMode",2),d([_()],E.prototype,"_groupByMode",2),d([_()],E.prototype,"_objectViewMode",2),d([_()],E.prototype,"_objectsTableColumns",2),d([_()],E.prototype,"_showArchived",2),d([_()],E.prototype,"_bulkMode",2),d([_()],E.prototype,"_bulkSelected",2),d([_()],E.prototype,"_virtStart",2),d([_()],E.prototype,"_virtEnd",2),d([_()],E.prototype,"_collapsedSections",2),d([_()],E.prototype,"_paletteOpen",2),d([_()],E.prototype,"_paletteQuery",2),d([_()],E.prototype,"_paletteActive",2),d([_()],E.prototype,"_templateGalleryOpen",2),d([_()],E.prototype,"_templates",2),d([_()],E.prototype,"_templateCategories",2),d([_()],E.prototype,"_templateBusy",2),E=d([bi("maintenance-supporter-panel")],E);export{E as MaintenanceSupporterPanel}; /*! Bundled license information: @lit/reactive-element/css-tag.js: diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/__init__.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/__init__.cpython-314.pyc index 80565afe55ee3a4b0e11a00398ed01f20e2737d4..af28bdd048081bde5cf6a7ed5a25e20666350d28 100644 GIT binary patch delta 20 acmbQsG?$56n~#@^0SK-}CT-*vVgvvwp#%8< delta 20 acmbQsG?$56n~#@^0SI_%<2P~(F#-T2>jO~$ diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/action_listener.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/action_listener.cpython-314.pyc index de27972306a37ae25725f1e9d161ba54dd186952..fa7513c1aa2e5ebe41f016f5e7d0fee674f40f7e 100644 GIT binary patch delta 20 acmca)aLIsMn~#@^0SK-}CT-+)mjD1bcm)dp delta 20 acmca)aLIsMn~#@^0SI_%<2Q1n~#@^0SK-}CT-+?Cky~SM+NBs delta 20 acmeyV{!^V>n~#@^0SI_%<2Q1@69xb|kp)2j diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/battery_fleet.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/battery_fleet.cpython-314.pyc index 848671b0efc4a84cda9e60898cede5ccd2a5d94a..37247c78495c68d39097810712b9f193321a85aa 100644 GIT binary patch delta 22 ccmX@t&3LYxkz1ROmx}=iu0|$ppF delta 22 ccmX@t&3LYxkz1ROmx}=icxvM}avydD07Xp&yZ`_I diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/battery_fleet_setup.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/battery_fleet_setup.cpython-314.pyc index 07d2c19718beb0ddc58c346ff31d6ba1f5e5bb4a..a1120d57801bc80eb6c8fe1d85ed0888fc2a43d6 100644 GIT binary patch delta 20 acmX?If4ZJqn~#@^0SK-}CT-+CU=IL8qXs(w delta 20 acmX?If4ZJqn~#@^0SI_%<2Q02um=D>?FEwn diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/dates.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/dates.cpython-314.pyc index 17bf3010a248c5ca73d60290ac4ecbce343a2cde..5a6564ddf533dd36af39896b97a10a86a2d07b99 100644 GIT binary patch delta 20 acmccObH#^Sn~#@^0SK-}CT-;QR09A!#syaZ delta 20 acmccObH#^Sn~#@^0SI_%<2Q19ssR8v5CpFP diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/documents.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/documents.cpython-314.pyc index 2deeb1746803bd9001f2b95d3003440a7c839295..f27bd1d78bb380fb7e7c4d803de0ffffb58275d5 100644 GIT binary patch delta 22 ccmcckpYh^b8xEh(Xk^5LZ09b8;Hiz@$bGCH09UjI=Kufz diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/entity_analyzer.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/entity_analyzer.cpython-314.pyc index 9192a0055b2f667fb6c74c84e638b76da07d3ce6..a57ab07ea87214dd11ee06e78f32373a7dad07bd 100644 GIT binary patch delta 20 acmez8_0Nl2n~#@^0SK-}CT-*{Rs{e<4F%8u delta 20 acmez8_0Nl2n~#@^0SI_%<2P~_s{#N%R|O~l diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/global_options.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/global_options.cpython-314.pyc index f8e4e5d214b7765d6bed86b9db80aaff694a0660..55644bf1ac1640ed7f8d698aa0aa3c0ffe5706d0 100644 GIT binary patch delta 20 acmbQHG);+Hn~#@^0SK-}CT-;A76bq-83Z%{ delta 20 acmbQHG);+Hn~#@^0SI_%<2Q113jzQrV*`u; diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/i18n.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/i18n.cpython-314.pyc index def96501b23ca7e42174457e6188a7711e0701ee..4eb8d536208f14d0c535143c91a4e58ad031c6d9 100644 GIT binary patch delta 20 acmdnVwUdimn~#@^0SK-}CT--_V+8;&B?Msr delta 20 acmdnVwUdimn~#@^0SI_%<2Q2au>t@pZv(ji diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/integration_signatures.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/integration_signatures.cpython-314.pyc index 0ebe206b503f8b40b56c4847a8ca4a7904256309..8fe8c13e60cd50a3b3606592f0148b1eebf6e470 100644 GIT binary patch delta 20 acmX@ec94x*n~#@^0SK-}CT-+4V+H^((*$n- delta 20 acmX@ec94x*n~#@^0SI_%<2Q1fF#`ZA9RtSz diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/notification_manager.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/notification_manager.cpython-314.pyc index 121ecc705ffe8df7b33e44faaa729418aad7256f..e2887b8c8e06bf136e589e8e869e31942abc5f48 100644 GIT binary patch delta 22 ccmbR7jCsa0W^QdhUM>b8xEh(Xk(>8308YjRssI20 delta 22 ccmbR7jCsa0W^QdhUM>b8;Hiz@$j$p107?)B0ssI2 diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/notify_targets.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/notify_targets.cpython-314.pyc index 71b2665e50d26e123c20a276aba2b68cb38c13b6..d9d6ee0f1efe09712936f5fdaf04e806f11c5636 100644 GIT binary patch delta 20 ZcmeAb?G@$L=HumJ0D`NLNgKJFxBx2F1b6@d delta 20 ZcmeAb?G@$L=HumJ00N%c_>J66TmU8@1JD2f diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/parts.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/parts.cpython-314.pyc index b449d1f4d7b763afe79751d86079e6f4cb784a58..8624aabd78e6ad34e9e40cfb2831270651cf0c1d 100644 GIT binary patch delta 22 ccmaF2hwb8xEh(XkvlmK09Eq_zW@LL delta 22 ccmaF2hwb8;Hiz@$ekPq08u>#7XSbN diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/pause.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/pause.cpython-314.pyc index c2a218172ef180796b8c46557f2b50f160eeee16..d50b2f3db7c622a2322a5b8cac857d74226c32b3 100644 GIT binary patch delta 20 acmeyV@Kb?Xn~#@^0SK-}CT---6951_NCj8` delta 20 acmeyV@Kb?Xn~#@^0SI_%<2Q2W2><{$k_4~- diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/permissions.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/permissions.cpython-314.pyc index 33d43d3a8d2081f95e56ba812d96c5aab675e84a..f97932e48df98f88beba744487c4cc58eb26cd83 100644 GIT binary patch delta 20 acmX@1cS4U_n~#@^0SK-}CT--l6$bz|p9JUt delta 20 acmX@1cS4U_n~#@^0SI_%<2Q2KiUR;K=>$Lk diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/problem_sensors.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/problem_sensors.cpython-314.pyc index 153a11f98aa1491832136c90abfe22486ddd565f..cc20b134780b5c0247a8f1b536972c106c68afff 100644 GIT binary patch delta 20 acmdl`u%Uokn~#@^0SK-}CT-+awEzG-NCiaz delta 20 acmdl`u%Uokn~#@^0SI_%<2Q1vS^xkyk_4Rq diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/qr_generator.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/qr_generator.cpython-314.pyc index b01516ae0505292cf87fc08403d9f5f374612523..83e5f6ca18d3b02eb8e1e338bcbd53a128efce60 100644 GIT binary patch delta 20 acmccaaNU7hn~#@^0SK-}CT-;QQ2+othy_9b delta 20 acmccaaNU7hn~#@^0SI_%<2Q2qC;$L9(gd0S diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/qrcodegen.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/qrcodegen.cpython-314.pyc index 2828b17f7ab1b451a4f494ff17ffb40db367ab2c..407e4e2469d406eb8c726832198654221ad8e33e 100644 GIT binary patch delta 22 ccmZ3{$-JVInOmEWmx}=iu0|$pi06%60$p8QV diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/retention.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/retention.cpython-314.pyc index e940b5c787d04083067e3673308d18048ced1322..b0af8b424d22430f6e442dad3fbe66bb8e4e8e5b 100644 GIT binary patch delta 20 acmdlGv>}LFn~#@^0SK-}CT-+a)dT=I0R;L0 delta 20 acmdlGv>}LFn~#@^0SI_%<2Q1vY61W=O9WB? diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/sanitize.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/sanitize.cpython-314.pyc index 7c334d28913f11407a0db987633ef933685d7060..ce26aec5538025180a144204fbbeee29563ac22d 100644 GIT binary patch delta 19 YcmeCq>dxZU=HumJ0D`NLNej760WjkPw*UYD delta 19 YcmeCq>dxZU=HumJ00N%c_=Vi204&u64*&oF diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/saved_views.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/saved_views.cpython-314.pyc index 097c9173436b8983e5470ff880a5de2d93b35087..407258060c6b70635c44c576da2dcdc10371cf07 100644 GIT binary patch delta 20 acmez8_|K7Bn~#@^0SK-}CT-;YtN;K)#0EJ4 delta 20 acmez8_|K7Bn~#@^0SI_%<2Q1DRsaA#4h4|_ diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/schedule.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/schedule.cpython-314.pyc index 4f9ae769c2cbcfceeff1be512fd6359482bcb861..b7afd7d84161b2aac3c073dd04bd61a81bd48581 100644 GIT binary patch delta 22 ccmezJl<~_`Ms96BUM>b8xEh(Xk^5y40A5H3w*UYD delta 22 ccmezJl<~_`Ms96BUM>b8;Hiz@$o;Yi09ld;4*&oF diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/settings_registry.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/settings_registry.cpython-314.pyc index f76ab0f99ddd64367aae11a5dfe9424f65ba1b0d..baf36c34716acdd08e686127b0570c495ecb2707 100644 GIT binary patch delta 20 acmdmDx5bWIn~#@^0SK-}CT--_lm`Gc#{|>> delta 20 acmdmDx5bWIn~#@^0SI_%<2Q0^$^!r~5dBy9IFo delta 20 acmaFL|CFCwn~#@^0SI_%<2Q2OVg~>-1q8_e diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/threshold_calculator.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/threshold_calculator.cpython-314.pyc index 08405905bd6dc5f1756d9f8103683b72805d850a..89abc445a78204aaa1d56a8d199b8c26931c74d9 100644 GIT binary patch delta 20 acmca-aL<5Sn~#@^0SK-}CT-*nmH+@cSOqTt delta 20 acmca-aL<5Sn~#@^0SI_%<2P~#O8@{hq6CKk diff --git a/custom_components/maintenance_supporter/helpers/__pycache__/vacation.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/__pycache__/vacation.cpython-314.pyc index ab2d55b5deb542fdc3c65f131c033465eee69c6e..1cdbe055b4e0a62533e016b0e5ef96518b2eea87 100644 GIT binary patch delta 22 ccmey^!1%F&kz1ROmx}=iu0|$p=4(*99Q} diff --git a/custom_components/maintenance_supporter/helpers/signatures/__pycache__/_registry.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/signatures/__pycache__/_registry.cpython-314.pyc index 5622901745e9ee222063e8e850cc7c41a11dcce1..68e465f4cd06dbd9c5f691944db8f9e34a7d8249 100644 GIT binary patch delta 20 acmZ3(xrUQln~#@^0SK-}CT-+i$N~T@_yo5A delta 20 acmZ3(xrUQln~#@^0SI_%<2Q0IWB~vvLIe*0 diff --git a/custom_components/maintenance_supporter/helpers/signatures/__pycache__/air.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/signatures/__pycache__/air.cpython-314.pyc index 7310a936537b1e58c1f5869987c4929d6db2c2fe..12cda46e1bfdbe91ad3e6bd133387c99cc6127bc 100644 GIT binary patch delta 20 acmccWbk&Jln~#@^0SK-}CT-;QQUm}yfdxST delta 20 acmccWbk&Jln~#@^0SI_%<2Q19DFOgC%LJJK diff --git a/custom_components/maintenance_supporter/helpers/signatures/__pycache__/cars.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/signatures/__pycache__/cars.cpython-314.pyc index f6b908c33a4838bf5a81b2ab085729b1ce7370ef..a38b52b940bf68d85a7ff94919e240bb36a7fae8 100644 GIT binary patch delta 20 acmccXeAk&Py@aI diff --git a/custom_components/maintenance_supporter/helpers/signatures/__pycache__/home_it.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/signatures/__pycache__/home_it.cpython-314.pyc index 6531a2381f098496d2869847c3b1d04bc22b23b9..d1a99a9c6a1eae86a4c0755eda919961b9a43d6e 100644 GIT binary patch delta 20 acmaFM{g#_sn~#@^0SK-}CT-+?#0mg7X$5}( delta 20 acmaFM{g#_sn~#@^0SI_%<2Q0YVg&#*vjo=w diff --git a/custom_components/maintenance_supporter/helpers/signatures/__pycache__/kitchen.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/signatures/__pycache__/kitchen.cpython-314.pyc index ff7d68c8bc793c2e4ac653f02dc390fc88fe91c0..8375f4effae3e1cc6cb03c08c82623a1c246d72c 100644 GIT binary patch delta 20 acmdn)yxo~wn~#@^0SK-}CT-+iqX+;u^#y4F delta 20 acmdn)yxo~wn~#@^0SI_%<2Q1zQ3L=pKLo)5 diff --git a/custom_components/maintenance_supporter/helpers/signatures/__pycache__/locks.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/signatures/__pycache__/locks.cpython-314.pyc index 81d129a02ce4a1c6d479f20294f0e44d45aa6ba9..c66759290d4731c11ae2653964bed4ab1856676f 100644 GIT binary patch delta 20 acmcbkdPkL8n~#@^0SK-}CT--tCxI&Oyn~#@^0SK-}CT--N%K-o`d<4G$ delta 20 acmZ1>xI&Oyn~#@^0SI_%<2Q28Akp*i2 delta 20 acmca9eN&oSn~#@^0SI_%<2Q1j;{^aR+XTY^ diff --git a/custom_components/maintenance_supporter/helpers/signatures/__pycache__/vacuums.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/signatures/__pycache__/vacuums.cpython-314.pyc index 9d5fcef9e01c0a3e1f89b6378ea98e00ed01e75a..0d292ecac2ec25ecc36a2069786eb180c8f1316a 100644 GIT binary patch delta 20 acmexn^Ua1^n~#@^0SK-}CT---mIDAk&IOeK delta 20 acmexn^Ua1^n~#@^0SI_%<2Q0=%K-p87zFJA diff --git a/custom_components/maintenance_supporter/helpers/signatures/__pycache__/wallboxes.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/signatures/__pycache__/wallboxes.cpython-314.pyc index 2871886153a69c924fb842b0277b491354124fcf..bcbc807569aa4dbdb36151fc755f2bda796ce6b5 100644 GIT binary patch delta 20 ZcmeAd>KEeH=HumJ0D`NLNgKIYH~}fS1NQ&` delta 20 ZcmeAd>KEeH=HumJ00N%c_>J5woB$;Q15W?| diff --git a/custom_components/maintenance_supporter/helpers/signatures/__pycache__/xiaomi.cpython-314.pyc b/custom_components/maintenance_supporter/helpers/signatures/__pycache__/xiaomi.cpython-314.pyc index cf254e2a4e2fb14b7ffd59aca06d7eb1eb900656..5a711fc625891e946f7296d7d375805a7246f909 100644 GIT binary patch delta 20 acmaFB|A3!cn~#@^0SK-}CT--t$_@ZF^95o6 delta 20 acmaFB|A3!cn~#@^0SI_%<2Q0&Wd{H=4.3.0" ], - "version": "2.38.3" + "version": "2.39.0" } diff --git a/custom_components/maintenance_supporter/models/__pycache__/__init__.cpython-314.pyc b/custom_components/maintenance_supporter/models/__pycache__/__init__.cpython-314.pyc index 801ee6bb52524501430faa66ef291a6010d683b1..277816968c7da3192a630855125a8068e00ed153 100644 GIT binary patch delta 20 acmbQpGLeN_n~#@^0SK-}CT-+qV*&su3Io~z delta 20 acmbQpGLeN_n~#@^0SI_%<2Q1%F#!N0R0A>q diff --git a/custom_components/maintenance_supporter/models/__pycache__/maintenance_object.cpython-314.pyc b/custom_components/maintenance_supporter/models/__pycache__/maintenance_object.cpython-314.pyc index 62ed18f150309b396b3a78d7b9f542825b6f6a14..f8f4afe277f2d4b8ef95d1534335b954a372d8d6 100644 GIT binary patch delta 20 acmZ3hzE+)En~#@^0SK-}CT-+iBn$vDX9WcS delta 20 acmZ3hzE+)En~#@^0SI_%<2Q0I5(WS*u>@TJ diff --git a/custom_components/maintenance_supporter/models/__pycache__/maintenance_task.cpython-314.pyc b/custom_components/maintenance_supporter/models/__pycache__/maintenance_task.cpython-314.pyc index ea9205be2efdc9b77939a457a7d5ce4b39462a7b..993bb6f28ccbd2cb43c109aab609e5458897e502 100644 GIT binary patch delta 22 ccmdmfgK_%}Ms96BUM>b8xEh(Xky|GR08t1AU;qFB delta 22 ccmdmfgK_%}Ms96BUM>b8;Hiz@$gPtD08CN^xBvhE diff --git a/custom_components/maintenance_supporter/models/__pycache__/maintenance_type.cpython-314.pyc b/custom_components/maintenance_supporter/models/__pycache__/maintenance_type.cpython-314.pyc index 2a6770492b6e58fdcce7ef2e9e1a42f892acf3a8..159dcb46d49877e81c460bfddb7e70c6a12b352b 100644 GIT binary patch delta 20 ZcmeB`=#}8s=HumJ0D`NLNgKJDcmOIy1ONa4 delta 20 ZcmeB`=#}8s=HumJ00N%c_>J65JOCw*16Tk6 diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/__init__.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/__init__.cpython-314.pyc index 6b9bbd05930499e01d0b802677546fa73940a5f6..69836b3ae934b7ad0825f9d881691e2e27ffd590 100644 GIT binary patch delta 22 ccmcbAh4KCsMs96BUM>b8xEh(Xkvk*{09PUg&Hw-a delta 22 ccmcbAh4KCsMs96BUM>b8;Hiz@$Q_ae08(rQCIA2c diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/analysis.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/analysis.cpython-314.pyc index 727fa831790a4918d6993f8c127407c5a43c5455..16754f4cdddfa26f7b117f0982e3b0925dc76e69 100644 GIT binary patch delta 20 ZcmeB8>rLa<=HumJ0D`NLNgKJDi~%)c1j7IT delta 20 ZcmeB8>rLa<=HumJ00N%c_>J65#sDy_1RDSV diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/battery_fleet.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/battery_fleet.cpython-314.pyc index 141bc660817cfbf2fb85429cca3e7da39a4c327d..22fadd71e7c752f5e1351e535cd78b910e8068b5 100644 GIT binary patch delta 20 acmcbwdS8`Wn~#@^0SK-}CT--tA_M?A?gf?r delta 20 acmcbwdS8`Wn~#@^0SI_%<2Q0&5dr`;I0Wth diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/dashboard.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/dashboard.cpython-314.pyc index c5d85cf3c4cdbf05e32d9724f146a4cdab353715..2214a6e14f84329c8e2a6e434aa784def077322d 100644 GIT binary patch delta 22 ccmbRKf^qr_Ms96BUM>b8xEh(Xk(;Lk08StVC;$Ke delta 22 ccmbRKf^qr_Ms96BUM>b8;Hiz@$jws%07+^EfB*mh diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/documents.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/documents.cpython-314.pyc index 115f3af5f78ebe6a6fa6a38ab5dae786875dee1a..586058225c0946e778c38a6b39c75e714c4e606c 100644 GIT binary patch delta 20 acmcbaelwk0n~#@^0SK-}CT-+CXAA&BX9g(% delta 20 acmcbaelwk0n~#@^0SI_%<2Q1jGX?-Xu?2wu diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/groups.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/groups.cpython-314.pyc index 1e74ee7f6c4da9b33b2894433ad2de28f8575275..3a7710968d75e9f5380fdaac62ca52863f17e0f1 100644 GIT binary patch delta 20 acmez8@Xvu;n~#@^0SK-}CT-*{RsaA&par)8 delta 20 acmez8@Xvu;n~#@^0SI_%<2P~_D*ymI>IDw~ diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/integration_setups.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/integration_setups.cpython-314.pyc index d5e77b76efd7f02aba53612e383572aef61a8851..992196ccc2463b4a8133edd51c28525348c08875 100644 GIT binary patch delta 20 acmZ4DvBZO0n~#@^0SK-}CT--FQ~>}qv;@5X delta 20 acmZ4DvBZO0n~#@^0SI_%<2Q0kssI2l{sa{O diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/io.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/io.cpython-314.pyc index 0db8df368aac04ada69250aa5cf36e2595ee7ae4..1fa89e06ec025f883ca4691497cdb1d2cf7702fc 100644 GIT binary patch delta 22 ccmcb=i0S?!CT?v$UM>b8xEh(Xkvn7}08h{clK=n! delta 22 ccmcb=i0S?!CT?v$UM>b8;Hiz@$Q?2f081JL>i_@% diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/objects.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/objects.cpython-314.pyc index e2dd5468987535d70d5dcc4bf1255f3ce9f53f50..2dccc92d08fbaf0e089b679cda04290af48665f9 100644 GIT binary patch delta 22 ccmX@{hv~#0CT?v$UM>b8xEh(Xk=u4309n=tF8}}l delta 22 ccmX@{hv~#0CT?v$UM>b8;Hiz@$Zfk1097CchX4Qo diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/parts.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/parts.cpython-314.pyc index 437cc5648b8238eb1f18afa1f45953a834e8eedf..e337abce8c771a3ef3b3715a2b7789bce8ef7de7 100644 GIT binary patch delta 20 acmdm0u&aPun~#@^0SK-}CT--_w*UY=*ac$% delta 20 acmdm0u&aPun~#@^0SI_%<2Q2aTL1t#A_Tht diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/problem_sensors.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/problem_sensors.cpython-314.pyc index cd3a89f15cdfd631e1566755e368b02c34ea4620..2af0dd15b4a73028fb58c2b6a32eb193b43b80db 100644 GIT binary patch delta 20 acmaFo@Xmo-n~#@^0SK-}CT-+SRsaA$AO(j2 delta 20 acmaFo@Xmo-n~#@^0SI_%<2Q0AD*ymGY6RZ^ diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/saved_views.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/saved_views.cpython-314.pyc index f06643090b33111e9b36f2d2b2be536cac372047..0b4cbe35dc3667def69171d243c80109fd0f5b35 100644 GIT binary patch delta 20 acmeyR_e+mkn~#@^0SK-}CT---7Y6`7Zv}n; delta 20 acmeyR_e+mkn~#@^0SI_%<2Q2Wivs{Txdhe# diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/tags.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/tags.cpython-314.pyc index 6d2d471d49c161d4540117b57f96b444dd29c46d..85f3e05667f0bd9b583f7384fc6901c877c65272 100644 GIT binary patch delta 20 acmew)_DPIen~#@^0SK-}CT-+S=LP^d!v!h; delta 20 acmew)_DPIen~#@^0SI_%<2Q1ra{~Z14FrM! diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/tasks.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/tasks.cpython-314.pyc index fa24deba35c05ecb4cbf9f3b8a12788795bb6906..a870ebbfd25e46a99b11f1a20fb3ddc986980966 100644 GIT binary patch delta 20 acmcc3dz+VAn~#@^0SK-}CT--tzy<&{;ss6s delta 20 acmcc3dz+VAn~#@^0SI_%<2Q0&U;_X#ECi+i diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/tasks_actions.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/tasks_actions.cpython-314.pyc index c84012379ab052a74fc519afde427fcbf5137516..20bdfec5c9349b96a788bb1b4c5706ee1ae426bc 100644 GIT binary patch delta 22 ccmey-$@rs_kz1ROmx}=iu0|$pb8xEh(Xk$YJ$07wA_cK`qY delta 22 ccmdlun`z^0CT?v$UM>b8;Hiz@$i1u=07FX!&j0`b diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/tasks_history.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/tasks_history.cpython-314.pyc index f70d5131311e22233155520037512c91c912153a..c4a5f669ac59080ac5b463b0d919ba7f512bb4a6 100644 GIT binary patch delta 20 acmexr^wo%4n~#@^0SK-}CT---k^}%gIR$wD delta 20 acmexr^wo%4n~#@^0SI_%<2Q0=Ndf>lg9On4 diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/tasks_lifecycle.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/tasks_lifecycle.cpython-314.pyc index 71b971d99bcdffec1b780286e5afd706052096dd..1980ac1ae225f56bda136db84d26059c2857ae2d 100644 GIT binary patch delta 20 acmccUbRvOn~#@^0SK-}CT--lR004wA_Wrw delta 20 acmX@&a>RvOn~#@^0SI_%<2Q0!DggjAYy@in diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/tasks_validation.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/tasks_validation.cpython-314.pyc index 4625edfae149879a3b6f5730823234093ee62821..3a8a0964fd260c71af12801ded9c9863eacd688b 100644 GIT binary patch delta 20 acmccQf61R)n~#@^0SK-}CT-+Ct_}b_;|16N delta 20 acmccQf61R)n~#@^0SI_%<2Q02R|fz&Ed?+D diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/users.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/users.cpython-314.pyc index 85639613d7bc58338fde2bf2cb19c43d427c8a8d..3ae9bfcab309a56e583bcee84406cab150519b8d 100644 GIT binary patch delta 20 acmX?Qeaf0!n~#@^0SK-}CT-;2FAD%U$pwx8 delta 20 acmX?Qeaf0!n~#@^0SI_%<2Q2emjwVd69nb} diff --git a/custom_components/maintenance_supporter/websocket/__pycache__/vacation.cpython-314.pyc b/custom_components/maintenance_supporter/websocket/__pycache__/vacation.cpython-314.pyc index 68951a7698c3ec65355ad520aaf1320a264eda92..fa68293b4fbcd0f05e41d4172c2d759580b6ed4a 100644 GIT binary patch delta 20 ZcmeAS?+oYG=HumJ0D`NLNgKKAv;i}p1rY!M delta 20 ZcmeAS?+oYG=HumJ00N%c_>J6k+5j%;1Ze;O diff --git a/custom_components/taskmate/__init__.py b/custom_components/taskmate/__init__.py index 314a102..8554448 100644 --- a/custom_components/taskmate/__init__.py +++ b/custom_components/taskmate/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy import logging +from datetime import timedelta from functools import wraps from pathlib import Path @@ -14,6 +15,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError, Unauthorized from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_set_service_schema +from homeassistant.util import dt as dt_util from .const import ( ATTR_AS_PARENT, @@ -39,12 +41,14 @@ from .const import ( ATTR_CHILD_ID, ATTR_CHORE_ASSIGNED_TO, ATTR_CHORE_DESCRIPTION, + ATTR_CHORE_EXPIRES_IN_MINUTES, ATTR_CHORE_ID, ATTR_CHORE_NAME, ATTR_CHORE_ONE_SHOT, ATTR_CHORE_ORDER, ATTR_CHORE_POINTS, ATTR_CHORE_REQUIRES_APPROVAL, + ATTR_CHORE_SPEED_BONUS_POINTS, ATTR_CHORE_TIME_CATEGORY, ATTR_PENALTY_ASSIGNED_TO, ATTR_PENALTY_DESCRIPTION, @@ -85,6 +89,7 @@ from .const import ( SERVICE_PAUSE_TIMED_TASK, SERVICE_POSTPONE_MANDATORY_CHORE, SERVICE_PREVIEW_SOUND, + SERVICE_READ_ALOUD, SERVICE_RECORD_ALLOWANCE_PAYOUT, SERVICE_REJECT_CHORE, SERVICE_REJECT_REWARD, @@ -96,6 +101,7 @@ from .const import ( SERVICE_SET_CHORE_MANUAL_START, SERVICE_SET_CHORE_ORDER, SERVICE_SKIP_CHORE, + SERVICE_SPIN_ROULETTE, SERVICE_START_TIMED_TASK, SERVICE_STOP_TIMED_TASK, SERVICE_TEST_NOTIFICATION, @@ -619,6 +625,37 @@ async def _async_register_services(hass: HomeAssistant) -> None: await _async_require_linked_child(hass, call, coordinator, requester_id) await coordinator.async_request_swap(call.data["chore_id"], requester_id) + async def handle_read_aloud(call: ServiceCall) -> None: + """Speak a child's outstanding chores to a media player (#684).""" + coordinator = _get_coordinator(hass) + if not coordinator: + _LOGGER.error("No TaskMate coordinator available") + return + try: + await coordinator.async_read_aloud( + child_id=call.data[ATTR_CHILD_ID], + media_player=call.data.get("media_player", ""), + tts_entity=call.data.get("tts_entity", ""), + message=call.data.get("message", ""), + ) + except ValueError as err: + raise ServiceValidationError(str(err)) from err + + async def handle_spin_roulette(call: ServiceCall) -> None: + """A child spins for a random chore at a multiplier (#677).""" + coordinator = _get_coordinator(hass) + if not coordinator: + _LOGGER.error("No TaskMate coordinator available") + return + child_id = call.data[ATTR_CHILD_ID] + await _async_require_linked_child(hass, call, coordinator, child_id) + try: + await coordinator.async_spin_roulette(child_id) + except ValueError as err: + # Roulette off, no spins left, nothing to spin for — all normal + # states the child should be told about, not server errors. + raise ServiceValidationError(str(err)) from err + async def handle_choose_avatar(call: ServiceCall) -> None: """A child switches to an avatar they've unlocked.""" coordinator = _get_coordinator(hass) @@ -882,7 +919,14 @@ async def _async_register_services(hass: HomeAssistant) -> None: if not coordinator: _LOGGER.error("No TaskMate coordinator available") return - schedule_mode = "one_shot" if call.data.get(ATTR_CHORE_ONE_SHOT, False) else "specific_days" + expires_in = call.data.get(ATTR_CHORE_EXPIRES_IN_MINUTES, 0) or 0 + # A chore with a deadline is inherently a one-off — it exists to be done + # now, so it should never linger into tomorrow. + one_shot = call.data.get(ATTR_CHORE_ONE_SHOT, False) or bool(expires_in) + schedule_mode = "one_shot" if one_shot else "specific_days" + deadline_at = "" + if expires_in: + deadline_at = (dt_util.now() + timedelta(minutes=expires_in)).isoformat() await coordinator.async_add_chore( name=call.data[ATTR_CHORE_NAME], description=call.data.get(ATTR_CHORE_DESCRIPTION, ""), @@ -892,6 +936,8 @@ async def _async_register_services(hass: HomeAssistant) -> None: requires_approval=call.data.get(ATTR_CHORE_REQUIRES_APPROVAL, True), difficulty=call.data.get("difficulty", "medium"), schedule_mode=schedule_mode, + deadline_at=deadline_at, + speed_bonus_points=call.data.get(ATTR_CHORE_SPEED_BONUS_POINTS, 0), ) async def handle_add_badge(call: ServiceCall) -> None: @@ -1188,6 +1234,25 @@ async def _async_register_services(hass: HomeAssistant) -> None: ), ) + hass.services.async_register( + DOMAIN, + SERVICE_READ_ALOUD, + _safe(handle_read_aloud), + schema=vol.Schema({ + vol.Required(ATTR_CHILD_ID): cv.string, + vol.Optional("media_player", default=""): cv.string, + vol.Optional("tts_entity", default=""): cv.string, + vol.Optional("message", default=""): cv.string, + }), + ) + + hass.services.async_register( + DOMAIN, + SERVICE_SPIN_ROULETTE, + _safe(handle_spin_roulette), + schema=vol.Schema({vol.Required(ATTR_CHILD_ID): cv.string}), + ) + hass.services.async_register( DOMAIN, SERVICE_CHOOSE_AVATAR, @@ -1398,6 +1463,10 @@ async def _async_register_services(hass: HomeAssistant) -> None: vol.Optional("difficulty", default=DEFAULT_DIFFICULTY): vol.In(DIFFICULTY_TIERS), vol.Optional(ATTR_CHORE_ONE_SHOT, default=False): cv.boolean, vol.Optional(ATTR_CHORE_REQUIRES_APPROVAL, default=True): cv.boolean, + vol.Optional(ATTR_CHORE_EXPIRES_IN_MINUTES, default=0): vol.All( + cv.positive_int, vol.Range(max=10080) + ), + vol.Optional(ATTR_CHORE_SPEED_BONUS_POINTS, default=0): cv.positive_int, }), ) diff --git a/custom_components/taskmate/__pycache__/__init__.cpython-314.pyc b/custom_components/taskmate/__pycache__/__init__.cpython-314.pyc index f52cc91724607142949db880a2b30f0a78fb2368..70fd569e165ec19e09876f0ba8c9892b7c7d31f2 100644 GIT binary patch delta 17932 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 delta 15761 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 JR!V#F{{z}7R0{wA diff --git a/custom_components/taskmate/__pycache__/binary_sensor.cpython-314.pyc b/custom_components/taskmate/__pycache__/binary_sensor.cpython-314.pyc index 640048d18919a45967ddaefebe5b45c083d9ef77..d0f90bc26b0d7928315c76e654d1b711d4939c3c 100644 GIT binary patch delta 20 acmeCu>C)lW=HumJ0D@zYNgKKUivj>HJOvp5 delta 20 acmeCu>C)lW=HumJ00PHV!5g{%ivj>FGz8WF diff --git a/custom_components/taskmate/__pycache__/button.cpython-314.pyc b/custom_components/taskmate/__pycache__/button.cpython-314.pyc index 4f82facb25fb311d406cceaefaf92226943aa5a4..436dfa6a2f021e52b41a9e2a04b330c8c95a4f1f 100644 GIT binary patch delta 734 zcmZvZe@IhN6vyw`kEcysi$RooY-_X4<+dD~n%WQRk3d3@6;UAMwjNHSg>S2cYCLeCk`u z*l4UZFg{+Mj1Jy%E*O8-Z4vY__{xZPW9p0+i>STB7)G?)(eH1@;ReIeZH$Smw~Efv zI1XV5YeL$}ssr2=gY|Qd$ZRaEb*jHdS2l&E)Ok5!PsJtsK%?`s6n5df6PhNN+ub6hNnmTj*^LOT|qq&|aCyu#0a83-ugz{1R1u%pktY%@D zCzdZ^*(w2IpY03GC_VNfzzhZ)Z-fzwW^l8!OTFfrmI=m^hVEK2;efLnX7TY>zw*iH z1DIFLJ8LvBk2d$R{x(1O9M{~dki}OG``{UF^318;CFQASTq7VUXYrUf155bMTUKLv z<*jcK>Mv7FB2;#frPg_$EFWPC7kBpxE8I+;p%=hCO*bHguibW4sHl8y>e4vUv{en+a delta 785 zcmZvZUr19?9LLY^ZeDBRQlT1awYAyYny$t+b<_;)C85YBL`ls9y9XRtj?Isd^B4>8Tq9*680$kHJFOgM@W{*Ql3%51;crzw zx4zvJ(Kf`jLvc1d-5DFY=k;CmElP%<=mn?b? zo=*%WV|o)E8Aaj0Dvc^~0^35h@KNmwwShiDS;ep&;2NsU`+d~;`XJXyjT3GVZgQjr z4)>oL6KHL5LK&kiC$>&L#E~5g!3k-B5Zf`n1IG9kBO5H+2V2ILX$3YH zA44wW;?M*sKokAx3;{yZP7=)|nKn&xX*<&-P0_-S*1ytAE^UF%GL!MI&h*{Sz`+c> z=hBq%E^z7YNRr~UiA1?Vald*m_&5VEkfbn0vUH!SH zeRI-weA%}ijaT)uzKS6&m#t^Uckwf}D(zqKFKoHmzf)IjDQ($*P|}E!`kN(JQ*SLQ zwXJxU4?{kor12gf0S^*q#cyO3n7`uVFe7As%_HDZVmpt5!}FD#gU5)oc?>*GoX7Wr zBg9Mj0dSP~13m%f#CARjjuB_^gW&zd+58arfJu!!ei)4j;-&luc#_!8r@#kq-e7_c z5ohsf@Zp>FN5Mykf5VS~r-&>0li>N!zmGo!o+kexe;Rz0__y4A290CX_=q0|KS}&C ze-``{v7J8$ewsL&KM#I}IFG*oK2H1zp8-Eh{3(AC{2cM`_zCdy#J}f10lz@Jl)nU? z8Peh>?M0^XN1j0QCF=T&zXE=l_;Y>=e3JMJei|HCjP1XMy*`6>g8Kf%e+qtu_)GpO z_!RMV{u=l+@g<%FpCSIUA&g7LWg~4|skn>B>oQN&e8t~D_v_UCH+~U(j`;8V=iu|i z|KM+eFA#rSz)Js{`YiLW_7%~w!O>tO92*XC9-7UHg!%`9{lnqdKVCgIo8!}s?XDI*Xz0$? zptI8xXifFjITkgpcKZEBu%+E&=s|s_*JbFwAa3k(xdVRPH+yHd&#UXL!Df#;;0t;^ zF1O#8I_qJA}a;kki0w1Hpp&rhUH91A4xyb8aP01 zJF-}?qC`!a70Dgsb|Tjawl)z;v$e8^+^z*~H@Q7XYcjp$`jFP=1<^9g<{rbPMH8&~ zESA?S`>Ap-()wzCL~a1-L}AletffhYNIs6V*7sv_Pax|At4(6swQ`W$z6EZG+_0UQ zcYfjs*->PTVAaXOG^>_ja$^hJIJpSY+Lb7|31qEcHEIWHsL+d4vsPQ5FT2SZx9Tu(Vf;jUL14aj z2lehmZV;?OeQy=Ahukitwf5cQ_8{{F%T=T0%3gAPNLo5ZD~M;f+A?of#J9V0&D(u) zi0X%t>jhhDb38WfCJ)V_vlH3&1TBJ#?DeKNfI?=-ZBCScckZWB~r<2>dz_p=; zr=%Sh>t+`fb|9_QZ6nu-Tq{_)nqax?BIib0_j<^Ak=24#sb{20c9S!ZbPjZN4t(VN z$c^Uh6$eP}LgovWr;c=<+)Zu|k{+Ao>apo1*M}?=Y>nFLH8M!Hb_iMHr z43R99>LQ28Z@+i`EnuoWEs;CWESuYdd{nTyWFXDzWG}g4q#9fu^=dgnZWKv_V>N4J znA{lBz$>I%y+XR>ILZCUErM-6i^9$F0J#ZdqhJlnHAr)kX${>%t`(`?Uh3vrsVisBZN)_$P$eC* zjSAb5>L4puFJ0t1kn`_I$R@dsWGC{D`E-$VBdryB$a#_00=vl>$i>ZAjZ=-|yx~h- z?EFyErs9L{V>#3KDT^FcaST!V%SdDVf9{p3a!=u1{u=zwMQT;lsd!XHy^2jL94Z=A zsGr1AEz*cf6#rOLWD72{_J)!00RDflIr2D~sw0|{l{#!($%xx~a#G%&x3y)(j!SIy SW#;(HEqG`5h_=Xy;{O9~!BK($ delta 2498 zcmY+FSx^*57{_~7AiJwTOhOsU7$DpWf(Q$uKv-Z!Q8`xd2BLs~+)HyGqj)PCW!EFg zR-R0%;!~`tN>Yh2_d}vNw&P2DiMcOhRa=#!%BTGM16h`${`>3SbWis;J;#4HH2Izu zks1+hW%zSk+jg+gej(!7h}SKQ?PG_S>)tZP-@2ZCOVfUyHl0}dOQpuM>R5e7yB5vT znd?{26;qb>o9DJETKnC&Z!)x{f1pbNy5!Au`O|niB*nDc$~z!im2|zsJHc(l?|L4F z#p!0=3)4x)%H7~D;&9#v?k0}m9Na@3$@{^*#P9O~u$$P-2f=;BRz3vg#Nm7x+)o_C zN5BKbk$e<9s8hqt$Iuv>8!!$YCbsek@W|W%9Xv|>0Y3yDBhKW9!Q;eF@=5Rn@eO_i zJbV5R`BCs8@@9Sve3&?#>&MZUq(%%s0X{-}lb-}1CH{z?0v{v(n4bn8C;o(=0iPg_ z&#x zCGa%y-~2MzLwuWG0egvWxxR7Tc75ym+Vx$=BK`{eMe=_BD)TNDi}P3AV-;%oi2g#kt zz+!fh+l>sIa1Xh?$P!Jk;;X2kSk{rON2Umt>~;ECvTPvt64HT=MORTuk=#deKQd5W zqghuqH$mnKmgB>^b7V7hJP_ns$h9IDoUx7ML8M)To-cw(#vjg zT+N>&m|g9FUG|e4LB1eZ>{P#>#mZ4~V?l15+ypXSusAQui<3IJBFjSOK+85$jC@|O zl~b5wr7R&=`q=3tog_CPlLbriqLL(8Ms6eWIl)#;IsI&f+(fP%nJ8F-H_Fcvtp3sautvJVU7e@Nv;aHTCfatwK8P2WvN=B3vzz2$66j&J6rn>8nP1^BUrSW zE?Vv)w;M^dW4buGhumIdx?ro+ox4ibk*i0}kM&HJm+65~E!3kmXjB`ygUB_4WvWGH z%64)cNV+&ECP{XZ>q4#*EYH{MXL+)lTn|!>RnJAH>?JpVoZC?yS7(sq5ORKLa+ur* zGEl-Oxv`*r!BblXfRe$oU>RVz%DPU^S;8JM_?+(u-! zU|H&^$da4Ll_P2EowW7jwjcu)ZzbnK#tIgr_Ay3oBUgj8>*`5~Qcp@P$?eDl!Q$2C z#>*Y#b|M3Vcahs~eOfa;WC&k^R6nwje8u9~2VZSfvaCeQHdBLKD_E|t*3WWfEx9J7 zdP;S5b7#wDk_V79IjV`5E#z8}F1$6W)mx)lwvp^Yz9?AXRdg(r-Q;?Z1%l-(moIzC zxsio}6)0CA`^a(RdcoEyw@&K)BnOaA%u=dssT?FZgq(e|K^Dtlk|W5#)T89akm`M< z2It6eaudjW!PYCcUh3qEmdxIt%H>H1TDHcSV*Jd$#9)hLiLx`L$OY?hlH7m{R9HrC zBQj9oCUWJ-Vie<0^Ee)EHU=tgYTAg$UNxCNhA)qMT*U+;<(k`$Z_(W+8XB6q8ygz# zhVkK^wyqYpN%c6d;)0546&~Zqs+2`(>idF@s#BQrJG-0uJDLmK^=PUC ZeGe?g-kLjMM)98Onh{#pp@rnZ{|`LT;41(C diff --git a/custom_components/taskmate/__pycache__/coord_assignments.cpython-314.pyc b/custom_components/taskmate/__pycache__/coord_assignments.cpython-314.pyc index 67b3f8bb0b1caf711b841e7b35d92b8888c08b17..322529aee8314988ecd0b8a7ca5765ee450c368c 100644 GIT binary patch delta 8434 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 diff --git a/custom_components/taskmate/__pycache__/coord_avatars.cpython-314.pyc b/custom_components/taskmate/__pycache__/coord_avatars.cpython-314.pyc index 1df62aa15ed749caa48a3d04787307babd712bdc..6e18f60bcb31e933c718e01f51494752ac77cd0a 100644 GIT binary patch delta 20 acmdnzu+M>8n~#@^0SJyoCT-+4RsaAuHw5Sa delta 20 acmdnzu+M>8n~#@^0SFvd1#jdwRsaAsF9f9k diff --git a/custom_components/taskmate/__pycache__/coord_badges.cpython-314.pyc b/custom_components/taskmate/__pycache__/coord_badges.cpython-314.pyc index 848172b5b2521a5660882135b39fad7b6d437f82..10e4d5327e133a64c8421bdf59d1ce1725c382f1 100644 GIT binary patch delta 22 ccmeymi{Ms96BUM>b8I2M_-ky|JV07kO~sQ>@~ delta 22 ccmbQcg>mi{Ms96BUM>b8a9kCtA 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 diff --git a/custom_components/taskmate/__pycache__/coord_mandatory.cpython-314.pyc b/custom_components/taskmate/__pycache__/coord_mandatory.cpython-314.pyc index 17a87cbcaab9bfdf2456641101ceaa6e4c2045f0..3e36a83f18615c60e86d53fd6e88d4bced3377e2 100644 GIT binary patch delta 22 ccmZoX!Pt0$kz1ROmx}=ijzuPI3|z4 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 KtBk3g;r|z`;l9BD delta 4251 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 Ang9R* diff --git a/custom_components/taskmate/__pycache__/coord_points.cpython-314.pyc b/custom_components/taskmate/__pycache__/coord_points.cpython-314.pyc index a0239432b24182deed95b062515d8cfd20c7753d..75d03aef8b7b2974bd461f0794e5dc49b03a573c 100644 GIT binary patch delta 672 zcmYL`&u z68#SxPew1^yxDNjqg+h1S{s#6O$rpP7d28L5^v5>jC1&W-@N(0w{K^Dz2?`}d38*+ zD?rza3s=+k3#;lJ-yg(nM9*cYBn{$m{?v|$}J^sm>Dxile%b5G1oVS=gfMZ zMMV~|u37e@Bcxo3qvgAG7YSEKXGCzi-=2LCD|^P@dB&eiJe^#fe3ts)IkOSJ)Nsmn zwJG%UDsAYIl@`OFEW4rGhR+D0q8}MSOYw+ggb3OQl2SFtSuJF7%UzhSA>??;KKzOy zK@ri(qsox&e{zl7+rG{U{MCcRRX?tETXTjT6)niHS&D*0V-m0bwCY@>b1CH9=N4Ko xoctoA1M~sghwOsV1d7g6nkpSm-*1*la)+Szq`l)s+hpMD>E3AvqCUos@h{b8a9kCfHD%LAz6fSXpCCtZh9D<#h8%$$#uBw4=MwcG5oHEV z-_7;Rem5uYo24;1BurxR><8SFW$tQCZoaC;^o@D)&j&I>+Ik?{d6|(|alQZr|Pu}-Pdh)&d8j~3xi->&| z0*W>{GCA@x9%Ny3Kr{bc1QUl`ACmUzm^C=GP?cR3*OnE^-? pNrH6q0SQfsq8pQKo=s-FGWo(Yb;d`Ne>`*Hujgk3ssoW=g#e+xY9Rms diff --git a/custom_components/taskmate/__pycache__/coord_quests.cpython-314.pyc b/custom_components/taskmate/__pycache__/coord_quests.cpython-314.pyc index da2d188325484ce6b9cea8400d0cbdb1b149a965..0b33a04a623619313ce23f9645987a4a89e2830e 100644 GIT binary patch delta 20 acmX>Xb}o!tn~#@^0SJyoCT-+))&>AP+67Ml delta 20 acmX>Xb}o!tn~#@^0SFvd1#jeb)&>AN(gh3v diff --git a/custom_components/taskmate/__pycache__/coord_rewards.cpython-314.pyc b/custom_components/taskmate/__pycache__/coord_rewards.cpython-314.pyc index f7c1d612bdf8489ee4a0c953a8d6acd5ea46d31e..394f76ba1cbcf0613761d3805d0729144a3a8612 100644 GIT binary patch delta 671 zcmXYvUr19?9LLY^&hFOC-Ob&5H|Na4bTu(kn-wld$%M8UcAH^kbLs5QYT>k?48xHF zu|Lr7xwnFXkm7<=PbH@G;5L<7Ne}iGA}@jxj|U@CAF#_;K_Ql2SPVuSFd+Raq!Gf z_^kGv{lnRf#8#Y@tLEgY=e-NEH*5B0%-$ulh`r~>?q1`F>|Bp=WUKj+Fe+s1N8a!L zV(@LQMnUIxta^!M-=f*QYTzU}XC#)xIgv=`V6*=zeWpaUQUntgiVucHnaX6Tp3&8K zh|1JX59LRjgR!=Za$0sW)|M!GenvDgcDZ;aRmVhHNdYL^_!MVjz6cb7PdVzTPn$t0 zA^Ls#%__VvJfd=#CpxF5N3K#VY0x%Y8lr-8Xu|{gZ^-u-8RExVJc`&XH_xOND zvk@Z)lj_%tQ${LJ;(a=hRN>}K9;{*Z_WgVw2#MFxTgLSm8+=BK;++DH(*gDsm%#zG zWoShQ`FJp~3kt9^5v8e5iOWE{>+ZdkmRaL(2EyhD3=+b~ltKGR8%;W#9dUsZH%2^Q c#LA=x>=;hEpd3e&jlz%tTx*0eZlgo~0?ReXHvj+t delta 581 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 D=Z!da delta 2387 zcmai0TTB~A6rHiXYiwgcBTs7JKbT|Yf^J%7$*$MbU~2jBLfUL0Xsbj|vtzS)p8G-oHX zJt)eQfGuqUDujxV6GhdXjx>{r9EzGMahgfy*bs*zYypaBE9op~N%P!~JvNoZ)L$6m z6hu@Ld{)<9#Y(xNW zK`(D11DZjcM{=~ajoFlv49WP&_;A{vq!yAqZ>033VIa|;^nT#&7^Bg0z#-<%^d&&1 zKHfr+&jqlO2vb5GPBMRGLtse*Gx>Zn#5(PTVBrQmyw-KfFFubB_irsI?IHwW zFC%1BKmA2RZfOtO=*Et#dmuxlMS^=!L`C{^1!O8{Sgp%?tTJ-jTXG&pd@z*b>(ys) zeueY*G8tR|I9s3>?Qj}2uIWOZSw!{($-V3xY$TYqsx_S^GEr_V+xiqds6o|AmqLf! z2&}gg+j)0dsJ+A5gd)X5H1BuujJl1{)8*(Qx2n9jn0#XN;F8s^jhmRL%w4Rqi+a*) zE@17{3*Dj_a{=bayqRN_jVdMIo5R>b9-He`msO;~5}3BzOm}VWtI@gWwF4`MMcaN^ zO{s?k-m6n{Q`g>H;l+XqnW2=-B3E#+eW86Nyp}5#Rm)mRKFdNb?poopYss}TwRZjv zS0@%7lXYOONB-T*;iYhDZ~gjN(bp;)fXzmp(#4U5k(HBc=R|jt++zgRwBy<$7FEeP zK$?-S3>>HS)U6*7xf8Ml*xVflG11*FTPgW04>{cP3B~V;HPsz=l~`CU+w5dtZeBM` zvLkP3acW^I6|Pz1Mb9z05Lib>fC`aol%2p9p_0AJBTFNx%GOl-fEbL+E}-3r+qG<5 zGN#Ipt~*3;o9wZZ#avq$#(Wu4BciWa_5&Npa9=5M&2q7w{GMxz!B{DB1{T8$;T6M; zZqZRIhk)GmvYRqsccVafxn`**wXb1)LiD%DO2~2)s`%FUl`(bTWa?BzEIlpnp~fFI z$Q9(awU|AK9A5bl`OjLzwP;#G_$G^6iZwU21<-fnmJ-cPj~4nRxP?UXnp%}Zs#6Xr zEjt1qg72pc01ls|N%W76D(CjKhxIu5CvQJJ`c=-t)g!j=R0i(3t8T*BeHEqjcgVoE z1Dy}fb%XI_ObIP%lVdTcE)tWVRZHOEQ1G4PoV~dG6imzJmGAf*&`NgQ;J}bBF0p~W zWS^w&PYm#9$v5`H^h5vKeg+?3ttz~NkAuP;MLbO8A(fqy^jc{#!Z@k4)hg#Kp3aa3 zUU~qc6dDz;9U(TYkO96A0JQSw0Td~0TFP@3>0~azc!Gk!QV7!Kr6rwqC0n2H-e`ZU zpN~T+Pm(HE7{^G$^<_4==-3iO9PYM+A{C0VvU!T=NjfuAG}5TGo>pTzBop7#FtW2Lb`O z(Q>(3JV8!#_uqI)8YIh40KD=BqXx5N$mgfIe(I~iR)G>7AtocmeDc6wRH$U9N^cBg zm%dYjGy`j(7SED1frK_4$w+=Wdeq~D$i)C%07C$y0G9yXCoQ$kgOHeVJEgBw_O!QXUI6*$I4ca%o3{(09QAm4$c_9lwOH_5$(~@rc zVi(dv;Br<@jB=vd#s&xDJU>NANgHcB+0@h!Q35Lhb~2K(sz=5KE)K_!@E=oKOdp|| zf~>|E-$RG)Bj2CMbRT&iq2O<*;}+_8q&DBxWN{DmmS6SG+j{4G^BsNhjCPZ~gmLLp I1gNDYV`{R%h=rItSha( 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 RjS+P6-zP7OVmYG!{|D_K=~n;% delta 9490 zcmb7K34D}Amj9}+bkd!WkPbOWCr$3o4M`v*&gcu}?NNAhxPSTQehx$4R0UW$W zQP&ZD{=9cZ#1%yaw$%~w;B-93LBtXG6pzJYc7Ng$6cLYc->WYN(b-=zt^B|Gs_LtH z@6~&+UcJgoyR{GR)Mn*GgojD^t9f$q)rq%C(kXfw{za+<_RTYFSJraY+NNy|47ZIw zr%7$)#Y}w<%!!evNiB)7Qj4`Mv(4USZJXYf*H+M$+?L)}(w5p*)MhD;80uTmqDhtP zNx^*05(al^n{@q7?uI0t9lW|~mL7ak*RL%o!On69!nM+QDOSIL4GO%k?TmQtbg9o5 z+-g0{H%?`@20M&(tg=PhD*2++$s|dCg}UlPBHzP-!u4Dc*0+ZngxLnbMw za4~oe7I`j06SRe=vb^AR;Z{vbBh}6$Xds{o6;p>N;E+9mfY;Z-(_!xBz_c) z-}i&b?F@Kq>upVbpGU0DS`+a$Tr-}gY=pk4q$v+f-OCJuuXeb~nozusNa6%4aagkJ z!~*c&(bQVEbAVrsC0U0+f=2;7W1Y=5!r!ce zk&s$^kb!%<+(OM{EmmZR)xI>pp6!P}l&JX%;RSgo!U))vy^96;~u=3+KdX`VTec~3#>bTYBE(_dih zNhm7Hit*ETVi=i9O#~&-QxjpZnwkP z>&C4Mm7EXo>!|5^fe3V4>@x!hEfErhYC3^C2zL*Nl zo0BxnvtjVL0#=TsBnT2K@I4nkK4O?zuUXI%A|YxK*@A-tZxh%j&?#_1h_s*%|5;14 zUS`+9-pVBSpUNytvZ^6rPIXUY|72#5S|W8M;SmqOiDc z25T(Q?JG*f^8Q#WVKHkg)$K1S$MS(f)>y7PFg+H_2aT+;Qg<*^lx=Jt(;b{9%B3xA zo=$gg_S`V+^@f(s3(>uy%cOD+n-{8kBR7P~C5*6CV7b6b!Z(>foxl)*p@^_^Ze^LU z2w`4mH2;ikWJ6YcCK`Kl{a1{oLPf(2b_4V@l*QK4I5gsjhoK4u_B9kSJDh7+FC1*y zC5NbbJfB*G1}0Czp0tDN^$Rf)C){aKpSX#hy)=QP&BPvxuHDOrw@7{(Yeu#fn(1>W zoHv6Nfot9!3kB1{u~CdPE)iu!#PBvAqqhx82%BBvfx$EeZ{V=#gG=Yf>sTl7MR|Fn zPK(T&hY-*yN7Z1n*onm^9w}>E9{{|ju$?n`>fmJ2{AU8OI)A6qF9d=Vu}?}w4* zKs`eUj0B-fDv%WB=3(?^A}}M!=X zebhdafb4Pj$h3zlXqgoQ_fSRyPfbSE8d1&#UGp(Lv92pcGgla(P>OW=M5u$HPUzQ! z!raH;Q(q41SGa$v9{Ft#*b-l+v4l#{&`OvHupK^Uw}&rfcVfMIilGxSwj*gf)>`5IwMFbOyuEhM#3(xf&#a@J z;;nTFtn4Zr6a704%~ui#@_tQ$+D;Mh==_j;%_4RJ{MT%mm`Qy~Uk{s90+|%THcYGG z15N7k-@TwSMG^(HZ($%8d&AYalkvR?lT)%P49WtkewIAX5GmKH{(b0@$}J5spsbqhv=m<=nNSZJbwowty} zy?x7SJ-Y;|?igS#u=@^c_2}w|6%(GrGHM{IS4bzxHxO6Cj|iV3oXL+8Oxcu?BYe@X zg!~UY2MHNEHdzzN1BgxQB0;L=#Mi>+O*5D#cyv>VW@4TlBeu62fLQYJ3cxn&G;m7EPoE zj;u+7y_=)JlN}A!nWPp z|C9(C&+7VN+15-oeWjuF6+F0g%dq9uJ(m7k%R5RO7K(*=;ISCi2Rk3bn3xh~?Gu}E z9W74+jzUMO6n)kp!3%L`=&=`92_NbzVZV`|8B3@q;oqE57ww_{IBUo^_h?!f9C_1n z*S1&~d^s9ke0&O2Z>wS%l$A&(iT&!goOUSj!(7Gs6;^QFhYOD@d)GFLcy@0L(_tb* z3;?@^8i$rWzRxBtPZ}P;Ah|I>NR*PL0l4O=>{ekip`JA^PY-276{ABqd52@jgZJ=WpU2_s>Tm6Wyx-?tJ+Kxs>dUPgI3XeKwPCsH!KW>gZX3jri z&KHH8Bjy}<^y$lu$&pcE8}(<>;FqWWv!QlQ{Y1i2Uu`4ZBxKt7ye*l>pjy_Fr_pTI`?U&)DT}A9+IJ4_~#)R~LJA?-*jZhMd zU<-u{ABlz3;O#S0QgVoE_By;f?4 zFCVs|2aAC-&(#U9Hn_q9mn23<_ORu2IuyTFO7U%fz8)TZWGa~c7&?{03$;a?Dq&LW zM*Qa!i7{=k=#Ou*5MG1Lb|ZxCPEDGRv+=*;yQ;)yN<2Hz<;xV%EQ99V2}aQy)fM>e zEWCYtaCenKuyzl8vO5nlUUcV1OQADQ81pX3HXP`BOKmc{ht*Ei-5ZOgXIQOOx1%Nx zi~FstHd!dwdo=5Ke(p8M-9wvV>LgoMR^`9B1%Ib8>_bgY0wm$esSc zYr*EdsSH&juy3g;g?fp=ARUhFE5$=&DETd-D%=<@nj;eI=u4HpL_EA=0bRRvb< zcOk2X_s6jfV({;Y#0vyJ5d26$lklIg)P$k77?)U8Lg_4{UQm!{8FOM8e=NQgzrh2G zIImFtTPHH$SEIL3VC%t?>b)6tQl}J5Wzy?-Onsw)mky<%*gih=?Q}t@xwi)e>-i0_ zLw9L{5;Ph-IpGmh_opfi4pIN7T`CD807C!$&tx1gXkhy5L_%}D1wLM<4|c!at7Rp@ zQ*YTAyAyP8XQqvcOug$-WBoRqXVf{sqPMFw7c{|5Z{Ne}gaV8uX05r7Qgg$Jm+=H+ z2Jd0@KGBydlyxbt>F|XjjXWl9UJ6O?q%bqoykpbkN${6<>uLlEJ`x4VL(%s+yL#1| z)`?wGM8cQvWDj3}y|c~&IY&Ov7nF>g*iqPgu>RdAWs+vbP! zYLZ9|!5jh+;#E;;H0&FR`1mYpNF$g@Ah@U0F&+~;A@}_ROD9%eB5t8nv={Szu{pJcfW2 zkXxyg2L5BYrFGa_c*UWkp^d&KAbpc5FY^6O(qN*bFF3EGKXy_7xaHgoc=y<1JzjX7 zSq9n1WgABCXL2P&#J3uaei8ddi^Vgdc+Q9+OX8Zi?sdvgdeaZr<=L_DXI? zVw5n4qrErK%RLxl;XD2%Mwh>%gZ@6`CYW*JaO8FL0j(2{AXorV%FUA}x%__a9*yMr z6nI&Q&y1z-&1#!^MCWlARjuyz$awP@4^baPV-VQEi#)lM=qBg5$$DO}_~SKFlzE7vFDUsbZl218X3MUH||9 diff --git a/custom_components/taskmate/__pycache__/frontend.cpython-314.pyc b/custom_components/taskmate/__pycache__/frontend.cpython-314.pyc index 79d6a579c3282542191a983a249ac0853aa6f799..b63ed50e7e8f2fb895d1faef1d7d0a63633516bc 100644 GIT binary patch delta 109 zcmZ4Gebk#zn~#@^0SJyoCS`GGY~;Jc#2C8yHPcKG)yitm$e6hKDBDt2AeGH?nvt<% zvpHWpACQ_Zl%#7WWB~)GcB?XuoSQ|<|Ge|NreR5!& IoULjP01*x$E&u=k delta 101 zcmX@=y~>+Un~#@^0SFvd1!uib-^h1|i7{mJYo?hXs+HBAkuhQOQMRS5Kq{N(G$UjC zW^=xHJ|Hz+#)*YdeDW=Yrp;xFyO|lgCL5``@<|9VHLy06erAwhWcp+`xlPp`05}F8 AaR2}S diff --git a/custom_components/taskmate/__pycache__/http_calendar.cpython-314.pyc b/custom_components/taskmate/__pycache__/http_calendar.cpython-314.pyc index dc980b8e585b1c4e5428a92e93d4daf8ddf8e8f8..e825c6100edbfbab907978e6ee6fea11e7b2927e 100644 GIT binary patch delta 20 acmeyR{!5)(n~#@^0SJyoCT-+?FAM-a1_j*! delta 20 acmeyR{!5)(n~#@^0SFvd1#jejFAM-X{so!< diff --git a/custom_components/taskmate/__pycache__/http_photos.cpython-314.pyc b/custom_components/taskmate/__pycache__/http_photos.cpython-314.pyc index 7e20a3e44a1afc114fecd5785fd193d57ad78d0b..bf133e9bec4e6c3d5af9870271397c71c605da0c 100644 GIT binary patch delta 20 acmdnsvB85|n~#@^0SJyoCT-+aRRI7tD+JO2 delta 20 acmdnsvB85|n~#@^0SFvd1#je5RRI7rBLt5C diff --git a/custom_components/taskmate/__pycache__/ics.cpython-314.pyc b/custom_components/taskmate/__pycache__/ics.cpython-314.pyc index 6c3d573871ad8948c761bf0a163ded4b1fc44268..57e1310fbc6ca75fde825df93555aff23f8f96ff 100644 GIT binary patch delta 20 ZcmZp%Z?Wgr=HumJ0D@zYNgKH<q=V;{>Vz delta 20 acmdmBvB82{n~#@^0SFvd1#je5l>q=T+XQC- diff --git a/custom_components/taskmate/__pycache__/models.cpython-314.pyc b/custom_components/taskmate/__pycache__/models.cpython-314.pyc index 86fc8f47747435e891da3660ff58837089550cee..e1d3e03962c6ced3a7b2a24fc2f946dfb1c2c6c3 100644 GIT binary patch delta 21889 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

+0=i`iCqB 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 diff --git a/custom_components/taskmate/__pycache__/number.cpython-314.pyc b/custom_components/taskmate/__pycache__/number.cpython-314.pyc index 4e163a9fcc0d312b61263b96ad7df88b18b818dc..e4938136361efb13f56999079697b29b7dd92854 100644 GIT binary patch delta 20 acmX@CeOQ}Yn~#@^0SJyoCT-;2AqoIC00lw- delta 20 acmX@CeOQ}Yn~#@^0SFvd1#jfuAqoI9_yqp| diff --git a/custom_components/taskmate/__pycache__/panel.cpython-314.pyc b/custom_components/taskmate/__pycache__/panel.cpython-314.pyc index 3b921ed5d590d1330a70b1bc03f2bbf61464c633..7d0b7f662af7ea5d0e64c5ce32a3357fd2f1616c 100644 GIT binary patch delta 20 acmZ22w_1){n~#@^0SJyoCT-+a;0FLNW&~OQ delta 20 acmZ22w_1){n~#@^0SFvd1#je5;0FLLUIZ5a diff --git a/custom_components/taskmate/__pycache__/photos.cpython-314.pyc b/custom_components/taskmate/__pycache__/photos.cpython-314.pyc index 8a1a64ca42c7ca2ee36407a901bf9d4cba7e51ef..c4757a864266c4a804f3354652ed1666961f5e13 100644 GIT binary patch delta 182 zcmZ1yvOI)On~#@^0SJyoCS@Iy-N?64NLrdv@*@L~Sm68tMC~vJQzw{#f|E}Pwew0d z#4!Xhd$M@27O==~juW27%$Pm-tC$AQ2NrHt$*(*NobHSdC##9uGi?!_oG#wR*t7Ya z_-RJQshf96HUX*0w$i5}6cA`It-&6Ob~NS7u~fzd2k%ipvBC%b91Zm112Dqrj`H_ SJg)A-#0qr9SEI=STH*jE8a7w} diff --git a/custom_components/taskmate/__pycache__/select.cpython-314.pyc b/custom_components/taskmate/__pycache__/select.cpython-314.pyc index 3eeea5126aa2a1ea46cebcb3a12ba1a8fb35be01..742c342693035f05485ab0fbe6c5157c5a803ef8 100644 GIT binary patch delta 67 zcmbQLzEGW4n~#@^0SJyoCS`5h$h%FDk#+JBK_#ABT#3oasl~;aNja&Tp9z{XGUiT} W6ZYYXzmS-9A*b>JOV#8OVMPG2A{H|M delta 54 zcmZ3eK2@Dpn~#@^0SFvd1!pbZ$h%FDk!A7`L8Z-~1kD*4b0%vE`)~zb2#&cBmwkaH JXL5tEA^@CK5V8OO diff --git a/custom_components/taskmate/__pycache__/sensor.cpython-314.pyc b/custom_components/taskmate/__pycache__/sensor.cpython-314.pyc index 3d23a48b7cae3d2cc341c2d49d89257bacfc1ded..5c157e7cafcf4eccb5040bfd7cf9a23ce767f774 100644 GIT binary patch delta 11063 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 uf&^QBP?CAR@V~BC7ZCh6sH;mXvlGb#zX%dJJg+ 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 Ac>n+a diff --git a/custom_components/taskmate/__pycache__/storage.cpython-314.pyc b/custom_components/taskmate/__pycache__/storage.cpython-314.pyc index 1e3fead3374a3d76191b6168f61767611b61f4d7..ca432ac79374ca4bb666c21d7c8c1274394fc766 100644 GIT binary patch delta 28198 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 JL3{{R{6F8ZLahJ* delta 25903 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ⅆqVXQ=NgNbY)*!}u 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 diff --git a/custom_components/taskmate/__pycache__/templates.cpython-314.pyc b/custom_components/taskmate/__pycache__/templates.cpython-314.pyc index 910ec551cdd7f2609ea806174e4ffda4ef776e55..e9e643d88606e0e278da67490f4d1b89a899a8e9 100644 GIT binary patch delta 202 zcmdm_J4ugMn~#@^0SJyoCS|SK$org=F=p~tPEAJX$?{wm;`z!`6H78ui{exBN-|3- zZ%M%TNjdq++40Hwc`2DCnfZCew*+7^C8@au@wu6K=pu;~2$Ay4yp;Id#EQ-DxFi{E z?SR%7GXe>RB2^%Hi^C>2KczG$)vm|`$OTCh*8|=7;{!7zBjX(g!@CShA6PggnJzGh MeiGa4!js1Z08N@hkpKVy delta 106 zcmbQFw@H^*n~#@^0SFvd1!q}oZ7;PQA* diff --git a/custom_components/taskmate/__pycache__/todo.cpython-314.pyc b/custom_components/taskmate/__pycache__/todo.cpython-314.pyc index a2dd8629b17adb53cd44a806141ae924febc2191..3aab050870d4e254b1ef9cc8b90a9e9b60a70e9e 100644 GIT binary patch delta 20 ZcmeCs@6hMg=HumJ0D@zYNgKIq#Q`l^1e*W= delta 20 ZcmeCs@6hMg=HumJ00PHV!5g`2#Q`f)1Xut7 diff --git a/custom_components/taskmate/__pycache__/websocket.cpython-314.pyc b/custom_components/taskmate/__pycache__/websocket.cpython-314.pyc index dd295fa7e31e3fcf96691af4beac1849f67839f0..3b35cbebe5fdf1bfee39cb9c6f6c0ae4344e8f0d 100644 GIT binary patch delta 47809 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 str: """Return the icon.""" chore = self.coordinator.get_chore(self.chore_id) - return getattr(chore, 'icon', "mdi:check-circle") if chore else "mdi:check-circle" + # Chores gained an optional icon in #683, defaulting to "". Fall back on + # falsiness, not on the attribute being absent — otherwise every chore + # without a picture gets a blank button icon. + return (getattr(chore, 'icon', "") or "mdi:check-circle") if chore else "mdi:check-circle" @property def extra_state_attributes(self) -> dict: diff --git a/custom_components/taskmate/const.py b/custom_components/taskmate/const.py index 67bea79..074afde 100644 --- a/custom_components/taskmate/const.py +++ b/custom_components/taskmate/const.py @@ -256,6 +256,8 @@ SERVICE_TEST_NOTIFICATION: Final = "test_notification" SERVICE_GIFT_POINTS: Final = "gift_points" SERVICE_RECORD_ALLOWANCE_PAYOUT: Final = "record_allowance_payout" SERVICE_REQUEST_SWAP: Final = "request_swap" +SERVICE_SPIN_ROULETTE: Final = "spin_roulette" +SERVICE_READ_ALOUD: Final = "read_aloud" SERVICE_CHOOSE_AVATAR: Final = "choose_avatar" SERVICE_SET_CHORE_ORDER: Final = "set_chore_order" SERVICE_PREVIEW_SOUND: Final = "preview_sound" @@ -310,6 +312,8 @@ ATTR_CHORE_ASSIGNED_TO: Final = "assigned_to" ATTR_CHORE_TIME_CATEGORY: Final = "time_category" ATTR_CHORE_ONE_SHOT: Final = "one_shot" ATTR_CHORE_REQUIRES_APPROVAL: Final = "requires_approval" +ATTR_CHORE_EXPIRES_IN_MINUTES: Final = "expires_in_minutes" +ATTR_CHORE_SPEED_BONUS_POINTS: Final = "speed_bonus_points" ATTR_BONUS_SUBTASK_ID: Final = "bonus_subtask_id" # Badge attributes diff --git a/custom_components/taskmate/coord_assignments.py b/custom_components/taskmate/coord_assignments.py index 487c398..c3f0177 100644 --- a/custom_components/taskmate/coord_assignments.py +++ b/custom_components/taskmate/coord_assignments.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any from homeassistant.core import callback from homeassistant.util import dt as dt_util -from .models import Chore +from .models import Chore, optional_float if TYPE_CHECKING: pass @@ -22,35 +22,61 @@ class AssignmentsMixin: """Mixin providing assignment rotation, availability, and visibility logic.""" def _refresh_tracked_availability_entities(self) -> None: - """Rebuild the cached set of entities that gate child availability. + """Rebuild the cached set of external entities TaskMate reacts to. - Children only change via mutations (which trigger a coordinator - refresh), so this is rebuilt on refresh rather than on every bus - state_changed event — which fires for every entity in HA (PERF-5). + Two groups, kept apart because they trigger different work: + + * child availability/unavailability entities — a change here can + re-assign a rotation chore, so it schedules a re-evaluation; + * chore visibility and weather entities — a change here only alters + what's *visible*, so it just invalidates the sensor attribute cache. + + Children and chores only change via mutations (which trigger a + coordinator refresh), so this is rebuilt on refresh rather than on + every bus state_changed event — which fires for every entity in HA + (PERF-5). """ - tracked: set[str] = set() + availability: set[str] = set() for c in self.storage.get_children(): if getattr(c, "availability_entity", ""): - tracked.add(c.availability_entity) + availability.add(c.availability_entity) if getattr(c, "unavailability_entity", ""): - tracked.add(c.unavailability_entity) - self._tracked_availability_entities = tracked + availability.add(c.unavailability_entity) + self._tracked_availability_entities = availability + + visibility: set[str] = set() + for chore in self.storage.get_chores(): + if getattr(chore, "visibility_entity", ""): + visibility.add(chore.visibility_entity) + if getattr(chore, "weather_entity", ""): + visibility.add(chore.weather_entity) + self._tracked_visibility_entities = visibility @callback def _availability_state_changed(self, event: Any) -> None: - """Cheap bus-filter: dispatch a re-eval only when a tracked entity changes. + """Cheap bus-filter: react only when a tracked entity changes. Decorated @callback so it runs on the event loop (a plain listener runs in the executor, where async_create_task raises) and reads the cached - tracked-entity set rather than rescanning all children per event. + tracked-entity sets rather than rescanning all children per event. """ data = getattr(event, "data", None) or {} entity_id = data.get("entity_id") if not entity_id: return - if entity_id not in getattr(self, "_tracked_availability_entities", set()): + + is_availability = entity_id in getattr(self, "_tracked_availability_entities", set()) + is_visibility = entity_id in getattr(self, "_tracked_visibility_entities", set()) + if not (is_availability or is_visibility): return - self.hass.async_create_task(self._async_reevaluate_availability()) + + # The data snapshot is reused while storage.data_version is unchanged, + # so without this the chores/availability sensors would keep serving + # attributes computed against the old entity state. + self.external_state_version += 1 + + if is_availability: + self.hass.async_create_task(self._async_reevaluate_availability()) async def _async_reevaluate_availability(self) -> None: """Re-run assignment for require_availability chores when availability flips. @@ -182,6 +208,61 @@ class AssignmentsMixin: return False + # Reasons a weather-gated chore is currently hidden. Returned by + # _weather_block_reason so the panel and sensor can explain the gap + # rather than silently dropping the chore off the list. + WEATHER_REASON_CONDITION = "condition" + WEATHER_REASON_TEMP_LOW = "temp_low" + WEATHER_REASON_TEMP_HIGH = "temp_high" + WEATHER_REASON_WIND = "wind" + + def weather_block_reason(self, chore) -> str | None: + """Return why the weather blocks this chore right now, else ``None``. + + Fail-open at every step: no entity configured, an entity that doesn't + exist, an unavailable/unknown state, or a missing attribute all mean + "not blocked". A weather integration going offline must never silently + hide the family's chores. + """ + entity_id = (getattr(chore, "weather_entity", "") or "").strip() + if not entity_id: + return None + + state_obj = self.hass.states.get(entity_id) + if state_obj is None or state_obj.state in ("unavailable", "unknown", None, ""): + _LOGGER.debug( + "Weather entity '%s' unavailable, not blocking chore '%s'", + entity_id, getattr(chore, "name", ""), + ) + return None + + blocked = [str(c).lower() for c in (getattr(chore, "weather_block_conditions", []) or [])] + if blocked and state_obj.state.lower() in blocked: + return self.WEATHER_REASON_CONDITION + + attrs = getattr(state_obj, "attributes", {}) or {} + + temp = optional_float(attrs.get("temperature")) + if temp is not None: + temp_min = optional_float(getattr(chore, "weather_temp_min", None)) + if temp_min is not None and temp < temp_min: + return self.WEATHER_REASON_TEMP_LOW + temp_max = optional_float(getattr(chore, "weather_temp_max", None)) + if temp_max is not None and temp > temp_max: + return self.WEATHER_REASON_TEMP_HIGH + + wind = optional_float(attrs.get("wind_speed")) + if wind is not None: + wind_max = optional_float(getattr(chore, "weather_wind_max", None)) + if wind_max is not None and wind > wind_max: + return self.WEATHER_REASON_WIND + + return None + + def _is_weather_ok_for_chore(self, chore) -> bool: + """True when current weather permits this chore (or no gate is set).""" + return self.weather_block_reason(chore) is None + def _is_child_available(self, child_id: str) -> bool: """Return True when the child should receive chores today. diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index fd41b39..e3293b6 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -63,6 +63,8 @@ class ChoresMixin: publish_calendar_entities: list[str] | None = None, require_availability: bool = False, manual_start_child_id: str = "", + deadline_at: str = "", + speed_bonus_points: int = 0, ) -> Chore: """Add a new chore.""" # One-shot chores: force daily_limit=1, set created_date to today @@ -112,6 +114,8 @@ class ChoresMixin: assignment_rotation_anchor=assignment_rotation_anchor, publish_calendar_entities=list(publish_calendar_entities or []), require_availability=require_availability, + deadline_at=deadline_at, + speed_bonus_points=max(0, int(speed_bonus_points or 0)), ) # Cache today's active child so the card can show it immediately active = self._compute_active_children(chore, today) @@ -183,6 +187,82 @@ class ChoresMixin: await self.storage.async_save() await self.async_refresh() + def chore_deadline(self, chore) -> datetime | None: + """A reactive chore's deadline as an aware datetime, or None if unset.""" + raw = (getattr(chore, "deadline_at", "") or "").strip() + if not raw: + return None + try: + parsed = datetime.fromisoformat(raw) + except (ValueError, TypeError): + _LOGGER.warning( + "Chore '%s' has an unparseable deadline_at %r — ignoring it", + getattr(chore, "name", ""), raw, + ) + return None + # A naive value came from hand-edited storage; treat it as local time + # rather than silently comparing across timezones. + if parsed.tzinfo is None: + return dt_util.as_local(parsed.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)) + return dt_util.as_local(parsed) + + def chore_deadline_passed(self, chore, now: datetime | None = None) -> bool: + """True when this chore had a deadline and it has already gone.""" + deadline = self.chore_deadline(chore) + if deadline is None: + return False + return dt_util.as_local(now or dt_util.now()) > deadline + + async def _async_expire_deadline_chores(self, refresh: bool = True) -> None: + """Soft-disable reactive chores whose deadline has passed. + + Runs on the 30s poll as well as at midnight — a 30-minute "empty the + washing machine" chore must not sit on the card until tomorrow. + + ``refresh=False`` is required when calling this from inside + ``_async_update_data``: that *is* the refresh, and asking the + coordinator to refresh re-entrantly deadlocks it. The caller there + builds its snapshot after us anyway, and our save has already bumped + ``data_version``, so the disabled chore lands in the same tick. + """ + now = dt_util.now() + changed = False + for chore in self.storage.get_chores(): + if not getattr(chore, "enabled", True): + continue + if not self.chore_deadline_passed(chore, now): + continue + chore.enabled = False + self.storage.update_chore(chore) + changed = True + _LOGGER.info( + "Reactive chore '%s' expired (deadline %s)", + chore.name, getattr(chore, "deadline_at", ""), + ) + self.hass.bus.async_fire( + "taskmate_chore_expired", + { + "chore_id": chore.id, + "chore_name": chore.name, + "deadline_at": getattr(chore, "deadline_at", ""), + "timestamp": now.isoformat(), + }, + ) + if changed: + await self.storage.async_save() + if refresh: + await self.async_refresh() + + def _apply_speed_bonus(self, chore, base: int, completed_at) -> int: + """Add a reactive chore's speed bonus when its deadline was beaten.""" + bonus = int(getattr(chore, "speed_bonus_points", 0) or 0) + if bonus <= 0: + return base + deadline = self.chore_deadline(chore) + if deadline is None or dt_util.as_local(completed_at) > deadline: + return base + return base + bonus + def _apply_time_adjustment(self, chore, base: int, completed_at) -> int: """Apply a chore's early-bonus / late-penalty based on completion time.""" due = getattr(chore, "due_time", "") or "" @@ -389,6 +469,8 @@ class ChoresMixin: self.storage.remove_last_completed_for_chore(chore_id) # Strip chore from any task group it belonged to. self.storage.remove_chore_from_task_groups(chore_id) + # Drop queued scheduled changes (#675) — nothing left to apply them to. + self.storage.remove_scheduled_changes_for_chore(chore_id) # Remove chore from children's chore_order lists for child in self.storage.get_children(): if chore_id in child.chore_order: @@ -623,8 +705,14 @@ class ChoresMixin: if requires_photo and not as_parent and not (photo_url or "").strip(): raise ValueError("This chore requires a photo as evidence.") auto_approve = as_parent or (not chore.requires_approval and not requires_photo) - effective_points = self._apply_time_adjustment( - chore, self.effective_chore_points(chore), now + effective_points = self._apply_roulette_multiplier( + chore, + child_id, + self._apply_speed_bonus( + chore, + self._apply_time_adjustment(chore, self.effective_chore_points(chore), now), + now, + ), ) completion = ChoreCompletion( @@ -671,7 +759,10 @@ class ChoresMixin: # Fire approval notification only if it stays pending if not auto_approve: - await self._async_notify_pending_approval(child.name, chore.name, chore.points, completion_id=completion.id) + await self._async_notify_pending_approval( + child.name, chore.name, chore.points, + completion_id=completion.id, photo_url=completion.photo_url, + ) await self.async_refresh() @@ -1181,6 +1272,18 @@ class ChoresMixin: if not self._is_visibility_entity_active(visibility_entity, visibility_state, visibility_operator): return False + # Weather gate (#673): an outdoor chore stays hidden while the chosen + # weather entity reports unsuitable conditions. Because this sits in the + # availability path, a rained-off chore also stops counting as a + # mandatory miss and can't break a streak. + if not self._is_weather_ok_for_chore(chore): + return False + + # Reactive chores (#674): once the deadline passes the chore is gone, + # even before the next sweep soft-disables it. + if self.chore_deadline_passed(chore): + return False + # Chore dependencies (FEAT-1): this chore unlocks only once every chore # it depends on has an approved completion today by this same child. depends_on = getattr(chore, 'depends_on', []) or [] diff --git a/custom_components/taskmate/coord_notifications.py b/custom_components/taskmate/coord_notifications.py index cbe254a..aa64c0f 100644 --- a/custom_components/taskmate/coord_notifications.py +++ b/custom_components/taskmate/coord_notifications.py @@ -159,7 +159,14 @@ class NotificationCoordinator: recipients_fired: list[str] = [] message = self._render_template(meta, context) + # Multi-parent routing (#687): thin the PARENT recipients down per the + # configured policy. Child routes are never touched — a reminder for a + # child must always reach that child. + allowed_parents = self._route_parents(type_id, cfg, only_recipients) + for recipient_id, route in cfg.routes.items(): + if recipient_id.startswith("parent:") and recipient_id not in allowed_parents: + continue if not route.enabled: continue if only_recipients is not None and recipient_id not in only_recipients: @@ -184,6 +191,68 @@ class NotificationCoordinator: # notification, flowing through _send_to like any other channel. self._fire_bus_event(type_id, context, recipients_fired) + # ── Multi-parent routing (#687) ────────────────────────────────────── + + PARENT_ROUTING_MODES = ("all", "home", "round_robin") + + def parent_routing_mode(self) -> str: + mode = str(self.storage.get_setting("parent_routing", "all") or "all") + return mode if mode in self.PARENT_ROUTING_MODES else "all" + + def _parent_is_home(self, recipient_id: str) -> bool: + """True when this parent's presence entity says they're here. + + No entity configured means "always available" — a parent who hasn't + set one up shouldn't be silently excluded from every approval. + """ + parent = next( + (p for p in self.storage.get_parent_recipients() if p.id == recipient_id), None, + ) + entity_id = (getattr(parent, "presence_entity", "") or "").strip() if parent else "" + if not entity_id: + return True + state = self.hass.states.get(entity_id) + if state is None or state.state in ("unavailable", "unknown", None, ""): + # Fail open: a broken presence sensor must not stop approvals. + return True + return str(state.state).lower() in ("home", "on", "true", "present") + + def _route_parents( + self, type_id: str, cfg, only_recipients: set[str] | None, + ) -> set[str]: + """Which parent recipient ids should receive this notification.""" + candidates = [ + rid for rid, route in cfg.routes.items() + if rid.startswith("parent:") and route.enabled + and (only_recipients is None or rid in only_recipients) + ] + if not candidates: + return set() + + mode = self.parent_routing_mode() + if mode == "all": + return set(candidates) + + if mode == "home": + at_home = [rid for rid in candidates if self._parent_is_home(rid)] + # Nobody home: tell everyone rather than nobody. An unseen approval + # is worse than a redundant buzz. + return set(at_home or candidates) + + # round_robin — one parent per notification, rotating. + ordered = sorted(candidates) + state = self.storage.get_setting("parent_routing_state", {}) + state = dict(state) if isinstance(state, dict) else {} + last = state.get(type_id) + try: + start = ordered.index(last) + 1 if last in ordered else 0 + except ValueError: + start = 0 + chosen = ordered[start % len(ordered)] + state[type_id] = chosen + self.storage.set_setting("parent_routing_state", state) + return {chosen} + async def send_test(self, type_id: str) -> list[str]: """Send a sample notification of ``type_id`` to its enabled routes. @@ -291,6 +360,18 @@ class NotificationCoordinator: return data: dict[str, Any] = {"title": "TaskMate", "message": message} + + # Evidence photo (#686): attach it so a parent can approve from the + # lock screen while actually looking at the tidied room. The URL is + # pre-signed by the caller, because the companion app fetches + # attachments without the user's bearer token. + photo_url = context.get("photo_url") or "" + attach_photo = bool(photo_url) and service.startswith("mobile_app") + + # Build the mobile-app payload once: the action buttons and the photo + # are independent, so a completion with no entry id still gets its + # picture, and a photo-less approval still gets its buttons. + push: dict[str, Any] = {} if meta.actionable: entry_id = context.get("entry_id") # Tap actions only render on the HA mobile app. Other backends @@ -300,15 +381,23 @@ class NotificationCoordinator: if entry_id: # `tag` lets us dismiss this push later (clear_approval) once # the item is reviewed — see _approval_tag. - data["data"] = { - "tag": _approval_tag(entry_id), - "actions": [ - {"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"}, - {"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"}, - ] - } + push["tag"] = _approval_tag(entry_id) + push["actions"] = [ + {"action": f"TASKMATE_APPROVE_{entry_id}", "title": "Approve"}, + {"action": f"TASKMATE_REJECT_{entry_id}", "title": "Reject"}, + ] else: data["message"] = f"{message} {_APPROVE_IN_PANEL_HINT}" + + if attach_photo: + # "image" renders inline on Android; iOS needs it under + # attachment.url. Sending both keeps one payload working on either. + push["image"] = photo_url + push["attachment"] = {"url": photo_url, "content-type": "jpeg"} + + if push: + data["data"] = push + try: await self.hass.services.async_call(domain, service, data, blocking=False) except Exception as err: # noqa: BLE001 diff --git a/custom_components/taskmate/coord_points.py b/custom_components/taskmate/coord_points.py index 2617ab8..700706a 100644 --- a/custom_components/taskmate/coord_points.py +++ b/custom_components/taskmate/coord_points.py @@ -1013,7 +1013,7 @@ class PointsMixin: async def _async_notify_pending_approval( self, child_name: str, chore_name: str, points: int, - completion_id: str | None = None, + completion_id: str | None = None, photo_url: str = "", ) -> None: await self.notifications.fire( "pending_chore_approval", @@ -1023,6 +1023,9 @@ class PointsMixin: "chore_name": chore_name, "points": points, "points_name": self.storage.get_points_name(), + # Evidence photo (#686). Signed here rather than in the + # notifier so the notifier stays free of photo specifics. + "photo_url": photos.sign_photo_url(self.hass, photo_url) if photo_url else "", }, ) diff --git a/custom_components/taskmate/coord_rewards.py b/custom_components/taskmate/coord_rewards.py index 1396157..6d7c02a 100644 --- a/custom_components/taskmate/coord_rewards.py +++ b/custom_components/taskmate/coord_rewards.py @@ -409,6 +409,9 @@ class RewardsMixin: "pending_reward_claim", claim_id ) + # Timed unlock (#678): allowlisted entity on, auto-off later. + await self.async_start_unlock(reward, child) + self.hass.bus.async_fire("taskmate_reward_approved", { "child_id": child.id, "child_name": child.name, "reward_id": reward.id, "reward_name": reward.name, diff --git a/custom_components/taskmate/coord_templates.py b/custom_components/taskmate/coord_templates.py index 24999a0..0255c7d 100644 --- a/custom_components/taskmate/coord_templates.py +++ b/custom_components/taskmate/coord_templates.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING -from .models import Chore, generate_id +from .models import Chore, generate_id, optional_float from .templates import BUILT_IN_IDS, BUILT_IN_TEMPLATES, TEMPLATE_CHORE_FIELDS if TYPE_CHECKING: @@ -54,6 +54,11 @@ class TemplatesMixin: visibility_entity=chore_def.get("visibility_entity", ""), visibility_state=chore_def.get("visibility_state", "on"), visibility_operator=chore_def.get("visibility_operator", "equals"), + weather_entity=chore_def.get("weather_entity", ""), + weather_block_conditions=list(chore_def.get("weather_block_conditions", [])), + weather_temp_min=optional_float(chore_def.get("weather_temp_min")), + weather_temp_max=optional_float(chore_def.get("weather_temp_max")), + weather_wind_max=optional_float(chore_def.get("weather_wind_max")), task_type=chore_def.get("task_type", "standard"), timed_rate_points=chore_def.get("timed_rate_points", 10), timed_rate_minutes=chore_def.get("timed_rate_minutes", 5), @@ -132,3 +137,132 @@ class TemplatesMixin: raise ValueError(f"Cannot delete built-in template '{template_id}'") self.storage.remove_custom_template(template_id) await self.storage.async_save() + + # ── Pack import / export (#688) ────────────────────────────────────── + + PACK_FORMAT = "taskmate.template-pack" + PACK_VERSION = 1 + MAX_PACK_TEMPLATES = 50 + MAX_PACK_CHORES = 200 + + def export_templates(self, template_ids: list[str] | None = None) -> dict: + """Build a shareable pack from custom templates. + + Built-ins are excluded: they ship with TaskMate, so exporting them + would just create duplicates on the other end. + """ + wanted = set(template_ids or []) + packed = [] + for tpl in self.storage.get_custom_templates(): + if wanted and tpl.get("id") not in wanted: + continue + packed.append({ + "name": tpl.get("name", ""), + "icon": tpl.get("icon", "mdi:clipboard-list-outline"), + "chores": [ + {k: v for k, v in chore.items() if k in TEMPLATE_CHORE_FIELDS} + for chore in tpl.get("chores", []) + ], + }) + + from homeassistant.util import dt as dt_util + return { + "format": self.PACK_FORMAT, + "version": self.PACK_VERSION, + "exported_at": dt_util.now().isoformat(), + "templates": packed, + } + + def _validate_pack(self, pack: dict) -> list[dict]: + """Validate an untrusted pack, returning clean template dicts. + + A pack is arbitrary user-supplied JSON, so everything is checked and + every unknown chore field is dropped rather than trusted — a shared + pack must not be able to set runtime state or fields the panel + wouldn't let you set by hand. + """ + if not isinstance(pack, dict): + raise ValueError("That doesn't look like a TaskMate pack") + if pack.get("format") != self.PACK_FORMAT: + raise ValueError("Not a TaskMate template pack") + try: + version = int(pack.get("version", 0)) + except (TypeError, ValueError) as err: + raise ValueError("Pack version is not a number") from err + if version > self.PACK_VERSION: + raise ValueError( + f"This pack needs a newer TaskMate (pack version {version}, " + f"this one understands {self.PACK_VERSION})" + ) + + templates = pack.get("templates") + if not isinstance(templates, list) or not templates: + raise ValueError("Pack contains no templates") + if len(templates) > self.MAX_PACK_TEMPLATES: + raise ValueError(f"Pack has too many templates (max {self.MAX_PACK_TEMPLATES})") + + clean: list[dict] = [] + for entry in templates: + if not isinstance(entry, dict): + raise ValueError("Pack contains a malformed template") + name = str(entry.get("name", "")).strip() + if not name: + raise ValueError("A template in the pack has no name") + + raw_chores = entry.get("chores") + if not isinstance(raw_chores, list) or not raw_chores: + raise ValueError(f"Template '{name}' has no chores") + if len(raw_chores) > self.MAX_PACK_CHORES: + raise ValueError(f"Template '{name}' has too many chores") + + chores = [] + for raw in raw_chores: + if not isinstance(raw, dict): + raise ValueError(f"Template '{name}' has a malformed chore") + chore_name = str(raw.get("name", "")).strip() + if not chore_name: + raise ValueError(f"A chore in '{name}' has no name") + # Whitelist: unknown keys are dropped, never carried through. + cleaned = {k: v for k, v in raw.items() if k in TEMPLATE_CHORE_FIELDS} + cleaned["name"] = chore_name[:200] + chores.append(cleaned) + + clean.append({ + "name": name[:120], + "icon": str(entry.get("icon", "") or "mdi:clipboard-list-outline"), + "chores": chores, + }) + return clean + + async def async_import_pack(self, pack: dict) -> dict: + """Import a validated pack as custom templates. Returns a summary. + + Names that already exist are suffixed rather than overwritten — an + import should never silently replace something the family built. + """ + clean = self._validate_pack(pack) + existing = {t.get("name", "") for t in self.storage.get_custom_templates()} + + created = [] + for tpl in clean: + name = tpl["name"] + if name in existing: + suffix = 2 + while f"{name} ({suffix})" in existing: + suffix += 1 + name = f"{name} ({suffix})" + existing.add(name) + + record = { + "id": generate_id(), + "name": name, + "icon": tpl["icon"], + "builtin": False, + "chores": tpl["chores"], + } + self.storage.add_custom_template(record) + created.append({"id": record["id"], "name": name, "chores": len(tpl["chores"])}) + + await self.storage.async_save() + await self.async_refresh() + return {"imported": len(created), "templates": created} diff --git a/custom_components/taskmate/coordinator.py b/custom_components/taskmate/coordinator.py index f7df667..e902814 100644 --- a/custom_components/taskmate/coordinator.py +++ b/custom_components/taskmate/coordinator.py @@ -21,13 +21,19 @@ from .coord_badges import BadgeCoordinator from .coord_calendar import CalendarMixin from .coord_challenges import ChallengesMixin from .coord_chores import ChoresMixin +from .coord_guests import GuestsMixin from .coord_mandatory import MandatoryMixin from .coord_notifications import NotificationCoordinator from .coord_points import PointsMixin from .coord_quests import QuestsMixin +from .coord_reports import ReportsMixin from .coord_rewards import RewardsMixin +from .coord_roulette import RouletteMixin +from .coord_scheduled import ScheduledChangesMixin from .coord_templates import TemplatesMixin from .coord_timed import TimedMixin +from .coord_tts import ReadAloudMixin +from .coord_unlocks import UnlocksMixin from .models import Child from .storage import TaskMateStorage @@ -46,6 +52,12 @@ class TaskMateCoordinator( TimedMixin, CalendarMixin, TemplatesMixin, + ScheduledChangesMixin, + ReportsMixin, + RouletteMixin, + ReadAloudMixin, + GuestsMixin, + UnlocksMixin, DataUpdateCoordinator, ): """Coordinator to manage TaskMate data.""" @@ -66,6 +78,12 @@ class TaskMateCoordinator( self._unsub_prune: Callable[[], None] | None = None self._unsub_availability: Callable[[], None] | None = None self._tracked_availability_entities: set[str] = set() + self._tracked_visibility_entities: set[str] = set() + # Bumped whenever a tracked external entity (child availability, chore + # visibility, chore weather) changes state. The data snapshot is reused + # while storage.data_version is unchanged, so sensors that derive + # attributes from live entity state need this to invalidate their cache. + self.external_state_version: int = 0 self._unsub_surprise: Callable[[], None] | None = None self._unsub_weekly: Callable[[], None] | None = None self._unsub_mandatory: list[Callable[[], None]] = [] @@ -74,6 +92,8 @@ class TaskMateCoordinator( self._avail_cache: dict | None = None # (data_version, snapshot) cache for _async_update_data (PERF-2). self._data_snapshot_cache: tuple[int, dict[str, Any]] | None = None + # Scheduled reverts for timed unlock rewards (#678). + self._unlock_timers: list = [] def difficulty_multiplier(self, tier: str) -> float: """Return the points multiplier for a difficulty tier. @@ -265,6 +285,11 @@ class TaskMateCoordinator( await self.storage.async_save() await self._async_backfill_career_history() await self._async_stop_stale_timed_sessions() + # Catch up on scheduled changes (#675) that came due while HA was off — + # the parent still expects "from 1 September" to have happened. + await self.async_apply_due_scheduled_changes(refresh=False) + # A restart mid-unlock must never strand the TV on (#678). + await self.async_resume_unlocks() await self.async_refresh() # Schedule midnight streak check at 00:00:05 self._unsub_midnight = async_track_time_change( @@ -607,6 +632,7 @@ class TaskMateCoordinator( async def async_shutdown(self) -> None: """Shutdown the coordinator and clean up listeners.""" + self.cancel_unlock_timers() if self._unsub_midnight: self._unsub_midnight() self._unsub_midnight = None @@ -640,9 +666,13 @@ class TaskMateCoordinator( saves. One step failing must not stop the rest. """ steps = [ + self.async_apply_due_scheduled_changes, + self.async_prune_roulette_state, + self.async_archive_expired_guests, self._async_check_streaks, self._async_expire_one_shot_chores, self._async_expire_dated_chores, + self._async_expire_deadline_chores, self._async_restock_rewards, self._async_expire_rewards, self._async_decay_points, @@ -698,6 +728,11 @@ class TaskMateCoordinator( # May stop sessions (mutates + saves -> bumps the version), so run first. await self._async_auto_stop_capped_sessions() await self._async_check_family_goal() + # Reactive-chore deadlines are minutes long, so they can't wait for + # midnight maintenance like the date-based expiries do. refresh=False: + # we are already inside the refresh, and the snapshot below picks the + # change up in this same tick. + await self._async_expire_deadline_chores(refresh=False) self._refresh_tracked_availability_entities() version = self.storage.data_version cached = getattr(self, "_data_snapshot_cache", None) diff --git a/custom_components/taskmate/frontend.py b/custom_components/taskmate/frontend.py index 23b8931..e2e7115 100644 --- a/custom_components/taskmate/frontend.py +++ b/custom_components/taskmate/frontend.py @@ -28,6 +28,7 @@ CARDS: Final = [ "taskmate-design.js", "taskmate-badges-card.js", "taskmate-child-card.js", + "taskmate-routine-card.js", "taskmate-rewards-card.js", "taskmate-approvals-card.js", "taskmate-points-card.js", diff --git a/custom_components/taskmate/manifest.json b/custom_components/taskmate/manifest.json index 467e59e..762de12 100644 --- a/custom_components/taskmate/manifest.json +++ b/custom_components/taskmate/manifest.json @@ -17,5 +17,5 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/tempus2016/taskmate/issues", "requirements": [], - "version": "4.5.1" + "version": "5.0.1" } diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index 231eabc..eec01b0 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -68,6 +68,21 @@ def format_datetime(dt: datetime | None) -> str | None: return utc_dt.isoformat().replace("+00:00", "Z") +def optional_float(value: Any) -> float | None: + """Coerce a stored/user value to a float, or None when it isn't set. + + Used for limits where 0 is a meaningful value (a 0 °C threshold is real), + so the usual "0 means off" sentinel can't be used. Empty strings and + unparseable values both read as "no limit". + """ + if value is None or value == "": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + @dataclass class TimedSession: """Tracks an active or paused timed-task session.""" @@ -159,6 +174,10 @@ class Child: quiet_hours_start: str = "" # "HH:MM" — start of do-not-disturb window; empty = no quiet hours quiet_hours_end: str = "" # "HH:MM" — end of do-not-disturb window; start>end means overnight level: int = 1 # cached XP level (derived from total_points_earned) + # Guest profiles (#690): a visiting cousin gets a temporary child that + # expires on its own and stays out of the family leaderboard. + is_guest: bool = False + guest_expires_on: str = "" # ISO date; profile auto-archives the day after id: str = field(default_factory=generate_id) @classmethod @@ -178,6 +197,8 @@ class Child: streak_paused=data.get("streak_paused", False), streak_milestones_achieved=list(data.get("streak_milestones_achieved", [])), awarded_perfect_weeks=list(data.get("awarded_perfect_weeks", [])), + is_guest=bool(data.get("is_guest", False)), + guest_expires_on=str(data.get("guest_expires_on", "") or ""), availability_entity=data.get("availability_entity", ""), availability_inverted=data.get("availability_inverted", False), unavailability_entity=data.get("unavailability_entity", ""), @@ -208,6 +229,8 @@ class Child: "streak_paused": self.streak_paused, "streak_milestones_achieved": self.streak_milestones_achieved, "awarded_perfect_weeks": self.awarded_perfect_weeks, + "is_guest": self.is_guest, + "guest_expires_on": self.guest_expires_on, "availability_entity": self.availability_entity, "availability_inverted": self.availability_inverted, "unavailability_entity": self.unavailability_entity, @@ -236,6 +259,9 @@ class Chore: claim_allowance_minutes: int = 0 # Grace minutes past period end during which the chore stays claimable; 0 = no grace. Night chores still cap at midnight. daily_limit: int = 1 completion_sound: str = "coin" # Sound to play on completion + # Optional picture for the chore. Text-free pre-reader mode (#683) needs + # one per chore; everything else falls back to the time-of-day icon. + icon: str = "" difficulty: str = "medium" # easy | medium | hard — scales awarded points by the tier multiplier (medium = ×1.0 baseline) # Scheduling # schedule_mode: "specific_days" = show on selected days of week (Mode A) @@ -251,12 +277,28 @@ class Chore: visibility_entity: str = "" # optional: entity_id to check for visibility visibility_state: str = "on" # state/value that makes chore visible (e.g. "on", "true", "123") visibility_operator: str = "equals" # equals, gte, lte, gt, lt, not_equals + # Weather-aware chores (#673): hide an outdoor chore while the chosen + # weather.* entity reports unsuitable conditions. Every limit is optional + # and evaluation is fail-open — a missing or unavailable entity never hides + # a chore. Temperature/wind limits are read from the entity's attributes in + # whatever unit HA reports them (native_temperature/wind_speed_unit). + weather_entity: str = "" + weather_block_conditions: list[str] = field(default_factory=list) # e.g. ["rainy", "pouring"] + weather_temp_min: float | None = None # block below this temperature + weather_temp_max: float | None = None # block above this temperature + weather_wind_max: float | None = None # block above this wind speed # One-shot chore fields enabled: bool = True # False = soft-disabled (completed or expired) disabled_for: list[str] = field(default_factory=list) # Child IDs this chore is disabled for depends_on: list[str] = field(default_factory=list) # Chore IDs that must be approved-completed today before this is available created_date: str = "" # ISO date for one-shot expiry, e.g. "2026-04-16" expires_on: str = "" # optional ISO end date; chore auto-disables the day after + # Reactive chores (#674): a short-lived chore raised by an automation, e.g. + # "the washing machine finished — empty it within 30 minutes". deadline_at + # is an ISO datetime after which the chore is unavailable and gets + # soft-disabled; beat it and speed_bonus_points is added to the award. + deadline_at: str = "" + speed_bonus_points: int = 0 # Time-of-day incentive: when due_time (HH:MM) is set, a completion at/before # it earns +early_bonus, after it loses late_penalty (applied to the award). due_time: str = "" @@ -311,6 +353,7 @@ class Chore: claim_allowance_minutes=max(0, int(data.get("claim_allowance_minutes", 0) or 0)), daily_limit=data.get("daily_limit", 1), completion_sound=data.get("completion_sound", "coin"), + icon=str(data.get("icon", "") or ""), difficulty=data.get("difficulty", "medium"), schedule_mode=schedule_mode, due_days=list(data.get("due_days", [])), @@ -321,6 +364,13 @@ class Chore: visibility_entity=data.get("visibility_entity", ""), visibility_state=data.get("visibility_state", "on"), visibility_operator=data.get("visibility_operator", "equals"), + weather_entity=data.get("weather_entity", ""), + weather_block_conditions=list(data.get("weather_block_conditions", [])), + weather_temp_min=optional_float(data.get("weather_temp_min")), + weather_temp_max=optional_float(data.get("weather_temp_max")), + weather_wind_max=optional_float(data.get("weather_wind_max")), + deadline_at=data.get("deadline_at", ""), + speed_bonus_points=int(data.get("speed_bonus_points", 0) or 0), enabled=data.get("enabled", True), disabled_for=list(data.get("disabled_for", [])), depends_on=list(data.get("depends_on", []) or []), @@ -366,6 +416,7 @@ class Chore: "claim_allowance_minutes": self.claim_allowance_minutes, "daily_limit": self.daily_limit, "completion_sound": self.completion_sound, + "icon": self.icon, "difficulty": self.difficulty, "schedule_mode": self.schedule_mode, "due_days": self.due_days, @@ -376,6 +427,13 @@ class Chore: "visibility_entity": self.visibility_entity, "visibility_state": self.visibility_state, "visibility_operator": self.visibility_operator, + "weather_entity": self.weather_entity, + "weather_block_conditions": self.weather_block_conditions, + "weather_temp_min": self.weather_temp_min, + "weather_temp_max": self.weather_temp_max, + "weather_wind_max": self.weather_wind_max, + "deadline_at": self.deadline_at, + "speed_bonus_points": self.speed_bonus_points, "enabled": self.enabled, "disabled_for": self.disabled_for, "depends_on": self.depends_on, @@ -422,6 +480,11 @@ class Reward: restock_amount: int = 0 restock_period: str = "weekly" # daily | weekly (Mon) | monthly (1st) restock_last: str = "" # ISO date this reward was last restocked + # Timed unlock (#678): on approval, turn this entity on and turn it back + # off after unlock_minutes. The entity must be on the parent's allowlist, + # checked both at save time and again when it fires. + unlock_entity: str = "" + unlock_minutes: int = 0 id: str = field(default_factory=generate_id) def __post_init__(self) -> None: @@ -451,6 +514,8 @@ class Reward: expires_at=expires_at, restock_enabled=data.get("restock_enabled", False), restock_amount=int(data.get("restock_amount", 0) or 0), + unlock_entity=str(data.get("unlock_entity", "") or ""), + unlock_minutes=int(data.get("unlock_minutes", 0) or 0), restock_period=data.get("restock_period", "weekly"), restock_last=data.get("restock_last", ""), id=data.get("id", generate_id()), @@ -472,6 +537,8 @@ class Reward: "restock_amount": self.restock_amount, "restock_period": self.restock_period, "restock_last": self.restock_last, + "unlock_entity": self.unlock_entity, + "unlock_minutes": self.unlock_minutes, "id": self.id, } @@ -999,6 +1066,73 @@ class TaskGroup: } +@dataclass +class ScheduledChange: + """A chore edit queued to take effect on a future date (#675). + + "From 1 September this chore is worth 20 points" / "from Monday it's + Ella's". ``changes`` maps chore field -> new value; only the fields in + ``SCHEDULED_CHANGE_FIELDS`` may be set, so a queued change can't rewrite + runtime state like rotation anchors or publish history. + + Applied changes are kept (not deleted) so the panel can show what happened + and when — a silent config change is worse than no config change. + """ + + chore_id: str + apply_on: str # ISO date, e.g. "2026-09-01" + changes: dict[str, Any] = field(default_factory=dict) + note: str = "" + applied: bool = False + applied_at: str = "" + created_at: str = field(default_factory=dt_util_now_iso) + id: str = field(default_factory=generate_id) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ScheduledChange": + return cls( + chore_id=data.get("chore_id", ""), + apply_on=data.get("apply_on", ""), + changes=dict(data.get("changes", {}) or {}), + note=data.get("note", ""), + applied=bool(data.get("applied", False)), + applied_at=data.get("applied_at", ""), + created_at=data.get("created_at", "") or dt_util_now_iso(), + id=data.get("id", generate_id()), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "chore_id": self.chore_id, + "apply_on": self.apply_on, + "changes": self.changes, + "note": self.note, + "applied": self.applied, + "applied_at": self.applied_at, + "created_at": self.created_at, + "id": self.id, + } + + +# Chore fields a scheduled change is allowed to set. Deliberately excludes +# runtime state (assignment_current_child_id, skip_date, publish history) and +# anything that would need extra coordination to change safely. +SCHEDULED_CHANGE_FIELDS: dict[str, type | tuple[type, ...]] = { + "points": int, + "assigned_to": list, + "enabled": bool, + "time_category": str, + "difficulty": str, + "requires_approval": bool, + "daily_limit": int, + "due_days": list, + "mandatory": bool, + "mandatory_penalty_points": int, + "description": str, + "expires_on": str, +} + + @dataclass class ParentRecipient: """A configured parent notification target.""" @@ -1006,6 +1140,9 @@ class ParentRecipient: name: str notify_service: str enabled: bool = True + # Presence entity for "whoever is home" routing (#687). Empty means this + # parent is always considered available. + presence_entity: str = "" id: str = field(default_factory=lambda: f"parent:{generate_id()}") @classmethod @@ -1015,6 +1152,7 @@ class ParentRecipient: name=data.get("name", ""), notify_service=data.get("notify_service", ""), enabled=bool(data.get("enabled", True)), + presence_entity=str(data.get("presence_entity", "") or ""), id=rid, ) @@ -1022,6 +1160,7 @@ class ParentRecipient: return { "id": self.id, "name": self.name, + "presence_entity": self.presence_entity, "notify_service": self.notify_service, "enabled": self.enabled, } diff --git a/custom_components/taskmate/photos.py b/custom_components/taskmate/photos.py index 9eceb4f..53c8e98 100644 --- a/custom_components/taskmate/photos.py +++ b/custom_components/taskmate/photos.py @@ -123,9 +123,14 @@ def sign_photo_url(hass, photo_url: str, expiration_hours: int = 24) -> str: return photo_url from datetime import timedelta - from homeassistant.components.http.auth import async_sign_path - try: + # Import inside the try: this is called on the completion path now + # (#686 signs the evidence photo for the approval push), and an + # ImportError escaping here would fail the completion itself — which + # is exactly what "never break delivery over a signing hiccup" is + # meant to prevent. + from homeassistant.components.http.auth import async_sign_path + return async_sign_path(hass, photo_url, timedelta(hours=expiration_hours)) except Exception: # noqa: BLE001 - never break state delivery over a signing hiccup _LOGGER.debug("Could not sign photo URL %s", photo_url, exc_info=True) diff --git a/custom_components/taskmate/select.py b/custom_components/taskmate/select.py index 0d62b53..796df45 100644 --- a/custom_components/taskmate/select.py +++ b/custom_components/taskmate/select.py @@ -14,7 +14,7 @@ from .coordinator import TaskMateCoordinator # (setting_key, translation_key, options, default, icon) _SELECTS = [ ("streak_reset_mode", "streak_reset_mode", ["reset", "pause"], "reset", "mdi:restart"), - ("card_design", "card_design", ["classic", "playroom", "console", "cleanpro"], "classic", "mdi:palette"), + ("card_design", "card_design", ["classic", "playroom", "console", "cleanpro", "accessible"], "classic", "mdi:palette"), ] diff --git a/custom_components/taskmate/sensor.py b/custom_components/taskmate/sensor.py index e3a44bd..4530f0c 100644 --- a/custom_components/taskmate/sensor.py +++ b/custom_components/taskmate/sensor.py @@ -159,6 +159,20 @@ def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> l "name": c.name, "points": c.points, "pending_points": pending.get(c.id, 0), + # Guest profiles (#690): cards filter these out of competitive views. + **({"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")} + if getattr(c, "is_guest", False) else {}), + # Chore roulette (#677): today's pick + spins left, so the card can + # show the result and disable the button once the allowance is used. + **( + { + "roulette": { + **(coordinator.roulette_selection(c.id) or {}), + "spins_left": coordinator.roulette_spins_left(c.id), + } + } + if coordinator.roulette_enabled() else {} + ), "committed_points": committed_amount, "allocated_points": allocated.get(c.id, 0), # Allocations were deducted from child.points already, so spendable @@ -260,6 +274,25 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d record["visibility_entity"] = visibility_entity record["visibility_operator"] = getattr(c, 'visibility_operator', 'equals') record["visibility_state"] = getattr(c, 'visibility_state', 'on') + weather_entity = getattr(c, 'weather_entity', '') + if weather_entity: + record["weather_entity"] = weather_entity + record["weather_block_conditions"] = list(getattr(c, 'weather_block_conditions', []) or []) + for limit in ("weather_temp_min", "weather_temp_max", "weather_wind_max"): + value = getattr(c, limit, None) + if value is not None: + record[limit] = value + # Why it's hidden, so the panel can say "rained off" instead of + # just dropping the chore out of the list. + reason = coordinator.weather_block_reason(c) + if reason: + record["weather_blocked"] = reason + deadline_at = getattr(c, 'deadline_at', '') + if deadline_at: + record["deadline_at"] = deadline_at + speed_bonus = getattr(c, 'speed_bonus_points', 0) + if speed_bonus: + record["speed_bonus_points"] = speed_bonus disabled_for = getattr(c, 'disabled_for', []) if disabled_for: record["disabled_for"] = disabled_for @@ -269,6 +302,9 @@ def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[d assignment_current_child_id = getattr(c, 'assignment_current_child_id', '') if assignment_current_child_id: record["assignment_current_child_id"] = assignment_current_child_id + icon = getattr(c, 'icon', '') + if icon: + record["icon"] = icon completion_sound = getattr(c, 'completion_sound', 'coin') if completion_sound and completion_sound != 'coin': record["completion_sound"] = completion_sound @@ -691,16 +727,20 @@ class _CachedAttrsSensor(TaskMateBaseSensor): def __init__(self, coordinator: TaskMateCoordinator, entry: ConfigEntry) -> None: super().__init__(coordinator, entry) self._cached_attrs: dict | None = None - self._cached_data_id: int | None = None + self._cached_key: tuple[int, int] | None = None @property def extra_state_attributes(self) -> dict: - data_id = id(self.coordinator.data) - if self._cached_data_id == data_id and self._cached_attrs is not None: + # The coordinator reuses one snapshot object while storage.data_version + # is unchanged, so id(data) alone would pin these attributes forever + # when only an external entity moved — stranding the visibility and + # weather gates on stale state. external_state_version covers that. + key = (id(self.coordinator.data), getattr(self.coordinator, "external_state_version", 0)) + if self._cached_key == key and self._cached_attrs is not None: return self._cached_attrs attrs = self._build_attributes() self._cached_attrs = attrs - self._cached_data_id = data_id + self._cached_key = key return attrs def _build_attributes(self) -> dict: # pragma: no cover - abstract diff --git a/custom_components/taskmate/services.yaml b/custom_components/taskmate/services.yaml index 53de142..67349a8 100644 --- a/custom_components/taskmate/services.yaml +++ b/custom_components/taskmate/services.yaml @@ -677,6 +677,75 @@ add_chore: default: true selector: boolean: + expires_in_minutes: + name: Expires in (minutes) + description: >- + Make this a reactive chore with a deadline this many minutes from now — e.g. the washing + machine finished, empty it within 30 minutes. The chore disappears and is disabled once the + deadline passes. 0 = no deadline. + required: false + default: 0 + selector: + number: + min: 0 + max: 10080 + mode: box + unit_of_measurement: min + speed_bonus_points: + name: Speed bonus points + description: Extra points awarded if the chore is completed before the deadline. Needs expires_in_minutes. + required: false + default: 0 + selector: + number: + min: 0 + max: 10000 + mode: box + +read_aloud: + name: Read Chores Aloud + description: >- + Speak a child's outstanding chores to a media player — "Ella, you have three things left...". + Wording is set by the read-aloud templates in the TaskMate panel settings. + fields: + child_id: + name: Child ID + required: true + selector: + text: + media_player: + name: Media player + description: Where to speak. Falls back to the default set in Settings. + required: false + selector: + entity: + domain: media_player + tts_entity: + name: Text-to-speech entity + description: Which TTS service to use. Falls back to Settings, then to the only one installed. + required: false + selector: + entity: + domain: tts + message: + name: Message override + description: Say this instead of the generated summary. + required: false + selector: + text: + +spin_roulette: + name: Spin Chore Roulette + description: >- + Pick a random outstanding chore for a child at a points multiplier. Opt-in — enable it and set + the multiplier and daily spin allowance in the TaskMate panel settings. + fields: + child_id: + name: Child ID + description: The ID of the child spinning + required: true + selector: + text: skip_chore: name: Skip Chore diff --git a/custom_components/taskmate/storage.py b/custom_components/taskmate/storage.py index f41f213..a6a0aaa 100644 --- a/custom_components/taskmate/storage.py +++ b/custom_components/taskmate/storage.py @@ -28,6 +28,7 @@ from .models import ( Quest, Reward, RewardClaim, + ScheduledChange, TaskGroup, TimedSession, ) @@ -115,6 +116,10 @@ class TaskMateStorage: if "chore_display_order" not in self._data: self._data["chore_display_order"] = [] + # Ensure scheduled_changes store exists (#675) + if "scheduled_changes" not in self._data: + self._data["scheduled_changes"] = [] + # Notifications overhaul (v3.9.0) if "parent_recipients" not in self._data: self._data["parent_recipients"] = [] @@ -847,6 +852,39 @@ class TaskMateStorage: if chore_id in g.get("chore_ids", []): g["chore_ids"] = [c for c in g["chore_ids"] if c != chore_id] + # Scheduled config changes (#675) + def get_scheduled_changes(self) -> list[ScheduledChange]: + """All scheduled changes, pending and already applied.""" + return [ScheduledChange.from_dict(c) for c in self._data.get("scheduled_changes", [])] + + def get_scheduled_change(self, change_id: str) -> ScheduledChange | None: + for c in self._data.get("scheduled_changes", []): + if c.get("id") == change_id: + return ScheduledChange.from_dict(c) + return None + + def add_scheduled_change(self, change: ScheduledChange) -> None: + self._data.setdefault("scheduled_changes", []).append(change.to_dict()) + + def update_scheduled_change(self, change: ScheduledChange) -> None: + changes = self._data.setdefault("scheduled_changes", []) + for i, c in enumerate(changes): + if c.get("id") == change.id: + changes[i] = change.to_dict() + return + changes.append(change.to_dict()) + + def remove_scheduled_change(self, change_id: str) -> None: + self._data["scheduled_changes"] = [ + c for c in self._data.get("scheduled_changes", []) if c.get("id") != change_id + ] + + def remove_scheduled_changes_for_chore(self, chore_id: str) -> None: + """Drop a deleted chore's queued changes so they can't fire on nothing.""" + self._data["scheduled_changes"] = [ + c for c in self._data.get("scheduled_changes", []) if c.get("chore_id") != chore_id + ] + # Points transactions management def get_points_transactions(self) -> list[PointsTransaction]: """Get all points transactions.""" diff --git a/custom_components/taskmate/templates.py b/custom_components/taskmate/templates.py index d61558c..846d8e2 100644 --- a/custom_components/taskmate/templates.py +++ b/custom_components/taskmate/templates.py @@ -6,7 +6,9 @@ TEMPLATE_CHORE_FIELDS = ( "daily_limit", "completion_sound", "schedule_mode", "due_days", "recurrence", "recurrence_day", "recurrence_start", "first_occurrence_mode", "assignment_mode", "require_availability", "visibility_entity", - "visibility_state", "visibility_operator", "task_type", + "visibility_state", "visibility_operator", + "weather_entity", "weather_block_conditions", "weather_temp_min", + "weather_temp_max", "weather_wind_max", "task_type", "timed_rate_points", "timed_rate_minutes", "timed_max_daily_minutes", ) diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index 545ba1f..efb0eaf 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -77,6 +77,14 @@ WS_ADD_CHORE: Final = "taskmate/add_chore" WS_UPDATE_CHORE: Final = "taskmate/update_chore" WS_REMOVE_CHORE: Final = "taskmate/remove_chore" +WS_REPORT_FAIRNESS: Final = "taskmate/reports/fairness" +WS_REPORT_FRICTION: Final = "taskmate/reports/friction" +WS_REPORT_PROJECTION: Final = "taskmate/reports/projection" +WS_REPORT_HEALTH: Final = "taskmate/reports/health" +WS_SCHEDULED_LIST: Final = "taskmate/scheduled/list" +WS_SCHEDULED_ADD: Final = "taskmate/scheduled/add" +WS_SCHEDULED_REMOVE: Final = "taskmate/scheduled/remove" + WS_ADD_REWARD: Final = "taskmate/add_reward" WS_UPDATE_REWARD: Final = "taskmate/update_reward" WS_REMOVE_REWARD: Final = "taskmate/remove_reward" @@ -127,6 +135,9 @@ WS_TEMPLATES_APPLY: Final = "taskmate/templates/apply" WS_TEMPLATES_SAVE_FROM: Final = "taskmate/templates/save_from_chores" WS_TEMPLATES_CREATE: Final = "taskmate/templates/create" WS_TEMPLATES_UPDATE: Final = "taskmate/templates/update" +WS_TEMPLATES_EXPORT: Final = "taskmate/templates/export" +WS_TEMPLATES_IMPORT: Final = "taskmate/templates/import" +WS_PRINT_CHART: Final = "taskmate/print/weekly_chart" WS_TEMPLATES_DELETE: Final = "taskmate/templates/delete" # Notifications @@ -177,8 +188,9 @@ WS_CONFIG_IMPORT: Final = "taskmate/config/import" # Everything else routed through @_admin_only mutates state and is logged. _AUDIT_EXCLUDE: Final = { WS_GET_STATE, WS_NOTIF_GET_STATE, WS_NOTIF_LIST_NOTIFY, - WS_TEMPLATES_LIST, WS_TEMPLATES_GET, WS_AUDIT_LIST, WS_AUDIT_CLEAR, - WS_CONFIG_EXPORT, + WS_TEMPLATES_LIST, WS_TEMPLATES_GET, WS_TEMPLATES_EXPORT, WS_PRINT_CHART, + WS_AUDIT_LIST, WS_AUDIT_CLEAR, + WS_CONFIG_EXPORT, WS_SCHEDULED_LIST, WS_REPORT_FAIRNESS, WS_REPORT_FRICTION, WS_REPORT_PROJECTION, WS_REPORT_HEALTH, } @@ -294,6 +306,7 @@ def _build_state_snapshot(coordinator: TaskMateCoordinator) -> dict[str, Any]: "children": list(data.get("children", [])), "chores": list(data.get("chores", [])), "chore_display_order": list(data.get("chore_display_order", [])), + "scheduled_changes": list(data.get("scheduled_changes", [])), "rewards": list(data.get("rewards", [])), "penalties": list(data.get("penalties", [])), "bonuses": list(data.get("bonuses", [])), @@ -377,6 +390,8 @@ async def _ws_add_child(hass, connection, msg, coordinator): vol.Optional("unavailability_entity"): str, vol.Optional("pause_streak_when_unavailable"): bool, vol.Optional("linked_user_id"): str, + vol.Optional("is_guest"): bool, + vol.Optional("guest_expires_on"): str, }) @websocket_api.async_response @_admin_only @@ -399,6 +414,19 @@ async def _ws_update_child(hass, connection, msg, coordinator): existing.pause_streak_when_unavailable = bool(msg["pause_streak_when_unavailable"]) if "linked_user_id" in msg: existing.linked_user_id = _opt_str(msg["linked_user_id"]) + if "is_guest" in msg or "guest_expires_on" in msg: + # Routed through the coordinator so the expiry is validated and an + # archived guest is un-archived when promoted to a family member. + try: + await coordinator.async_set_guest( + existing.id, + bool(msg.get("is_guest", existing.is_guest)), + _opt_str(msg.get("guest_expires_on", existing.guest_expires_on)), + ) + except ValueError as err: + connection.send_error(msg["id"], "invalid_format", str(err)) + return + existing = coordinator.storage.get_child(msg["child_id"]) await coordinator.async_update_child(existing) connection.send_result(msg["id"], {"id": existing.id}) @@ -448,10 +476,14 @@ async def _ws_list_ha_users(hass, connection, msg, coordinator): _CHORE_EDITABLE_FIELDS = { "name", "description", "points", "assigned_to", "depends_on", "requires_approval", "time_category", "claim_allowance_minutes", "daily_limit", "completion_sound", - "difficulty", + "icon", "difficulty", "schedule_mode", "due_days", "recurrence", "recurrence_day", "recurrence_start", "first_occurrence_mode", "visibility_entity", - "visibility_state", "visibility_operator", "enabled", "expires_on", + "visibility_state", "visibility_operator", + "weather_entity", "weather_block_conditions", "weather_temp_min", + "weather_temp_max", "weather_wind_max", + "deadline_at", "speed_bonus_points", + "enabled", "expires_on", "due_time", "early_bonus", "late_penalty", "require_photo", "mandatory", "mandatory_penalty_points", "assignment_mode", "assignment_rotation_anchor", "require_availability", @@ -474,6 +506,7 @@ def _chore_payload_schema(*, require_name: bool): vol.Optional("claim_allowance_minutes"): vol.All(int, vol.Range(min=0)), vol.Optional("daily_limit"): vol.All(int, vol.Range(min=1)), vol.Optional("completion_sound"): str, + vol.Optional("icon"): str, vol.Optional("difficulty"): vol.In(["easy", "medium", "hard"]), vol.Optional("schedule_mode"): vol.In(["specific_days", "recurring", "one_shot"]), vol.Optional("due_days"): [str], @@ -484,6 +517,14 @@ def _chore_payload_schema(*, require_name: bool): vol.Optional("visibility_entity"): str, vol.Optional("visibility_state"): str, vol.Optional("visibility_operator"): str, + vol.Optional("weather_entity"): str, + vol.Optional("weather_block_conditions"): [str], + # None clears the limit — 0 is a real threshold, so it can't double as "off". + vol.Optional("weather_temp_min"): vol.Any(None, vol.Coerce(float)), + vol.Optional("weather_temp_max"): vol.Any(None, vol.Coerce(float)), + vol.Optional("weather_wind_max"): vol.Any(None, vol.All(vol.Coerce(float), vol.Range(min=0))), + vol.Optional("deadline_at"): str, + vol.Optional("speed_bonus_points"): vol.All(int, vol.Range(min=0)), vol.Optional("enabled"): bool, vol.Optional("expires_on"): str, vol.Optional("due_time"): str, @@ -602,13 +643,168 @@ async def _ws_remove_chore(hass, connection, msg, coordinator): connection.send_result(msg["id"], {"id": msg["chore_id"]}) + +# --------------------------------------------------------------------------- +# Insight reports (#679) +# --------------------------------------------------------------------------- + +@websocket_api.websocket_command({ + vol.Required("type"): WS_REPORT_FAIRNESS, + vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)), +}) +@websocket_api.async_response +@_admin_only +async def _ws_report_fairness(hass, connection, msg, coordinator): + connection.send_result(msg["id"], coordinator.fairness_report(msg.get("days"))) + + +@websocket_api.websocket_command({ + vol.Required("type"): WS_REPORT_FRICTION, + vol.Optional("days"): vol.All(int, vol.Range(min=1, max=90)), +}) +@websocket_api.async_response +@_admin_only +async def _ws_report_friction(hass, connection, msg, coordinator): + connection.send_result(msg["id"], coordinator.friction_report(msg.get("days"))) + + +@websocket_api.websocket_command({ + vol.Required("type"): WS_REPORT_PROJECTION, + vol.Optional("days"): vol.All(int, vol.Range(min=1, max=28)), +}) +@websocket_api.async_response +@_admin_only +async def _ws_report_projection(hass, connection, msg, coordinator): + connection.send_result(msg["id"], coordinator.projection_report(msg.get("days"))) + + +@websocket_api.websocket_command({ + vol.Required("type"): WS_TEMPLATES_EXPORT, + vol.Optional("template_ids"): [str], +}) +@websocket_api.async_response +@_admin_only +async def _ws_templates_export(hass, connection, msg, coordinator): + connection.send_result(msg["id"], coordinator.export_templates(msg.get("template_ids"))) + + +@websocket_api.websocket_command({ + vol.Required("type"): WS_TEMPLATES_IMPORT, + vol.Required("pack"): dict, +}) +@websocket_api.async_response +@_admin_only +async def _ws_templates_import(hass, connection, msg, coordinator): + try: + result = await coordinator.async_import_pack(msg["pack"]) + except ValueError as err: + connection.send_error(msg["id"], "invalid_format", str(err)) + return + connection.send_result(msg["id"], result) + + +@websocket_api.websocket_command({ + vol.Required("type"): WS_PRINT_CHART, + vol.Optional("orientation", default="portrait"): vol.In(["portrait", "landscape"]), + vol.Optional("week_start"): str, + vol.Optional("title"): vol.All(str, vol.Length(max=80)), +}) +@websocket_api.async_response +@_admin_only +async def _ws_print_chart(hass, connection, msg, coordinator): + from datetime import date as _date + + from homeassistant.util import dt as dt_util + + from . import printable + + raw = msg.get("week_start", "") + try: + anchor = _date.fromisoformat(raw) if raw else dt_util.as_local(dt_util.now()).date() + except (TypeError, ValueError): + connection.send_error(msg["id"], "invalid_format", "week_start must be an ISO date") + return + + data = coordinator.storage.data + html = printable.build_chart( + children=list(data.get("children", [])), + chores=list(data.get("chores", [])), + start=printable.week_start(anchor), + orientation=msg.get("orientation", "portrait"), + title=msg.get("title") or "This week", + points_name=coordinator.storage.get_points_name(), + ) + connection.send_result(msg["id"], {"html": html}) + + +@websocket_api.websocket_command({vol.Required("type"): WS_REPORT_HEALTH}) +@websocket_api.async_response +@_admin_only +async def _ws_report_health(hass, connection, msg, coordinator): + connection.send_result(msg["id"], coordinator.health_report()) + + +# --------------------------------------------------------------------------- +# Scheduled config changes (#675) +# --------------------------------------------------------------------------- + +@websocket_api.websocket_command({ + vol.Required("type"): WS_SCHEDULED_LIST, + vol.Optional("chore_id"): str, +}) +@websocket_api.async_response +@_admin_only +async def _ws_scheduled_list(hass, connection, msg, coordinator): + changes = coordinator.get_scheduled_changes(msg.get("chore_id", "")) + connection.send_result(msg["id"], {"changes": [c.to_dict() for c in changes]}) + + +@websocket_api.websocket_command({ + vol.Required("type"): WS_SCHEDULED_ADD, + vol.Required("chore_id"): str, + vol.Required("apply_on"): str, + vol.Required("changes"): dict, + vol.Optional("note", default=""): vol.All(str, vol.Length(max=200)), +}) +@websocket_api.async_response +@_admin_only +async def _ws_scheduled_add(hass, connection, msg, coordinator): + try: + change = await coordinator.async_add_scheduled_change( + chore_id=msg["chore_id"], + apply_on=msg["apply_on"], + changes=msg["changes"], + note=msg.get("note", ""), + ) + except ValueError as err: + connection.send_error(msg["id"], "invalid_format", str(err)) + return + connection.send_result(msg["id"], {"id": change.id}) + + +@websocket_api.websocket_command({ + vol.Required("type"): WS_SCHEDULED_REMOVE, + vol.Required("change_id"): str, +}) +@websocket_api.async_response +@_admin_only +async def _ws_scheduled_remove(hass, connection, msg, coordinator): + try: + await coordinator.async_remove_scheduled_change(msg["change_id"]) + except ValueError as err: + connection.send_error(msg["id"], "not_found", str(err)) + return + connection.send_result(msg["id"], {"success": True}) + + # --------------------------------------------------------------------------- # Rewards # --------------------------------------------------------------------------- _REWARD_FIELDS = {"name", "cost", "description", "icon", "assigned_to", "is_jackpot", "pool_enabled", "quantity", "expires_at", - "restock_enabled", "restock_amount", "restock_period"} + "restock_enabled", "restock_amount", "restock_period", + "unlock_entity", "unlock_minutes"} def _reward_payload_schema(*, require_name: bool): @@ -627,6 +823,8 @@ def _reward_payload_schema(*, require_name: bool): vol.Optional("restock_enabled"): bool, vol.Optional("restock_amount"): vol.All(int, vol.Range(min=0, max=10000)), vol.Optional("restock_period"): vol.In(["daily", "weekly", "monthly"]), + vol.Optional("unlock_entity"): str, + vol.Optional("unlock_minutes"): vol.All(int, vol.Range(min=0, max=1440)), } @@ -637,6 +835,16 @@ def _reward_payload_schema(*, require_name: bool): @websocket_api.async_response @_admin_only async def _ws_add_reward(hass, connection, msg, coordinator): + # Timed unlock (#678): refuse an entity that isn't on the parent's + # allowlist, at save time, with a message the panel can show. + try: + unlock_entity, unlock_minutes = coordinator.validate_unlock( + msg.get("unlock_entity", ""), msg.get("unlock_minutes", 0), + ) + except ValueError as err: + connection.send_error(msg["id"], "invalid_format", str(err)) + return + # coordinator.async_add_reward accepts a subset; build a Reward dataclass # to populate every editable field uniformly. reward = Reward( @@ -652,6 +860,8 @@ async def _ws_add_reward(hass, connection, msg, coordinator): restock_enabled=msg.get("restock_enabled", False), restock_amount=msg.get("restock_amount", 0), restock_period=msg.get("restock_period", "weekly"), + unlock_entity=unlock_entity, + unlock_minutes=unlock_minutes, ) coordinator.storage.add_reward(reward) await coordinator.storage.async_save() @@ -671,6 +881,15 @@ async def _ws_update_reward(hass, connection, msg, coordinator): if not existing: connection.send_error(msg["id"], "not_found", f"Reward {msg['reward_id']} not found") return + if "unlock_entity" in msg or "unlock_minutes" in msg: + try: + coordinator.validate_unlock( + msg.get("unlock_entity", existing.unlock_entity), + msg.get("unlock_minutes", existing.unlock_minutes), + ) + except ValueError as err: + connection.send_error(msg["id"], "invalid_format", str(err)) + return for field in _REWARD_FIELDS: if field in msg: value = msg[field] @@ -1105,7 +1324,7 @@ async def _ws_remove_task_group(hass, connection, msg, coordinator): # Top-level fields stored at storage._data root _TOP_LEVEL_SETTINGS = {"points_name", "points_icon"} # Allowed values for the global default card-design style (per-card design styles). -_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro"} +_ALLOWED_CARD_DESIGNS = {"classic", "playroom", "console", "cleanpro", "accessible"} # Settings stored under storage._data["settings"][key] _SUBKEY_SETTINGS = { "history_days", "streak_reset_mode", "card_design", @@ -1113,6 +1332,10 @@ _SUBKEY_SETTINGS = { "perfect_week_bonus", "streak_milestones", "streak_requires_all_chores", "perfect_week_requires_all_chores", "difficulty_multiplier_easy", "difficulty_multiplier_medium", "difficulty_multiplier_hard", + "unlock_allowlist", "parent_routing", + "read_aloud_media_player", "read_aloud_tts_entity", "read_aloud_template", + "read_aloud_one_template", "read_aloud_done_template", "read_aloud_joiner", + "roulette_enabled", "roulette_multiplier", "roulette_daily_spins", "surprise_bonus_enabled", "surprise_bonus_chance", "surprise_bonus_min", "surprise_bonus_max", "points_decay_enabled", "points_decay_period", "points_decay_percent", "level_xp_step", @@ -1272,6 +1495,17 @@ _UPDATE_SETTINGS_SCHEMA = { vol.Optional("difficulty_multiplier_easy"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)), vol.Optional("difficulty_multiplier_medium"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)), vol.Optional("difficulty_multiplier_hard"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=10.0)), + vol.Optional("unlock_allowlist"): [str], + vol.Optional("parent_routing"): vol.In(["all", "home", "round_robin"]), + vol.Optional("read_aloud_media_player"): str, + vol.Optional("read_aloud_tts_entity"): str, + vol.Optional("read_aloud_template"): vol.All(str, vol.Length(max=300)), + vol.Optional("read_aloud_one_template"): vol.All(str, vol.Length(max=300)), + vol.Optional("read_aloud_done_template"): vol.All(str, vol.Length(max=300)), + vol.Optional("read_aloud_joiner"): vol.All(str, vol.Length(max=20)), + vol.Optional("roulette_enabled"): bool, + vol.Optional("roulette_multiplier"): vol.All(vol.Coerce(float), vol.Range(min=1.0, max=5.0)), + vol.Optional("roulette_daily_spins"): vol.All(int, vol.Range(min=1, max=10)), vol.Optional("surprise_bonus_enabled"): bool, vol.Optional("surprise_bonus_chance"): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=100.0)), vol.Optional("surprise_bonus_min"): vol.All(int, vol.Range(min=0, max=10000)), @@ -1770,6 +2004,7 @@ async def ws_notif_set_child_quiet(hass, connection, msg, coordinator): vol.Required("name"): str, vol.Required("notify_service"): str, vol.Optional("enabled", default=True): bool, + vol.Optional("presence_entity", default=""): str, }) @websocket_api.async_response @_admin_only @@ -1787,6 +2022,7 @@ async def ws_notif_upsert_parent(hass, connection, msg, coordinator): existing.name = msg["name"] existing.notify_service = msg["notify_service"] existing.enabled = msg["enabled"] + existing.presence_entity = msg.get("presence_entity", "") await c.notifications.upsert_parent(existing) connection.send_result(msg["id"], existing.to_dict()) else: @@ -1794,6 +2030,7 @@ async def ws_notif_upsert_parent(hass, connection, msg, coordinator): name=msg["name"], notify_service=msg["notify_service"], enabled=msg["enabled"], + presence_entity=msg.get("presence_entity", ""), ) await c.notifications.upsert_parent(p) connection.send_result(msg["id"], p.to_dict()) @@ -2060,6 +2297,9 @@ _COMMANDS = ( _ws_audit_list, _ws_audit_clear, _ws_undo_transaction, _ws_add_child, _ws_update_child, _ws_remove_child, _ws_list_ha_users, _ws_add_chore, _ws_update_chore, _ws_remove_chore, _ws_clone_chore, + _ws_scheduled_list, _ws_scheduled_add, _ws_scheduled_remove, + _ws_report_fairness, _ws_report_friction, _ws_report_projection, _ws_report_health, + _ws_templates_export, _ws_templates_import, _ws_print_chart, _ws_bulk_chore_action, _ws_gift_points, _ws_request_swap, _ws_approve_swap, _ws_reject_swap, _ws_config_export, _ws_config_import, diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index eaa504c..6ab5bcb 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -1538,5 +1538,179 @@ "panel.family_goal_reward": "Belohnung", "panel.family_goal_reward_ph": "ein Familienfilmabend", "notification.family_goal_reached.name": "Familienziel erreicht", - "notification.family_goal_reached.description": "Feiert, wenn die kombinierten Punkte der Familie das gemeinsame Ziel erreichen. Standardmäßig aus." + "notification.family_goal_reached.description": "Feiert, wenn die kombinierten Punkte der Familie das gemeinsame Ziel erreichen. Standardmäßig aus.", + "panel.chore_advanced_weather": "Erweitert — Wetterbedingungen", + "panel.chore_weather_intro": "Diese Aufgabe ausblenden, solange das Wetter ungeeignet ist. Praktisch für Arbeiten im Freien — eine wetterbedingt ausgefallene Aufgabe gilt nicht als versäumte Pflichtaufgabe und unterbricht keine Serie.", + "panel.chore_weather_entity_label": "Wetter-Entität", + "panel.chore_weather_entity_hint": "Leer lassen, um das Wetter komplett zu ignorieren.", + "panel.chore_weather_conditions_label": "Ausblenden bei folgendem Wetter", + "panel.chore_weather_conditions_hint": "Wähle die Bedingungen, bei denen die Aufgabe ausgeblendet wird.", + "panel.chore_weather_temp_min_label": "Mindesttemperatur", + "panel.chore_weather_temp_min_hint": "Darunter ausblenden. Leer lassen für keine Grenze.", + "panel.chore_weather_temp_max_label": "Höchsttemperatur", + "panel.chore_weather_temp_max_hint": "Darüber ausblenden. Leer lassen für keine Grenze.", + "panel.chore_weather_wind_max_label": "Maximale Windgeschwindigkeit", + "panel.chore_weather_wind_max_hint": "Darüber ausblenden. Leer lassen für keine Grenze.", + "panel.chore_weather_failopen_hint": "Die Grenzwerte verwenden die Einheiten deiner Wetter-Entität. Fehlt die Entität oder ist sie nicht verfügbar, bleibt die Aufgabe sichtbar.", + "weather.blocked_condition": "Ausgeblendet — ungeeignetes Wetter", + "weather.blocked_temp_low": "Ausgeblendet — zu kalt", + "weather.blocked_temp_high": "Ausgeblendet — zu heiß", + "weather.blocked_wind": "Ausgeblendet — zu windig", + "weather.condition_rainy": "Regnerisch", + "weather.condition_pouring": "Starkregen", + "weather.condition_snowy": "Schnee", + "weather.condition_snowy_rainy": "Schneeregen", + "weather.condition_hail": "Hagel", + "weather.condition_lightning": "Blitze", + "weather.condition_lightning_rainy": "Gewitter", + "weather.condition_fog": "Nebel", + "weather.condition_windy": "Windig", + "weather.condition_windy_variant": "Sehr windig", + "weather.condition_cloudy": "Bewölkt", + "weather.condition_exceptional": "Unwetter", + "child.deadline_minutes": "noch {mins} Min.", + "child.deadline_hours": "noch {hours} Std. {mins} Min.", + "child.deadline_title": "Erledige das, bevor die Zeit abläuft", + "panel.chore_advanced_scheduled": "Erweitert — geplante Änderungen", + "panel.chore_scheduled_intro": "Plane eine Änderung für ein späteres Datum — „ab 1. September 20 Punkte wert“. Wird um Mitternacht angewendet; war Home Assistant aus, wird es beim nächsten Start nachgeholt.", + "panel.chore_scheduled_none": "Keine Änderungen geplant.", + "panel.chore_scheduled_history": "Bereits angewendet", + "panel.chore_scheduled_applied": "Angewendet", + "panel.sched_date_label": "Anwenden am", + "panel.sched_field_label": "Änderung", + "panel.sched_value_label": "Neuer Wert", + "panel.sched_note_label": "Notiz (optional)", + "panel.sched_note_hint": "Eine Erinnerung an den Grund, neben der geplanten Änderung angezeigt.", + "panel.btn_add_scheduled": "Änderung planen", + "panel.sched_value_yes": "Ja", + "panel.sched_value_no": "Nein", + "panel.sched_value_nobody": "Niemand", + "panel.toast_sched_added": "Änderung geplant", + "panel.toast_sched_removed": "Änderung entfernt", + "panel.toast_sched_date_required": "Wähle ein Datum für die Änderung", + "panel.toast_sched_value_required": "Gib einen Wert für die Änderung ein", + "panel.sched_field_points": "Punkte", + "panel.sched_field_assigned_to": "Zugewiesen an", + "panel.sched_field_enabled": "Aktiviert", + "panel.sched_field_requires_approval": "Bestätigung nötig", + "panel.sched_field_daily_limit": "Tageslimit", + "panel.sched_field_due_days": "Tage", + "panel.sched_field_mandatory": "Pflicht", + "panel.sched_field_mandatory_penalty_points": "Strafpunkte", + "panel.sched_field_expires_on": "Läuft ab am", + "panel.sched_field_description": "Beschreibung", + "panel.sched_field_time_category": "Tageszeit", + "panel.sched_field_difficulty": "Schwierigkeit", + "routine.title": "{name}s Routine", + "routine.no_child": "Wähle ein Kind für diese Karte.", + "routine.task_of": "Aufgabe {current} von {total}", + "routine.all_done_count": "Alle {total} erledigt", + "routine.done": "Fertig ✓", + "routine.completed": "Erledigt", + "routine.sent_for_checking": "Zur Prüfung gesendet", + "routine.skip": "Später", + "routine.next": "Weiter →", + "routine.back": "← Zurück", + "routine.waiting_for_parent": "⏳ Warte auf die Prüfung durch einen Erwachsenen", + "routine.photo_note": "Hierfür ist ein Foto nötig — tippe es auf deiner Karte an.", + "routine.finished_title": "Routine geschafft!", + "routine.finished_body": "Gut gemacht, {name}. Das war alles.", + "routine.earned": "+{points} verdient", + "routine.awaiting_points": "+{points}, sobald ein Erwachsener prüft", + "routine.skipped_note": "{count} für später übrig.", + "routine.start_again": "Neu starten", + "routine.empty_title": "Nichts zu tun", + "routine.empty_body": "Diese Routine ist schon erledigt.", + "child.roulette_spin": "Für eine ×{multiplier}-Aufgabe drehen", + "child.roulette_respin": "Nochmal drehen (noch {count})", + "child.roulette_no_spins": "Heute keine Drehungen mehr", + "panel.settings_roulette_section": "Aufgaben-Roulette", + "panel.settings_roulette_enabled": "Aufgaben-Roulette aktivieren", + "panel.settings_roulette_enabled_hint": "Ein Kind kann für eine zufällige offene Aufgabe mit Extrapunkten drehen.", + "panel.settings_roulette_multiplier": "Punkte-Multiplikator", + "panel.settings_roulette_spins": "Drehungen pro Tag", + "panel.settings_unlock_allowlist": "Freischalt-Positivliste", + "panel.settings_unlock_allowlist_hint": "Nur diese Entitäten dürfen von einer Belohnung freigeschaltet werden. Bis du hier etwas hinzufügst, ist nichts erlaubt.", + "panel.settings_unlock_allowlist_empty": "Noch nichts erlaubt.", + "panel.settings_unlock_allowlist_add": "Entität zum Erlauben suchen", + "panel.reward_unlock_section": "Erweitert — zeitlich begrenzte Freischaltung", + "panel.reward_unlock_intro": "Schaltet etwas ein, wenn diese Belohnung genehmigt wird, und nach einer festgelegten Zeit wieder aus.", + "panel.reward_unlock_none": "Nichts", + "panel.reward_unlock_entity": "Freischalten", + "panel.reward_unlock_minutes": "Wie lange (Minuten)", + "panel.reward_unlock_minutes_hint": "0 = eingeschaltet lassen; du musst selbst ausschalten.", + "panel.reward_unlock_no_allowlist": "Füge zuerst in den Einstellungen eine Entität zur Freischalt-Positivliste hinzu.", + "panel.tab_insights": "Einblicke", + "panel.insights_loading": "Wird berechnet…", + "panel.insights_fairness_title": "Wer macht die Arbeit?", + "panel.insights_fairness_intro": "{start} bis {end}. Gleichmäßig verteilt wären {fair}% pro Kind.", + "panel.insights_last_days": "Letzte {days} Tage", + "panel.insights_no_data": "In diesem Zeitraum noch keine bestätigten Aufgaben.", + "panel.insights_balanced": "Wirkt recht ausgewogen.", + "panel.insights_unbalanced": "Die Arbeit ist ungleich verteilt.", + "panel.insights_chores": "Aufgaben", + "panel.insights_status_over": "Macht mehr", + "panel.insights_status_under": "Macht weniger", + "panel.insights_status_balanced": "Ausgewogen", + "panel.insights_status_idle": "Noch nichts", + "panel.insights_fairness_note": "Bewertet nach Anzahl der Aufgaben, nicht nach Punkten, damit eine teurere Aufgabe keine ungleiche Verteilung verdeckt. Markiert, wenn ein Kind mehr als {tolerance} Punkte von einem gleichen Anteil abweicht.", + "panel.insights_chore": "Aufgabe", + "panel.insights_view_fairness": "Fairness", + "panel.insights_view_friction": "Reibung", + "panel.insights_friction_title": "Was funktioniert nicht?", + "panel.insights_friction_intro": "{start} bis {end}. Aufgaben werden danach bewertet, wie oft sie tatsächlich erledigt wurden im Verhältnis dazu, wie oft sie anstanden.", + "panel.insights_friction_none": "Keine aktiven Aufgaben vorhanden.", + "panel.insights_friction_healthy": "Es wird alles erledigt.", + "panel.insights_friction_problems": "{count} Aufgabe(n) genauer ansehen.", + "panel.insights_col_chore": "Aufgabe", + "panel.insights_col_done": "Erledigt", + "panel.insights_col_rate": "Quote", + "panel.insights_col_last": "Zuletzt", + "panel.insights_col_verdict": "Urteil", + "panel.insights_col_suggestion": "Vorschlag", + "panel.insights_never": "Nie", + "panel.insights_days_ago": "vor {days} T.", + "panel.insights_verdict_never": "Nie erledigt", + "panel.insights_verdict_stalling": "Bleibt liegen", + "panel.insights_verdict_struggling": "Unregelmäßig", + "panel.insights_verdict_fine": "In Ordnung", + "panel.insights_verdict_unknown": "Unklar", + "panel.insights_suggest_retire": "Abschaffen", + "panel.insights_suggest_reprice": "Punkte erhöhen", + "panel.insights_suggest_reassign": "Jemand anderem geben", + "panel.insights_suggest_keep": "So lassen", + "panel.insights_outstanding_misses": "Offene versäumte Pflichtaufgaben", + "panel.insights_friction_note": "Ablehnungen sind nicht aufgeführt: TaskMate löscht eine Erledigung, wenn du sie ablehnst, es gibt also keinen Verlauf dazu. Die erwarteten Werte sind Näherungen — Rotation, Abhängigkeiten und Wetter werden nicht nachgestellt.", + "panel.insights_view_projection": "Kommende Woche", + "panel.insights_projection_title": "Was steht an?", + "panel.insights_projection_intro": "{start} bis {end}, basierend auf dem Zeitplan jeder Aufgabe.", + "panel.insights_next_days": "Nächste {days} Tage", + "panel.insights_col_day": "Tag", + "panel.insights_projected_earn": "bis zu {points} {name}", + "panel.insights_projected_total": "käme auf {total}", + "panel.insights_projection_unassigned": "{points} Punkte sind niemandem zugewiesen.", + "panel.insights_projection_note": "Eine Obergrenze, keine Prognose: Aufgaben für alle werden jedem berechtigten Kind angerechnet, da der Zeitplan nicht wissen kann, wer zuerst da ist. Wetter, Abhängigkeiten und Verfügbarkeit werden nicht vorhergesagt — sie hängen vom Tag ab.", + "panel.insights_view_health": "Zustand", + "panel.health_title": "Ist etwas kaputt?", + "panel.health_recheck": "Erneut prüfen", + "panel.health_all_good": "Nichts gefunden.", + "panel.health_issues": "{count} Sache(n) brauchen Aufmerksamkeit.", + "panel.health_sev_error": "Fehler", + "panel.health_sev_warning": "Warnung", + "panel.health_sev_info": "Hinweis", + "panel.health_goto": "Anzeigen", + "panel.health_count_children": "Kinder", + "panel.health_count_chores": "Aufgaben", + "panel.health_count_enabled_chores": "Aktiv", + "panel.health_count_rewards": "Belohnungen", + "panel.health_count_completions": "Erledigungen", + "panel.health_count_badges": "Abzeichen", + "panel.health_count_scheduled_changes": "Geplant", + "panel.health_count_active_unlocks": "Freischaltungen", + "panel.health_count_mandatory_misses": "Versäumt", + "panel.health_count_storage": "Speicher", + "panel.chore_icon_label": "Bild", + "child.editor.pre_reader": "Nur-Bilder-Modus (für kleine Kinder)", + "child.editor.pre_reader_labels": "Namen im Bildmodus anzeigen", + "common.design.accessible": "Barrierefrei" } diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index 3f9ae72..62ff8f7 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -1538,5 +1538,179 @@ "panel.family_goal_reward": "Reward", "panel.family_goal_reward_ph": "a family movie night", "notification.family_goal_reached.name": "Family goal reached", - "notification.family_goal_reached.description": "Celebrates when the family's combined points reach the shared goal. Off by default." + "notification.family_goal_reached.description": "Celebrates when the family's combined points reach the shared goal. Off by default.", + "panel.chore_advanced_weather": "Advanced — weather conditions", + "panel.chore_weather_intro": "Hide this chore while the weather is unsuitable. Useful for outdoor jobs — a rained-off chore doesn't count as a mandatory miss and won't break a streak.", + "panel.chore_weather_entity_label": "Weather entity", + "panel.chore_weather_entity_hint": "Leave empty to ignore the weather entirely.", + "panel.chore_weather_conditions_label": "Hide when the weather is", + "panel.chore_weather_conditions_hint": "Pick any conditions that should hide the chore.", + "panel.chore_weather_temp_min_label": "Minimum temperature", + "panel.chore_weather_temp_min_hint": "Hide below this. Leave empty for no limit.", + "panel.chore_weather_temp_max_label": "Maximum temperature", + "panel.chore_weather_temp_max_hint": "Hide above this. Leave empty for no limit.", + "panel.chore_weather_wind_max_label": "Maximum wind speed", + "panel.chore_weather_wind_max_hint": "Hide above this. Leave empty for no limit.", + "panel.chore_weather_failopen_hint": "Limits use whatever units your weather entity reports. If the entity is missing or unavailable, the chore stays visible.", + "weather.blocked_condition": "Hidden — unsuitable weather", + "weather.blocked_temp_low": "Hidden — too cold", + "weather.blocked_temp_high": "Hidden — too hot", + "weather.blocked_wind": "Hidden — too windy", + "weather.condition_rainy": "Rainy", + "weather.condition_pouring": "Pouring", + "weather.condition_snowy": "Snowy", + "weather.condition_snowy_rainy": "Sleet", + "weather.condition_hail": "Hail", + "weather.condition_lightning": "Lightning", + "weather.condition_lightning_rainy": "Thunderstorm", + "weather.condition_fog": "Fog", + "weather.condition_windy": "Windy", + "weather.condition_windy_variant": "Very windy", + "weather.condition_cloudy": "Cloudy", + "weather.condition_exceptional": "Severe", + "child.deadline_minutes": "{mins} min left", + "child.deadline_hours": "{hours}h {mins}m left", + "child.deadline_title": "Do this before the time runs out", + "panel.chore_advanced_scheduled": "Advanced — scheduled changes", + "panel.chore_scheduled_intro": "Queue a change to take effect on a future date — \"from 1 September this is worth 20 points\". Applied at midnight; if Home Assistant was off, it catches up on the next start.", + "panel.chore_scheduled_none": "No changes queued.", + "panel.chore_scheduled_history": "Already applied", + "panel.chore_scheduled_applied": "Applied", + "panel.sched_date_label": "Apply on", + "panel.sched_field_label": "Change", + "panel.sched_value_label": "New value", + "panel.sched_note_label": "Note (optional)", + "panel.sched_note_hint": "A reminder of why, shown beside the queued change.", + "panel.btn_add_scheduled": "Queue change", + "panel.sched_value_yes": "Yes", + "panel.sched_value_no": "No", + "panel.sched_value_nobody": "Nobody", + "panel.toast_sched_added": "Change queued", + "panel.toast_sched_removed": "Change removed", + "panel.toast_sched_date_required": "Pick a date for the change", + "panel.toast_sched_value_required": "Enter a value for the change", + "panel.sched_field_points": "Points", + "panel.sched_field_assigned_to": "Assigned to", + "panel.sched_field_enabled": "Enabled", + "panel.sched_field_requires_approval": "Requires approval", + "panel.sched_field_daily_limit": "Daily limit", + "panel.sched_field_due_days": "Days", + "panel.sched_field_mandatory": "Mandatory", + "panel.sched_field_mandatory_penalty_points": "Penalty points", + "panel.sched_field_expires_on": "Expires on", + "panel.sched_field_description": "Description", + "panel.sched_field_time_category": "Time of day", + "panel.sched_field_difficulty": "Difficulty", + "routine.title": "{name}'s routine", + "routine.no_child": "Pick a child for this card.", + "routine.task_of": "Task {current} of {total}", + "routine.all_done_count": "All {total} done", + "routine.done": "Done ✓", + "routine.completed": "Completed", + "routine.sent_for_checking": "Sent for checking", + "routine.skip": "Skip for now", + "routine.next": "Next →", + "routine.back": "← Back", + "routine.waiting_for_parent": "⏳ Waiting for a grown-up to check", + "routine.photo_note": "This one needs a photo — tap it on your card.", + "routine.finished_title": "Routine done!", + "routine.finished_body": "Nice one, {name}. That's everything.", + "routine.earned": "+{points} earned", + "routine.awaiting_points": "+{points} once a grown-up checks", + "routine.skipped_note": "{count} left for later.", + "routine.start_again": "Start again", + "routine.empty_title": "Nothing to do", + "routine.empty_body": "This routine is already finished.", + "child.roulette_spin": "Spin for a ×{multiplier} chore", + "child.roulette_respin": "Spin again ({count} left)", + "child.roulette_no_spins": "No spins left today", + "panel.settings_roulette_section": "Chore roulette", + "panel.settings_roulette_enabled": "Enable chore roulette", + "panel.settings_roulette_enabled_hint": "Lets a child spin for a random outstanding chore worth extra points.", + "panel.settings_roulette_multiplier": "Points multiplier", + "panel.settings_roulette_spins": "Spins per day", + "panel.settings_unlock_allowlist": "Unlock allowlist", + "panel.settings_unlock_allowlist_hint": "Only these entities may be unlocked by a reward. Nothing is allowed until you add something here.", + "panel.settings_unlock_allowlist_empty": "Nothing allowed yet.", + "panel.settings_unlock_allowlist_add": "Search for an entity to allow", + "panel.reward_unlock_section": "Advanced — timed unlock", + "panel.reward_unlock_intro": "Turn something on when this reward is approved, and back off again after a set time.", + "panel.reward_unlock_none": "Nothing", + "panel.reward_unlock_entity": "Unlock", + "panel.reward_unlock_minutes": "For how long (minutes)", + "panel.reward_unlock_minutes_hint": "0 = leave it on; you'll need to turn it off yourself.", + "panel.reward_unlock_no_allowlist": "Add an entity to the unlock allowlist in Settings first.", + "panel.tab_insights": "Insights", + "panel.insights_loading": "Working it out…", + "panel.insights_fairness_title": "Who's doing the work?", + "panel.insights_fairness_intro": "{start} to {end}. An even split would be {fair}% each.", + "panel.insights_last_days": "Last {days} days", + "panel.insights_no_data": "No approved chores in this period yet.", + "panel.insights_balanced": "Looks fairly balanced.", + "panel.insights_unbalanced": "The workload is uneven.", + "panel.insights_chores": "chores", + "panel.insights_status_over": "Doing more", + "panel.insights_status_under": "Doing less", + "panel.insights_status_balanced": "Balanced", + "panel.insights_status_idle": "Nothing yet", + "panel.insights_fairness_note": "Judged on chore count, not points, so a pricier chore doesn't hide an uneven split. Flagged when a child is more than {tolerance} points off an even share.", + "panel.insights_chore": "chore", + "panel.insights_view_fairness": "Fairness", + "panel.insights_view_friction": "Friction", + "panel.insights_friction_title": "What isn't working?", + "panel.insights_friction_intro": "{start} to {end}. Chores are judged on how often they actually get done versus how often they came up.", + "panel.insights_friction_none": "No active chores to look at.", + "panel.insights_friction_healthy": "Everything is getting done.", + "panel.insights_friction_problems": "{count} chore(s) worth a look.", + "panel.insights_col_chore": "Chore", + "panel.insights_col_done": "Done", + "panel.insights_col_rate": "Rate", + "panel.insights_col_last": "Last done", + "panel.insights_col_verdict": "Verdict", + "panel.insights_col_suggestion": "Try", + "panel.insights_never": "Never", + "panel.insights_days_ago": "{days}d ago", + "panel.insights_verdict_never": "Never done", + "panel.insights_verdict_stalling": "Stalling", + "panel.insights_verdict_struggling": "Patchy", + "panel.insights_verdict_fine": "Fine", + "panel.insights_verdict_unknown": "Unclear", + "panel.insights_suggest_retire": "Retire it", + "panel.insights_suggest_reprice": "Raise the points", + "panel.insights_suggest_reassign": "Give it to someone else", + "panel.insights_suggest_keep": "Leave it alone", + "panel.insights_outstanding_misses": "Outstanding mandatory misses", + "panel.insights_friction_note": "Rejections aren't listed: TaskMate deletes a completion when you reject it, so there's no rejection history to count. Expected counts are approximate — they don't replay rotation, dependencies or weather.", + "panel.insights_view_projection": "Week ahead", + "panel.insights_projection_title": "What's coming up?", + "panel.insights_projection_intro": "{start} to {end}, based on each chore's schedule.", + "panel.insights_next_days": "Next {days} days", + "panel.insights_col_day": "Day", + "panel.insights_projected_earn": "up to {points} {name}", + "panel.insights_projected_total": "would reach {total}", + "panel.insights_projection_unassigned": "{points} points aren't assigned to anyone.", + "panel.insights_projection_note": "A ceiling, not a forecast: chores open to everyone are counted for each eligible child, since the schedule can't know who'll get there first. Weather, dependencies and availability aren't projected — they depend on the day.", + "panel.insights_view_health": "Health", + "panel.health_title": "Is anything broken?", + "panel.health_recheck": "Check again", + "panel.health_all_good": "Nothing wrong found.", + "panel.health_issues": "{count} thing(s) need attention.", + "panel.health_sev_error": "Error", + "panel.health_sev_warning": "Warning", + "panel.health_sev_info": "Note", + "panel.health_goto": "Show me", + "panel.health_count_children": "Children", + "panel.health_count_chores": "Chores", + "panel.health_count_enabled_chores": "Active", + "panel.health_count_rewards": "Rewards", + "panel.health_count_completions": "Completions", + "panel.health_count_badges": "Badges", + "panel.health_count_scheduled_changes": "Scheduled", + "panel.health_count_active_unlocks": "Unlocks", + "panel.health_count_mandatory_misses": "Misses", + "panel.health_count_storage": "Storage", + "panel.chore_icon_label": "Picture", + "child.editor.pre_reader": "Picture-only mode (for young children)", + "child.editor.pre_reader_labels": "Show names in picture mode", + "common.design.accessible": "Accessible" } diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index 3f9ae72..62ff8f7 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -1538,5 +1538,179 @@ "panel.family_goal_reward": "Reward", "panel.family_goal_reward_ph": "a family movie night", "notification.family_goal_reached.name": "Family goal reached", - "notification.family_goal_reached.description": "Celebrates when the family's combined points reach the shared goal. Off by default." + "notification.family_goal_reached.description": "Celebrates when the family's combined points reach the shared goal. Off by default.", + "panel.chore_advanced_weather": "Advanced — weather conditions", + "panel.chore_weather_intro": "Hide this chore while the weather is unsuitable. Useful for outdoor jobs — a rained-off chore doesn't count as a mandatory miss and won't break a streak.", + "panel.chore_weather_entity_label": "Weather entity", + "panel.chore_weather_entity_hint": "Leave empty to ignore the weather entirely.", + "panel.chore_weather_conditions_label": "Hide when the weather is", + "panel.chore_weather_conditions_hint": "Pick any conditions that should hide the chore.", + "panel.chore_weather_temp_min_label": "Minimum temperature", + "panel.chore_weather_temp_min_hint": "Hide below this. Leave empty for no limit.", + "panel.chore_weather_temp_max_label": "Maximum temperature", + "panel.chore_weather_temp_max_hint": "Hide above this. Leave empty for no limit.", + "panel.chore_weather_wind_max_label": "Maximum wind speed", + "panel.chore_weather_wind_max_hint": "Hide above this. Leave empty for no limit.", + "panel.chore_weather_failopen_hint": "Limits use whatever units your weather entity reports. If the entity is missing or unavailable, the chore stays visible.", + "weather.blocked_condition": "Hidden — unsuitable weather", + "weather.blocked_temp_low": "Hidden — too cold", + "weather.blocked_temp_high": "Hidden — too hot", + "weather.blocked_wind": "Hidden — too windy", + "weather.condition_rainy": "Rainy", + "weather.condition_pouring": "Pouring", + "weather.condition_snowy": "Snowy", + "weather.condition_snowy_rainy": "Sleet", + "weather.condition_hail": "Hail", + "weather.condition_lightning": "Lightning", + "weather.condition_lightning_rainy": "Thunderstorm", + "weather.condition_fog": "Fog", + "weather.condition_windy": "Windy", + "weather.condition_windy_variant": "Very windy", + "weather.condition_cloudy": "Cloudy", + "weather.condition_exceptional": "Severe", + "child.deadline_minutes": "{mins} min left", + "child.deadline_hours": "{hours}h {mins}m left", + "child.deadline_title": "Do this before the time runs out", + "panel.chore_advanced_scheduled": "Advanced — scheduled changes", + "panel.chore_scheduled_intro": "Queue a change to take effect on a future date — \"from 1 September this is worth 20 points\". Applied at midnight; if Home Assistant was off, it catches up on the next start.", + "panel.chore_scheduled_none": "No changes queued.", + "panel.chore_scheduled_history": "Already applied", + "panel.chore_scheduled_applied": "Applied", + "panel.sched_date_label": "Apply on", + "panel.sched_field_label": "Change", + "panel.sched_value_label": "New value", + "panel.sched_note_label": "Note (optional)", + "panel.sched_note_hint": "A reminder of why, shown beside the queued change.", + "panel.btn_add_scheduled": "Queue change", + "panel.sched_value_yes": "Yes", + "panel.sched_value_no": "No", + "panel.sched_value_nobody": "Nobody", + "panel.toast_sched_added": "Change queued", + "panel.toast_sched_removed": "Change removed", + "panel.toast_sched_date_required": "Pick a date for the change", + "panel.toast_sched_value_required": "Enter a value for the change", + "panel.sched_field_points": "Points", + "panel.sched_field_assigned_to": "Assigned to", + "panel.sched_field_enabled": "Enabled", + "panel.sched_field_requires_approval": "Requires approval", + "panel.sched_field_daily_limit": "Daily limit", + "panel.sched_field_due_days": "Days", + "panel.sched_field_mandatory": "Mandatory", + "panel.sched_field_mandatory_penalty_points": "Penalty points", + "panel.sched_field_expires_on": "Expires on", + "panel.sched_field_description": "Description", + "panel.sched_field_time_category": "Time of day", + "panel.sched_field_difficulty": "Difficulty", + "routine.title": "{name}'s routine", + "routine.no_child": "Pick a child for this card.", + "routine.task_of": "Task {current} of {total}", + "routine.all_done_count": "All {total} done", + "routine.done": "Done ✓", + "routine.completed": "Completed", + "routine.sent_for_checking": "Sent for checking", + "routine.skip": "Skip for now", + "routine.next": "Next →", + "routine.back": "← Back", + "routine.waiting_for_parent": "⏳ Waiting for a grown-up to check", + "routine.photo_note": "This one needs a photo — tap it on your card.", + "routine.finished_title": "Routine done!", + "routine.finished_body": "Nice one, {name}. That's everything.", + "routine.earned": "+{points} earned", + "routine.awaiting_points": "+{points} once a grown-up checks", + "routine.skipped_note": "{count} left for later.", + "routine.start_again": "Start again", + "routine.empty_title": "Nothing to do", + "routine.empty_body": "This routine is already finished.", + "child.roulette_spin": "Spin for a ×{multiplier} chore", + "child.roulette_respin": "Spin again ({count} left)", + "child.roulette_no_spins": "No spins left today", + "panel.settings_roulette_section": "Chore roulette", + "panel.settings_roulette_enabled": "Enable chore roulette", + "panel.settings_roulette_enabled_hint": "Lets a child spin for a random outstanding chore worth extra points.", + "panel.settings_roulette_multiplier": "Points multiplier", + "panel.settings_roulette_spins": "Spins per day", + "panel.settings_unlock_allowlist": "Unlock allowlist", + "panel.settings_unlock_allowlist_hint": "Only these entities may be unlocked by a reward. Nothing is allowed until you add something here.", + "panel.settings_unlock_allowlist_empty": "Nothing allowed yet.", + "panel.settings_unlock_allowlist_add": "Search for an entity to allow", + "panel.reward_unlock_section": "Advanced — timed unlock", + "panel.reward_unlock_intro": "Turn something on when this reward is approved, and back off again after a set time.", + "panel.reward_unlock_none": "Nothing", + "panel.reward_unlock_entity": "Unlock", + "panel.reward_unlock_minutes": "For how long (minutes)", + "panel.reward_unlock_minutes_hint": "0 = leave it on; you'll need to turn it off yourself.", + "panel.reward_unlock_no_allowlist": "Add an entity to the unlock allowlist in Settings first.", + "panel.tab_insights": "Insights", + "panel.insights_loading": "Working it out…", + "panel.insights_fairness_title": "Who's doing the work?", + "panel.insights_fairness_intro": "{start} to {end}. An even split would be {fair}% each.", + "panel.insights_last_days": "Last {days} days", + "panel.insights_no_data": "No approved chores in this period yet.", + "panel.insights_balanced": "Looks fairly balanced.", + "panel.insights_unbalanced": "The workload is uneven.", + "panel.insights_chores": "chores", + "panel.insights_status_over": "Doing more", + "panel.insights_status_under": "Doing less", + "panel.insights_status_balanced": "Balanced", + "panel.insights_status_idle": "Nothing yet", + "panel.insights_fairness_note": "Judged on chore count, not points, so a pricier chore doesn't hide an uneven split. Flagged when a child is more than {tolerance} points off an even share.", + "panel.insights_chore": "chore", + "panel.insights_view_fairness": "Fairness", + "panel.insights_view_friction": "Friction", + "panel.insights_friction_title": "What isn't working?", + "panel.insights_friction_intro": "{start} to {end}. Chores are judged on how often they actually get done versus how often they came up.", + "panel.insights_friction_none": "No active chores to look at.", + "panel.insights_friction_healthy": "Everything is getting done.", + "panel.insights_friction_problems": "{count} chore(s) worth a look.", + "panel.insights_col_chore": "Chore", + "panel.insights_col_done": "Done", + "panel.insights_col_rate": "Rate", + "panel.insights_col_last": "Last done", + "panel.insights_col_verdict": "Verdict", + "panel.insights_col_suggestion": "Try", + "panel.insights_never": "Never", + "panel.insights_days_ago": "{days}d ago", + "panel.insights_verdict_never": "Never done", + "panel.insights_verdict_stalling": "Stalling", + "panel.insights_verdict_struggling": "Patchy", + "panel.insights_verdict_fine": "Fine", + "panel.insights_verdict_unknown": "Unclear", + "panel.insights_suggest_retire": "Retire it", + "panel.insights_suggest_reprice": "Raise the points", + "panel.insights_suggest_reassign": "Give it to someone else", + "panel.insights_suggest_keep": "Leave it alone", + "panel.insights_outstanding_misses": "Outstanding mandatory misses", + "panel.insights_friction_note": "Rejections aren't listed: TaskMate deletes a completion when you reject it, so there's no rejection history to count. Expected counts are approximate — they don't replay rotation, dependencies or weather.", + "panel.insights_view_projection": "Week ahead", + "panel.insights_projection_title": "What's coming up?", + "panel.insights_projection_intro": "{start} to {end}, based on each chore's schedule.", + "panel.insights_next_days": "Next {days} days", + "panel.insights_col_day": "Day", + "panel.insights_projected_earn": "up to {points} {name}", + "panel.insights_projected_total": "would reach {total}", + "panel.insights_projection_unassigned": "{points} points aren't assigned to anyone.", + "panel.insights_projection_note": "A ceiling, not a forecast: chores open to everyone are counted for each eligible child, since the schedule can't know who'll get there first. Weather, dependencies and availability aren't projected — they depend on the day.", + "panel.insights_view_health": "Health", + "panel.health_title": "Is anything broken?", + "panel.health_recheck": "Check again", + "panel.health_all_good": "Nothing wrong found.", + "panel.health_issues": "{count} thing(s) need attention.", + "panel.health_sev_error": "Error", + "panel.health_sev_warning": "Warning", + "panel.health_sev_info": "Note", + "panel.health_goto": "Show me", + "panel.health_count_children": "Children", + "panel.health_count_chores": "Chores", + "panel.health_count_enabled_chores": "Active", + "panel.health_count_rewards": "Rewards", + "panel.health_count_completions": "Completions", + "panel.health_count_badges": "Badges", + "panel.health_count_scheduled_changes": "Scheduled", + "panel.health_count_active_unlocks": "Unlocks", + "panel.health_count_mandatory_misses": "Misses", + "panel.health_count_storage": "Storage", + "panel.chore_icon_label": "Picture", + "child.editor.pre_reader": "Picture-only mode (for young children)", + "child.editor.pre_reader_labels": "Show names in picture mode", + "common.design.accessible": "Accessible" } diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index d38acae..537705a 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -1538,5 +1538,179 @@ "panel.family_goal_reward": "Récompense", "panel.family_goal_reward_ph": "une soirée cinéma en famille", "notification.family_goal_reached.name": "Objectif familial atteint", - "notification.family_goal_reached.description": "Célèbre lorsque les points cumulés de la famille atteignent l'objectif commun. Désactivé par défaut." + "notification.family_goal_reached.description": "Célèbre lorsque les points cumulés de la famille atteignent l'objectif commun. Désactivé par défaut.", + "panel.chore_advanced_weather": "Avancé — conditions météo", + "panel.chore_weather_intro": "Masquer cette tâche tant que la météo est défavorable. Utile pour les travaux extérieurs — une tâche annulée par la pluie ne compte pas comme un manquement obligatoire et ne casse aucune série.", + "panel.chore_weather_entity_label": "Entité météo", + "panel.chore_weather_entity_hint": "Laisser vide pour ignorer complètement la météo.", + "panel.chore_weather_conditions_label": "Masquer quand la météo est", + "panel.chore_weather_conditions_hint": "Choisissez les conditions qui doivent masquer la tâche.", + "panel.chore_weather_temp_min_label": "Température minimale", + "panel.chore_weather_temp_min_hint": "Masquer en dessous. Laisser vide pour aucune limite.", + "panel.chore_weather_temp_max_label": "Température maximale", + "panel.chore_weather_temp_max_hint": "Masquer au-dessus. Laisser vide pour aucune limite.", + "panel.chore_weather_wind_max_label": "Vitesse de vent maximale", + "panel.chore_weather_wind_max_hint": "Masquer au-dessus. Laisser vide pour aucune limite.", + "panel.chore_weather_failopen_hint": "Les limites utilisent les unités de votre entité météo. Si l'entité est absente ou indisponible, la tâche reste visible.", + "weather.blocked_condition": "Masquée — météo défavorable", + "weather.blocked_temp_low": "Masquée — trop froid", + "weather.blocked_temp_high": "Masquée — trop chaud", + "weather.blocked_wind": "Masquée — trop venteux", + "weather.condition_rainy": "Pluvieux", + "weather.condition_pouring": "Averses", + "weather.condition_snowy": "Neigeux", + "weather.condition_snowy_rainy": "Neige fondue", + "weather.condition_hail": "Grêle", + "weather.condition_lightning": "Éclairs", + "weather.condition_lightning_rainy": "Orage", + "weather.condition_fog": "Brouillard", + "weather.condition_windy": "Venteux", + "weather.condition_windy_variant": "Très venteux", + "weather.condition_cloudy": "Nuageux", + "weather.condition_exceptional": "Conditions extrêmes", + "child.deadline_minutes": "{mins} min restantes", + "child.deadline_hours": "{hours} h {mins} min restantes", + "child.deadline_title": "À faire avant la fin du temps imparti", + "panel.chore_advanced_scheduled": "Avancé — modifications planifiées", + "panel.chore_scheduled_intro": "Planifiez une modification pour une date future — « à partir du 1er septembre, elle vaut 20 points ». Appliquée à minuit ; si Home Assistant était éteint, elle se rattrape au démarrage suivant.", + "panel.chore_scheduled_none": "Aucune modification planifiée.", + "panel.chore_scheduled_history": "Déjà appliquées", + "panel.chore_scheduled_applied": "Appliquée", + "panel.sched_date_label": "Appliquer le", + "panel.sched_field_label": "Modification", + "panel.sched_value_label": "Nouvelle valeur", + "panel.sched_note_label": "Note (facultatif)", + "panel.sched_note_hint": "Un rappel du pourquoi, affiché à côté de la modification planifiée.", + "panel.btn_add_scheduled": "Planifier", + "panel.sched_value_yes": "Oui", + "panel.sched_value_no": "Non", + "panel.sched_value_nobody": "Personne", + "panel.toast_sched_added": "Modification planifiée", + "panel.toast_sched_removed": "Modification supprimée", + "panel.toast_sched_date_required": "Choisissez une date pour la modification", + "panel.toast_sched_value_required": "Saisissez une valeur pour la modification", + "panel.sched_field_points": "Points", + "panel.sched_field_assigned_to": "Attribuée à", + "panel.sched_field_enabled": "Activée", + "panel.sched_field_requires_approval": "Validation requise", + "panel.sched_field_daily_limit": "Limite quotidienne", + "panel.sched_field_due_days": "Jours", + "panel.sched_field_mandatory": "Obligatoire", + "panel.sched_field_mandatory_penalty_points": "Points de pénalité", + "panel.sched_field_expires_on": "Expire le", + "panel.sched_field_description": "Description", + "panel.sched_field_time_category": "Moment de la journée", + "panel.sched_field_difficulty": "Difficulté", + "routine.title": "Routine de {name}", + "routine.no_child": "Choisissez un enfant pour cette carte.", + "routine.task_of": "Tâche {current} sur {total}", + "routine.all_done_count": "Les {total} sont faites", + "routine.done": "Terminé ✓", + "routine.completed": "Terminée", + "routine.sent_for_checking": "Envoyé pour vérification", + "routine.skip": "Plus tard", + "routine.next": "Suivant →", + "routine.back": "← Retour", + "routine.waiting_for_parent": "⏳ En attente de la vérification d'un adulte", + "routine.photo_note": "Celle-ci demande une photo — touchez-la sur votre carte.", + "routine.finished_title": "Routine terminée !", + "routine.finished_body": "Bravo, {name}. C'est tout.", + "routine.earned": "+{points} gagnés", + "routine.awaiting_points": "+{points} après vérification", + "routine.skipped_note": "{count} laissée(s) pour plus tard.", + "routine.start_again": "Recommencer", + "routine.empty_title": "Rien à faire", + "routine.empty_body": "Cette routine est déjà terminée.", + "child.roulette_spin": "Tourner pour une tâche ×{multiplier}", + "child.roulette_respin": "Tourner encore ({count} restant)", + "child.roulette_no_spins": "Plus de tours aujourd'hui", + "panel.settings_roulette_section": "Roulette des tâches", + "panel.settings_roulette_enabled": "Activer la roulette des tâches", + "panel.settings_roulette_enabled_hint": "Permet à un enfant de tirer une tâche au hasard valant plus de points.", + "panel.settings_roulette_multiplier": "Multiplicateur de points", + "panel.settings_roulette_spins": "Tours par jour", + "panel.settings_unlock_allowlist": "Liste d'autorisation de déverrouillage", + "panel.settings_unlock_allowlist_hint": "Seules ces entités peuvent être déverrouillées par une récompense. Rien n'est autorisé tant que vous n'ajoutez rien ici.", + "panel.settings_unlock_allowlist_empty": "Rien d'autorisé pour l'instant.", + "panel.settings_unlock_allowlist_add": "Rechercher une entité à autoriser", + "panel.reward_unlock_section": "Avancé — déverrouillage temporaire", + "panel.reward_unlock_intro": "Allume quelque chose quand cette récompense est validée, puis l'éteint après un temps donné.", + "panel.reward_unlock_none": "Rien", + "panel.reward_unlock_entity": "Déverrouiller", + "panel.reward_unlock_minutes": "Pendant combien de temps (minutes)", + "panel.reward_unlock_minutes_hint": "0 = laisser allumé ; vous devrez l'éteindre vous-même.", + "panel.reward_unlock_no_allowlist": "Ajoutez d'abord une entité à la liste d'autorisation dans les Réglages.", + "panel.tab_insights": "Analyses", + "panel.insights_loading": "Calcul en cours…", + "panel.insights_fairness_title": "Qui fait le travail ?", + "panel.insights_fairness_intro": "Du {start} au {end}. Une répartition égale serait de {fair}% chacun.", + "panel.insights_last_days": "{days} derniers jours", + "panel.insights_no_data": "Aucune tâche validée sur cette période.", + "panel.insights_balanced": "Cela semble équilibré.", + "panel.insights_unbalanced": "La charge est déséquilibrée.", + "panel.insights_chores": "tâches", + "panel.insights_status_over": "En fait plus", + "panel.insights_status_under": "En fait moins", + "panel.insights_status_balanced": "Équilibré", + "panel.insights_status_idle": "Rien encore", + "panel.insights_fairness_note": "Évalué sur le nombre de tâches, pas les points, pour qu'une tâche mieux payée ne masque pas un déséquilibre. Signalé au-delà de {tolerance} points d'écart avec une part égale.", + "panel.insights_chore": "tâche", + "panel.insights_view_fairness": "Équité", + "panel.insights_view_friction": "Frictions", + "panel.insights_friction_title": "Qu'est-ce qui ne marche pas ?", + "panel.insights_friction_intro": "Du {start} au {end}. Les tâches sont jugées sur la fréquence réelle d'exécution par rapport à leur fréquence d'apparition.", + "panel.insights_friction_none": "Aucune tâche active à examiner.", + "panel.insights_friction_healthy": "Tout se fait.", + "panel.insights_friction_problems": "{count} tâche(s) à regarder.", + "panel.insights_col_chore": "Tâche", + "panel.insights_col_done": "Faites", + "panel.insights_col_rate": "Taux", + "panel.insights_col_last": "Dernière fois", + "panel.insights_col_verdict": "Verdict", + "panel.insights_col_suggestion": "Essayez", + "panel.insights_never": "Jamais", + "panel.insights_days_ago": "il y a {days} j", + "panel.insights_verdict_never": "Jamais faite", + "panel.insights_verdict_stalling": "À la traîne", + "panel.insights_verdict_struggling": "Irrégulière", + "panel.insights_verdict_fine": "Correcte", + "panel.insights_verdict_unknown": "Incertain", + "panel.insights_suggest_retire": "La supprimer", + "panel.insights_suggest_reprice": "Augmenter les points", + "panel.insights_suggest_reassign": "La confier à un autre", + "panel.insights_suggest_keep": "Ne rien changer", + "panel.insights_outstanding_misses": "Manquements obligatoires en attente", + "panel.insights_friction_note": "Les refus ne sont pas listés : TaskMate supprime une validation quand vous la refusez, il n'y a donc pas d'historique. Les valeurs attendues sont approximatives — rotation, dépendances et météo ne sont pas rejouées.", + "panel.insights_view_projection": "Semaine à venir", + "panel.insights_projection_title": "Qu'est-ce qui arrive ?", + "panel.insights_projection_intro": "Du {start} au {end}, d'après le planning de chaque tâche.", + "panel.insights_next_days": "{days} prochains jours", + "panel.insights_col_day": "Jour", + "panel.insights_projected_earn": "jusqu'à {points} {name}", + "panel.insights_projected_total": "atteindrait {total}", + "panel.insights_projection_unassigned": "{points} points ne sont attribués à personne.", + "panel.insights_projection_note": "Un plafond, pas une prévision : les tâches ouvertes à tous sont comptées pour chaque enfant éligible, le planning ne pouvant savoir qui arrivera en premier. Météo, dépendances et disponibilité ne sont pas projetées — elles dépendent du jour.", + "panel.insights_view_health": "Santé", + "panel.health_title": "Quelque chose ne va pas ?", + "panel.health_recheck": "Revérifier", + "panel.health_all_good": "Rien d'anormal.", + "panel.health_issues": "{count} point(s) à regarder.", + "panel.health_sev_error": "Erreur", + "panel.health_sev_warning": "Avertissement", + "panel.health_sev_info": "Note", + "panel.health_goto": "Voir", + "panel.health_count_children": "Enfants", + "panel.health_count_chores": "Tâches", + "panel.health_count_enabled_chores": "Actives", + "panel.health_count_rewards": "Récompenses", + "panel.health_count_completions": "Validations", + "panel.health_count_badges": "Badges", + "panel.health_count_scheduled_changes": "Planifiées", + "panel.health_count_active_unlocks": "Déverrouillages", + "panel.health_count_mandatory_misses": "Manquements", + "panel.health_count_storage": "Stockage", + "panel.chore_icon_label": "Image", + "child.editor.pre_reader": "Mode images seules (jeunes enfants)", + "child.editor.pre_reader_labels": "Afficher les noms en mode images", + "common.design.accessible": "Accessible" } diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index e6418ca..7b54bc0 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -1538,5 +1538,179 @@ "panel.family_goal_reward": "Belønning", "panel.family_goal_reward_ph": "en familiefilmkveld", "notification.family_goal_reached.name": "Familiemål nådd", - "notification.family_goal_reached.description": "Feirer når familiens samlede poeng når det felles målet. Av som standard." + "notification.family_goal_reached.description": "Feirer når familiens samlede poeng når det felles målet. Av som standard.", + "panel.chore_advanced_weather": "Avansert — værforhold", + "panel.chore_weather_intro": "Skjul denne oppgaven mens været er uegnet. Nyttig for utendørsarbeid — en oppgave som regner bort teller ikke som en glemt obligatorisk oppgave og bryter ingen rekke.", + "panel.chore_weather_entity_label": "Vær-entitet", + "panel.chore_weather_entity_hint": "La stå tom for å ignorere været helt.", + "panel.chore_weather_conditions_label": "Skjul når været er", + "panel.chore_weather_conditions_hint": "Velg forholdene som skal skjule oppgaven.", + "panel.chore_weather_temp_min_label": "Minimumstemperatur", + "panel.chore_weather_temp_min_hint": "Skjul under dette. La stå tom for ingen grense.", + "panel.chore_weather_temp_max_label": "Maksimumstemperatur", + "panel.chore_weather_temp_max_hint": "Skjul over dette. La stå tom for ingen grense.", + "panel.chore_weather_wind_max_label": "Maksimal vindstyrke", + "panel.chore_weather_wind_max_hint": "Skjul over dette. La stå tom for ingen grense.", + "panel.chore_weather_failopen_hint": "Grensene bruker enhetene vær-entiteten din rapporterer. Hvis entiteten mangler eller er utilgjengelig, forblir oppgaven synlig.", + "weather.blocked_condition": "Skjult — uegnet vær", + "weather.blocked_temp_low": "Skjult — for kaldt", + "weather.blocked_temp_high": "Skjult — for varmt", + "weather.blocked_wind": "Skjult — for mye vind", + "weather.condition_rainy": "Regn", + "weather.condition_pouring": "Kraftig regn", + "weather.condition_snowy": "Snø", + "weather.condition_snowy_rainy": "Sludd", + "weather.condition_hail": "Hagl", + "weather.condition_lightning": "Lyn", + "weather.condition_lightning_rainy": "Tordenvær", + "weather.condition_fog": "Tåke", + "weather.condition_windy": "Vindfullt", + "weather.condition_windy_variant": "Svært vindfullt", + "weather.condition_cloudy": "Skyet", + "weather.condition_exceptional": "Ekstremvær", + "child.deadline_minutes": "{mins} min igjen", + "child.deadline_hours": "{hours}t {mins}m igjen", + "child.deadline_title": "Gjør dette før tiden renner ut", + "panel.chore_advanced_scheduled": "Avansert — planlagte endringer", + "panel.chore_scheduled_intro": "Planlegg en endring til en framtidig dato — «fra 1. september er den verdt 20 poeng». Utføres ved midnatt; var Home Assistant av, tas den igjen ved neste oppstart.", + "panel.chore_scheduled_none": "Ingen endringer planlagt.", + "panel.chore_scheduled_history": "Allerede utført", + "panel.chore_scheduled_applied": "Utført", + "panel.sched_date_label": "Utfør den", + "panel.sched_field_label": "Endring", + "panel.sched_value_label": "Ny verdi", + "panel.sched_note_label": "Notat (valgfritt)", + "panel.sched_note_hint": "En påminnelse om hvorfor, vist ved siden av den planlagte endringen.", + "panel.btn_add_scheduled": "Planlegg endring", + "panel.sched_value_yes": "Ja", + "panel.sched_value_no": "Nei", + "panel.sched_value_nobody": "Ingen", + "panel.toast_sched_added": "Endring planlagt", + "panel.toast_sched_removed": "Endring fjernet", + "panel.toast_sched_date_required": "Velg en dato for endringen", + "panel.toast_sched_value_required": "Skriv inn en verdi for endringen", + "panel.sched_field_points": "Poeng", + "panel.sched_field_assigned_to": "Tildelt", + "panel.sched_field_enabled": "Aktivert", + "panel.sched_field_requires_approval": "Krever godkjenning", + "panel.sched_field_daily_limit": "Daglig grense", + "panel.sched_field_due_days": "Dager", + "panel.sched_field_mandatory": "Obligatorisk", + "panel.sched_field_mandatory_penalty_points": "Strafferpoeng", + "panel.sched_field_expires_on": "Utløper", + "panel.sched_field_description": "Beskrivelse", + "panel.sched_field_time_category": "Tid på dagen", + "panel.sched_field_difficulty": "Vanskelighetsgrad", + "routine.title": "{name}s rutine", + "routine.no_child": "Velg et barn for dette kortet.", + "routine.task_of": "Oppgave {current} av {total}", + "routine.all_done_count": "Alle {total} ferdige", + "routine.done": "Ferdig ✓", + "routine.completed": "Fullført", + "routine.sent_for_checking": "Sendt til sjekk", + "routine.skip": "Senere", + "routine.next": "Neste →", + "routine.back": "← Tilbake", + "routine.waiting_for_parent": "⏳ Venter på at en voksen sjekker", + "routine.photo_note": "Denne trenger et bilde — trykk på den på kortet ditt.", + "routine.finished_title": "Rutinen er ferdig!", + "routine.finished_body": "Bra jobba, {name}. Det var alt.", + "routine.earned": "+{points} tjent", + "routine.awaiting_points": "+{points} når en voksen har sjekket", + "routine.skipped_note": "{count} igjen til senere.", + "routine.start_again": "Start på nytt", + "routine.empty_title": "Ingenting å gjøre", + "routine.empty_body": "Denne rutinen er allerede ferdig.", + "child.roulette_spin": "Spinn for en ×{multiplier}-oppgave", + "child.roulette_respin": "Spinn igjen ({count} igjen)", + "child.roulette_no_spins": "Ingen spinn igjen i dag", + "panel.settings_roulette_section": "Oppgaveroulette", + "panel.settings_roulette_enabled": "Slå på oppgaveroulette", + "panel.settings_roulette_enabled_hint": "Lar et barn spinne for en tilfeldig gjenstående oppgave verdt ekstra poeng.", + "panel.settings_roulette_multiplier": "Poengmultiplikator", + "panel.settings_roulette_spins": "Spinn per dag", + "panel.settings_unlock_allowlist": "Tillatelsesliste for opplåsing", + "panel.settings_unlock_allowlist_hint": "Bare disse entitetene kan låses opp av en belønning. Ingenting er tillatt før du legger til noe her.", + "panel.settings_unlock_allowlist_empty": "Ingenting tillatt ennå.", + "panel.settings_unlock_allowlist_add": "Søk etter en entitet å tillate", + "panel.reward_unlock_section": "Avansert — tidsbegrenset opplåsing", + "panel.reward_unlock_intro": "Slår på noe når denne belønningen godkjennes, og av igjen etter en angitt tid.", + "panel.reward_unlock_none": "Ingenting", + "panel.reward_unlock_entity": "Lås opp", + "panel.reward_unlock_minutes": "Hvor lenge (minutter)", + "panel.reward_unlock_minutes_hint": "0 = la den stå på; du må slå av selv.", + "panel.reward_unlock_no_allowlist": "Legg først til en entitet i tillatelseslisten under Innstillinger.", + "panel.tab_insights": "Innsikt", + "panel.insights_loading": "Regner ut…", + "panel.insights_fairness_title": "Hvem gjør jobben?", + "panel.insights_fairness_intro": "{start} til {end}. En jevn fordeling ville vært {fair}% hver.", + "panel.insights_last_days": "Siste {days} dager", + "panel.insights_no_data": "Ingen godkjente oppgaver i denne perioden ennå.", + "panel.insights_balanced": "Ser ganske jevnt fordelt ut.", + "panel.insights_unbalanced": "Arbeidet er ujevnt fordelt.", + "panel.insights_chores": "oppgaver", + "panel.insights_status_over": "Gjør mer", + "panel.insights_status_under": "Gjør mindre", + "panel.insights_status_balanced": "Jevnt", + "panel.insights_status_idle": "Ingenting ennå", + "panel.insights_fairness_note": "Vurdert på antall oppgaver, ikke poeng, så en dyrere oppgave ikke skjuler en skjev fordeling. Markert når et barn er mer enn {tolerance} poeng unna en lik andel.", + "panel.insights_chore": "oppgave", + "panel.insights_view_fairness": "Rettferdighet", + "panel.insights_view_friction": "Friksjon", + "panel.insights_friction_title": "Hva fungerer ikke?", + "panel.insights_friction_intro": "{start} til {end}. Oppgaver vurderes etter hvor ofte de faktisk blir gjort mot hvor ofte de dukket opp.", + "panel.insights_friction_none": "Ingen aktive oppgaver å se på.", + "panel.insights_friction_healthy": "Alt blir gjort.", + "panel.insights_friction_problems": "{count} oppgave(r) verdt et blikk.", + "panel.insights_col_chore": "Oppgave", + "panel.insights_col_done": "Gjort", + "panel.insights_col_rate": "Andel", + "panel.insights_col_last": "Sist gjort", + "panel.insights_col_verdict": "Dom", + "panel.insights_col_suggestion": "Prøv", + "panel.insights_never": "Aldri", + "panel.insights_days_ago": "for {days}d siden", + "panel.insights_verdict_never": "Aldri gjort", + "panel.insights_verdict_stalling": "Stopper opp", + "panel.insights_verdict_struggling": "Ujevn", + "panel.insights_verdict_fine": "Greit", + "panel.insights_verdict_unknown": "Uklart", + "panel.insights_suggest_retire": "Fjern den", + "panel.insights_suggest_reprice": "Øk poengene", + "panel.insights_suggest_reassign": "Gi den til noen andre", + "panel.insights_suggest_keep": "La den være", + "panel.insights_outstanding_misses": "Uavklarte glemte pliktoppgaver", + "panel.insights_friction_note": "Avvisninger er ikke listet: TaskMate sletter en fullføring når du avviser den, så det finnes ingen historikk. Forventede tall er omtrentlige — rotasjon, avhengigheter og vær spilles ikke av på nytt.", + "panel.insights_view_projection": "Uka som kommer", + "panel.insights_projection_title": "Hva står for tur?", + "panel.insights_projection_intro": "{start} til {end}, basert på timeplanen til hver oppgave.", + "panel.insights_next_days": "Neste {days} dager", + "panel.insights_col_day": "Dag", + "panel.insights_projected_earn": "opptil {points} {name}", + "panel.insights_projected_total": "ville nå {total}", + "panel.insights_projection_unassigned": "{points} poeng er ikke tildelt noen.", + "panel.insights_projection_note": "Et tak, ikke en prognose: oppgaver åpne for alle telles for hvert aktuelt barn, siden timeplanen ikke kan vite hvem som rekker først. Vær, avhengigheter og tilgjengelighet framskrives ikke — de avhenger av dagen.", + "panel.insights_view_health": "Tilstand", + "panel.health_title": "Er noe ødelagt?", + "panel.health_recheck": "Sjekk igjen", + "panel.health_all_good": "Fant ingenting galt.", + "panel.health_issues": "{count} ting trenger oppmerksomhet.", + "panel.health_sev_error": "Feil", + "panel.health_sev_warning": "Advarsel", + "panel.health_sev_info": "Merk", + "panel.health_goto": "Vis meg", + "panel.health_count_children": "Barn", + "panel.health_count_chores": "Oppgaver", + "panel.health_count_enabled_chores": "Aktive", + "panel.health_count_rewards": "Belønninger", + "panel.health_count_completions": "Fullføringer", + "panel.health_count_badges": "Merker", + "panel.health_count_scheduled_changes": "Planlagte", + "panel.health_count_active_unlocks": "Opplåsinger", + "panel.health_count_mandatory_misses": "Glemt", + "panel.health_count_storage": "Lagring", + "panel.chore_icon_label": "Bilde", + "child.editor.pre_reader": "Kun bilder (for små barn)", + "child.editor.pre_reader_labels": "Vis navn i bildemodus", + "common.design.accessible": "Tilgjengelig" } diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index 149ac7c..0dbe01f 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -1538,5 +1538,179 @@ "panel.family_goal_reward": "Beløning", "panel.family_goal_reward_ph": "ein familiefilmkveld", "notification.family_goal_reached.name": "Familiemål nådd", - "notification.family_goal_reached.description": "Feirar når dei samla poenga til familien når det felles målet. Av som standard." + "notification.family_goal_reached.description": "Feirar når dei samla poenga til familien når det felles målet. Av som standard.", + "panel.chore_advanced_weather": "Avansert — vertilhøve", + "panel.chore_weather_intro": "Skjul denne oppgåva medan veret er uegna. Nyttig for utandørsarbeid — ei oppgåve som regnar bort tel ikkje som ei gløymd obligatorisk oppgåve og bryt ingen rekkje.", + "panel.chore_weather_entity_label": "Ver-entitet", + "panel.chore_weather_entity_hint": "La stå tom for å ignorere veret heilt.", + "panel.chore_weather_conditions_label": "Skjul når veret er", + "panel.chore_weather_conditions_hint": "Vel tilhøva som skal skjule oppgåva.", + "panel.chore_weather_temp_min_label": "Minimumstemperatur", + "panel.chore_weather_temp_min_hint": "Skjul under dette. La stå tom for inga grense.", + "panel.chore_weather_temp_max_label": "Maksimumstemperatur", + "panel.chore_weather_temp_max_hint": "Skjul over dette. La stå tom for inga grense.", + "panel.chore_weather_wind_max_label": "Maksimal vindstyrke", + "panel.chore_weather_wind_max_hint": "Skjul over dette. La stå tom for inga grense.", + "panel.chore_weather_failopen_hint": "Grensene bruker einingane ver-entiteten din rapporterer. Om entiteten manglar eller er utilgjengeleg, held oppgåva fram med å vere synleg.", + "weather.blocked_condition": "Skjult — uegna ver", + "weather.blocked_temp_low": "Skjult — for kaldt", + "weather.blocked_temp_high": "Skjult — for varmt", + "weather.blocked_wind": "Skjult — for mykje vind", + "weather.condition_rainy": "Regn", + "weather.condition_pouring": "Kraftig regn", + "weather.condition_snowy": "Snø", + "weather.condition_snowy_rainy": "Sludd", + "weather.condition_hail": "Hagl", + "weather.condition_lightning": "Lyn", + "weather.condition_lightning_rainy": "Tordenver", + "weather.condition_fog": "Skodde", + "weather.condition_windy": "Vindfullt", + "weather.condition_windy_variant": "Svært vindfullt", + "weather.condition_cloudy": "Skya", + "weather.condition_exceptional": "Ekstremver", + "child.deadline_minutes": "{mins} min att", + "child.deadline_hours": "{hours}t {mins}m att", + "child.deadline_title": "Gjer dette før tida renn ut", + "panel.chore_advanced_scheduled": "Avansert — planlagde endringar", + "panel.chore_scheduled_intro": "Planlegg ei endring til ein framtidig dato — «frå 1. september er ho verdt 20 poeng». Vert utført ved midnatt; var Home Assistant av, vert ho teken att ved neste oppstart.", + "panel.chore_scheduled_none": "Ingen endringar planlagde.", + "panel.chore_scheduled_history": "Alt utført", + "panel.chore_scheduled_applied": "Utført", + "panel.sched_date_label": "Utfør den", + "panel.sched_field_label": "Endring", + "panel.sched_value_label": "Ny verdi", + "panel.sched_note_label": "Notat (valfritt)", + "panel.sched_note_hint": "Ei påminning om kvifor, vist ved sida av den planlagde endringa.", + "panel.btn_add_scheduled": "Planlegg endring", + "panel.sched_value_yes": "Ja", + "panel.sched_value_no": "Nei", + "panel.sched_value_nobody": "Ingen", + "panel.toast_sched_added": "Endring planlagd", + "panel.toast_sched_removed": "Endring fjerna", + "panel.toast_sched_date_required": "Vel ein dato for endringa", + "panel.toast_sched_value_required": "Skriv inn ein verdi for endringa", + "panel.sched_field_points": "Poeng", + "panel.sched_field_assigned_to": "Tildelt", + "panel.sched_field_enabled": "Aktivert", + "panel.sched_field_requires_approval": "Krev godkjenning", + "panel.sched_field_daily_limit": "Dagleg grense", + "panel.sched_field_due_days": "Dagar", + "panel.sched_field_mandatory": "Obligatorisk", + "panel.sched_field_mandatory_penalty_points": "Straffepoeng", + "panel.sched_field_expires_on": "Går ut", + "panel.sched_field_description": "Skildring", + "panel.sched_field_time_category": "Tid på dagen", + "panel.sched_field_difficulty": "Vanskegrad", + "routine.title": "{name} si rutine", + "routine.no_child": "Vel eit barn for dette kortet.", + "routine.task_of": "Oppgåve {current} av {total}", + "routine.all_done_count": "Alle {total} ferdige", + "routine.done": "Ferdig ✓", + "routine.completed": "Fullført", + "routine.sent_for_checking": "Sendt til sjekk", + "routine.skip": "Seinare", + "routine.next": "Neste →", + "routine.back": "← Tilbake", + "routine.waiting_for_parent": "⏳ Ventar på at ein vaksen sjekkar", + "routine.photo_note": "Denne treng eit bilete — trykk på han på kortet ditt.", + "routine.finished_title": "Rutinen er ferdig!", + "routine.finished_body": "Bra jobba, {name}. Det var alt.", + "routine.earned": "+{points} tent", + "routine.awaiting_points": "+{points} når ein vaksen har sjekka", + "routine.skipped_note": "{count} att til seinare.", + "routine.start_again": "Start på nytt", + "routine.empty_title": "Ingenting å gjere", + "routine.empty_body": "Denne rutinen er alt ferdig.", + "child.roulette_spin": "Spinn for ei ×{multiplier}-oppgåve", + "child.roulette_respin": "Spinn igjen ({count} att)", + "child.roulette_no_spins": "Ingen spinn att i dag", + "panel.settings_roulette_section": "Oppgåveroulette", + "panel.settings_roulette_enabled": "Slå på oppgåveroulette", + "panel.settings_roulette_enabled_hint": "Lèt eit barn spinne for ei tilfeldig gjenståande oppgåve verdt ekstra poeng.", + "panel.settings_roulette_multiplier": "Poengmultiplikator", + "panel.settings_roulette_spins": "Spinn per dag", + "panel.settings_unlock_allowlist": "Løyveliste for opplåsing", + "panel.settings_unlock_allowlist_hint": "Berre desse entitetane kan låsast opp av ei belønning. Ingenting er tillate før du legg til noko her.", + "panel.settings_unlock_allowlist_empty": "Ingenting tillate enno.", + "panel.settings_unlock_allowlist_add": "Søk etter ein entitet å tillate", + "panel.reward_unlock_section": "Avansert — tidsavgrensa opplåsing", + "panel.reward_unlock_intro": "Slår på noko når denne belønninga vert godkjend, og av igjen etter ei fastsett tid.", + "panel.reward_unlock_none": "Ingenting", + "panel.reward_unlock_entity": "Lås opp", + "panel.reward_unlock_minutes": "Kor lenge (minutt)", + "panel.reward_unlock_minutes_hint": "0 = la han stå på; du må slå av sjølv.", + "panel.reward_unlock_no_allowlist": "Legg først til ein entitet i løyvelista under Innstillingar.", + "panel.tab_insights": "Innsikt", + "panel.insights_loading": "Reknar ut…", + "panel.insights_fairness_title": "Kven gjer jobben?", + "panel.insights_fairness_intro": "{start} til {end}. Ei jamn fordeling ville vore {fair}% kvar.", + "panel.insights_last_days": "Siste {days} dagar", + "panel.insights_no_data": "Ingen godkjende oppgåver i denne perioden enno.", + "panel.insights_balanced": "Ser ganske jamt fordelt ut.", + "panel.insights_unbalanced": "Arbeidet er ujamt fordelt.", + "panel.insights_chores": "oppgåver", + "panel.insights_status_over": "Gjer meir", + "panel.insights_status_under": "Gjer mindre", + "panel.insights_status_balanced": "Jamt", + "panel.insights_status_idle": "Ingenting enno", + "panel.insights_fairness_note": "Vurdert på tal oppgåver, ikkje poeng, så ei dyrare oppgåve ikkje skjuler ei skeiv fordeling. Markert når eit barn er meir enn {tolerance} poeng unna ein lik del.", + "panel.insights_chore": "oppgåve", + "panel.insights_view_fairness": "Rettferd", + "panel.insights_view_friction": "Friksjon", + "panel.insights_friction_title": "Kva fungerer ikkje?", + "panel.insights_friction_intro": "{start} til {end}. Oppgåver vert vurderte etter kor ofte dei faktisk vert gjorde mot kor ofte dei dukka opp.", + "panel.insights_friction_none": "Ingen aktive oppgåver å sjå på.", + "panel.insights_friction_healthy": "Alt vert gjort.", + "panel.insights_friction_problems": "{count} oppgåve(r) verdt eit blikk.", + "panel.insights_col_chore": "Oppgåve", + "panel.insights_col_done": "Gjort", + "panel.insights_col_rate": "Del", + "panel.insights_col_last": "Sist gjort", + "panel.insights_col_verdict": "Dom", + "panel.insights_col_suggestion": "Prøv", + "panel.insights_never": "Aldri", + "panel.insights_days_ago": "for {days}d sidan", + "panel.insights_verdict_never": "Aldri gjort", + "panel.insights_verdict_stalling": "Stoppar opp", + "panel.insights_verdict_struggling": "Ujamn", + "panel.insights_verdict_fine": "Greitt", + "panel.insights_verdict_unknown": "Uklart", + "panel.insights_suggest_retire": "Fjern henne", + "panel.insights_suggest_reprice": "Auk poenga", + "panel.insights_suggest_reassign": "Gje henne til nokon andre", + "panel.insights_suggest_keep": "La henne vere", + "panel.insights_outstanding_misses": "Uavklarte gløymde pliktoppgåver", + "panel.insights_friction_note": "Avvisingar er ikkje lista: TaskMate slettar ei fullføring når du avviser henne, så det finst ingen historikk. Venta tal er omtrentlege — rotasjon, avhengnader og ver vert ikkje spelte av på nytt.", + "panel.insights_view_projection": "Veka som kjem", + "panel.insights_projection_title": "Kva står for tur?", + "panel.insights_projection_intro": "{start} til {end}, basert på timeplanen til kvar oppgåve.", + "panel.insights_next_days": "Neste {days} dagar", + "panel.insights_col_day": "Dag", + "panel.insights_projected_earn": "opptil {points} {name}", + "panel.insights_projected_total": "ville nå {total}", + "panel.insights_projection_unassigned": "{points} poeng er ikkje tildelte nokon.", + "panel.insights_projection_note": "Eit tak, ikkje ein prognose: oppgåver opne for alle vert talde for kvart aktuelt barn, sidan timeplanen ikkje kan vite kven som rekk først. Ver, avhengnader og tilgjenge vert ikkje framskrivne — dei kjem an på dagen.", + "panel.insights_view_health": "Tilstand", + "panel.health_title": "Er noko øydelagt?", + "panel.health_recheck": "Sjekk igjen", + "panel.health_all_good": "Fann ingenting gale.", + "panel.health_issues": "{count} ting treng merksemd.", + "panel.health_sev_error": "Feil", + "panel.health_sev_warning": "Åtvaring", + "panel.health_sev_info": "Merk", + "panel.health_goto": "Vis meg", + "panel.health_count_children": "Born", + "panel.health_count_chores": "Oppgåver", + "panel.health_count_enabled_chores": "Aktive", + "panel.health_count_rewards": "Belønningar", + "panel.health_count_completions": "Fullføringar", + "panel.health_count_badges": "Merke", + "panel.health_count_scheduled_changes": "Planlagde", + "panel.health_count_active_unlocks": "Opplåsingar", + "panel.health_count_mandatory_misses": "Gløymt", + "panel.health_count_storage": "Lagring", + "panel.chore_icon_label": "Bilete", + "child.editor.pre_reader": "Berre bilete (for små born)", + "child.editor.pre_reader_labels": "Vis namn i biletemodus", + "common.design.accessible": "Tilgjengeleg" } diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index daed21b..fdb660d 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -1538,5 +1538,179 @@ "panel.family_goal_reward": "Recompensa", "panel.family_goal_reward_ph": "uma noite de cinema em família", "notification.family_goal_reached.name": "Meta da família alcançada", - "notification.family_goal_reached.description": "Comemora quando os pontos combinados da família atingem a meta compartilhada. Desativado por padrão." + "notification.family_goal_reached.description": "Comemora quando os pontos combinados da família atingem a meta compartilhada. Desativado por padrão.", + "panel.chore_advanced_weather": "Avançado — condições do tempo", + "panel.chore_weather_intro": "Ocultar esta tarefa enquanto o tempo não estiver adequado. Útil para trabalhos ao ar livre — uma tarefa cancelada pela chuva não conta como falha obrigatória nem quebra uma sequência.", + "panel.chore_weather_entity_label": "Entidade de clima", + "panel.chore_weather_entity_hint": "Deixe vazio para ignorar o tempo completamente.", + "panel.chore_weather_conditions_label": "Ocultar quando o tempo estiver", + "panel.chore_weather_conditions_hint": "Escolha as condições que devem ocultar a tarefa.", + "panel.chore_weather_temp_min_label": "Temperatura mínima", + "panel.chore_weather_temp_min_hint": "Ocultar abaixo disso. Deixe vazio para não ter limite.", + "panel.chore_weather_temp_max_label": "Temperatura máxima", + "panel.chore_weather_temp_max_hint": "Ocultar acima disso. Deixe vazio para não ter limite.", + "panel.chore_weather_wind_max_label": "Velocidade máxima do vento", + "panel.chore_weather_wind_max_hint": "Ocultar acima disso. Deixe vazio para não ter limite.", + "panel.chore_weather_failopen_hint": "Os limites usam as unidades informadas pela sua entidade de clima. Se a entidade estiver ausente ou indisponível, a tarefa continua visível.", + "weather.blocked_condition": "Oculta — tempo inadequado", + "weather.blocked_temp_low": "Oculta — frio demais", + "weather.blocked_temp_high": "Oculta — calor demais", + "weather.blocked_wind": "Oculta — vento demais", + "weather.condition_rainy": "Chuvoso", + "weather.condition_pouring": "Chuva forte", + "weather.condition_snowy": "Neve", + "weather.condition_snowy_rainy": "Chuva com neve", + "weather.condition_hail": "Granizo", + "weather.condition_lightning": "Relâmpagos", + "weather.condition_lightning_rainy": "Tempestade", + "weather.condition_fog": "Neblina", + "weather.condition_windy": "Ventando", + "weather.condition_windy_variant": "Muito vento", + "weather.condition_cloudy": "Nublado", + "weather.condition_exceptional": "Tempo severo", + "child.deadline_minutes": "faltam {mins} min", + "child.deadline_hours": "faltam {hours}h {mins}m", + "child.deadline_title": "Faça isso antes de o tempo acabar", + "panel.chore_advanced_scheduled": "Avançado — alterações agendadas", + "panel.chore_scheduled_intro": "Agende uma alteração para uma data futura — \"a partir de 1º de setembro vale 20 pontos\". Aplicada à meia-noite; se o Home Assistant estiver desligado, é recuperada na próxima inicialização.", + "panel.chore_scheduled_none": "Nenhuma alteração agendada.", + "panel.chore_scheduled_history": "Já aplicadas", + "panel.chore_scheduled_applied": "Aplicada", + "panel.sched_date_label": "Aplicar em", + "panel.sched_field_label": "Alteração", + "panel.sched_value_label": "Novo valor", + "panel.sched_note_label": "Observação (opcional)", + "panel.sched_note_hint": "Um lembrete do motivo, mostrado ao lado da alteração agendada.", + "panel.btn_add_scheduled": "Agendar alteração", + "panel.sched_value_yes": "Sim", + "panel.sched_value_no": "Não", + "panel.sched_value_nobody": "Ninguém", + "panel.toast_sched_added": "Alteração agendada", + "panel.toast_sched_removed": "Alteração removida", + "panel.toast_sched_date_required": "Escolha uma data para a alteração", + "panel.toast_sched_value_required": "Digite um valor para a alteração", + "panel.sched_field_points": "Pontos", + "panel.sched_field_assigned_to": "Atribuída a", + "panel.sched_field_enabled": "Ativada", + "panel.sched_field_requires_approval": "Requer aprovação", + "panel.sched_field_daily_limit": "Limite diário", + "panel.sched_field_due_days": "Dias", + "panel.sched_field_mandatory": "Obrigatória", + "panel.sched_field_mandatory_penalty_points": "Pontos de penalidade", + "panel.sched_field_expires_on": "Expira em", + "panel.sched_field_description": "Descrição", + "panel.sched_field_time_category": "Período do dia", + "panel.sched_field_difficulty": "Dificuldade", + "routine.title": "Rotina de {name}", + "routine.no_child": "Escolha uma criança para este card.", + "routine.task_of": "Tarefa {current} de {total}", + "routine.all_done_count": "Todas as {total} feitas", + "routine.done": "Feito ✓", + "routine.completed": "Concluída", + "routine.sent_for_checking": "Enviado para verificação", + "routine.skip": "Mais tarde", + "routine.next": "Próxima →", + "routine.back": "← Voltar", + "routine.waiting_for_parent": "⏳ Aguardando a verificação de um adulto", + "routine.photo_note": "Esta precisa de uma foto — toque nela no seu card.", + "routine.finished_title": "Rotina concluída!", + "routine.finished_body": "Boa, {name}. É tudo.", + "routine.earned": "+{points} ganhos", + "routine.awaiting_points": "+{points} assim que um adulto verificar", + "routine.skipped_note": "{count} deixada(s) para depois.", + "routine.start_again": "Recomeçar", + "routine.empty_title": "Nada a fazer", + "routine.empty_body": "Esta rotina já está concluída.", + "child.roulette_spin": "Girar para uma tarefa ×{multiplier}", + "child.roulette_respin": "Girar de novo ({count} restante)", + "child.roulette_no_spins": "Sem giros hoje", + "panel.settings_roulette_section": "Roleta de tarefas", + "panel.settings_roulette_enabled": "Ativar roleta de tarefas", + "panel.settings_roulette_enabled_hint": "Permite que uma criança gire por uma tarefa pendente aleatória que vale pontos extras.", + "panel.settings_roulette_multiplier": "Multiplicador de pontos", + "panel.settings_roulette_spins": "Giros por dia", + "panel.settings_unlock_allowlist": "Lista de permissões de desbloqueio", + "panel.settings_unlock_allowlist_hint": "Só estas entidades podem ser desbloqueadas por uma recompensa. Nada é permitido até você adicionar algo aqui.", + "panel.settings_unlock_allowlist_empty": "Nada permitido ainda.", + "panel.settings_unlock_allowlist_add": "Procurar uma entidade para permitir", + "panel.reward_unlock_section": "Avançado — desbloqueio temporário", + "panel.reward_unlock_intro": "Liga algo quando esta recompensa for aprovada e desliga de novo após um tempo definido.", + "panel.reward_unlock_none": "Nada", + "panel.reward_unlock_entity": "Desbloquear", + "panel.reward_unlock_minutes": "Por quanto tempo (minutos)", + "panel.reward_unlock_minutes_hint": "0 = deixar ligado; você terá de desligar.", + "panel.reward_unlock_no_allowlist": "Adicione primeiro uma entidade à lista de permissões nas Configurações.", + "panel.tab_insights": "Análises", + "panel.insights_loading": "Calculando…", + "panel.insights_fairness_title": "Quem está fazendo o trabalho?", + "panel.insights_fairness_intro": "{start} a {end}. Uma divisão igual seria {fair}% para cada.", + "panel.insights_last_days": "Últimos {days} dias", + "panel.insights_no_data": "Ainda sem tarefas aprovadas neste período.", + "panel.insights_balanced": "Parece bem equilibrado.", + "panel.insights_unbalanced": "A carga está desequilibrada.", + "panel.insights_chores": "tarefas", + "panel.insights_status_over": "Faz mais", + "panel.insights_status_under": "Faz menos", + "panel.insights_status_balanced": "Equilibrado", + "panel.insights_status_idle": "Ainda nada", + "panel.insights_fairness_note": "Avaliado pelo número de tarefas, não por pontos, para que uma tarefa mais cara não esconda uma divisão desigual. Sinalizado quando uma criança está a mais de {tolerance} pontos de uma parte igual.", + "panel.insights_chore": "tarefa", + "panel.insights_view_fairness": "Equidade", + "panel.insights_view_friction": "Atrito", + "panel.insights_friction_title": "O que não está funcionando?", + "panel.insights_friction_intro": "{start} a {end}. As tarefas são avaliadas pela frequência com que são feitas em relação à frequência com que surgiram.", + "panel.insights_friction_none": "Sem tarefas ativas para analisar.", + "panel.insights_friction_healthy": "Está tudo sendo feito.", + "panel.insights_friction_problems": "{count} tarefa(s) merecendo atenção.", + "panel.insights_col_chore": "Tarefa", + "panel.insights_col_done": "Feitas", + "panel.insights_col_rate": "Taxa", + "panel.insights_col_last": "Última vez", + "panel.insights_col_verdict": "Veredito", + "panel.insights_col_suggestion": "Tente", + "panel.insights_never": "Nunca", + "panel.insights_days_ago": "há {days}d", + "panel.insights_verdict_never": "Nunca feita", + "panel.insights_verdict_stalling": "Parada", + "panel.insights_verdict_struggling": "Irregular", + "panel.insights_verdict_fine": "Bem", + "panel.insights_verdict_unknown": "Incerto", + "panel.insights_suggest_retire": "Remover", + "panel.insights_suggest_reprice": "Aumentar os pontos", + "panel.insights_suggest_reassign": "Dar para outra pessoa", + "panel.insights_suggest_keep": "Deixar como está", + "panel.insights_outstanding_misses": "Falhas obrigatórias pendentes", + "panel.insights_friction_note": "As rejeições não são listadas: o TaskMate apaga a conclusão quando você a rejeita, então não há histórico. Os valores esperados são aproximados — rotação, dependências e clima não são reproduzidos.", + "panel.insights_view_projection": "Semana seguinte", + "panel.insights_projection_title": "O que vem por aí?", + "panel.insights_projection_intro": "{start} a {end}, com base na programação de cada tarefa.", + "panel.insights_next_days": "Próximos {days} dias", + "panel.insights_col_day": "Dia", + "panel.insights_projected_earn": "até {points} {name}", + "panel.insights_projected_total": "chegaria a {total}", + "panel.insights_projection_unassigned": "{points} pontos não estão atribuídos a ninguém.", + "panel.insights_projection_note": "Um teto, não uma previsão: tarefas abertas a todos são contadas para cada criança elegível, já que a programação não sabe quem chega primeiro. Clima, dependências e disponibilidade não são projetados — dependem do dia.", + "panel.insights_view_health": "Saúde", + "panel.health_title": "Tem algo quebrado?", + "panel.health_recheck": "Verificar de novo", + "panel.health_all_good": "Nada de errado.", + "panel.health_issues": "{count} coisa(s) precisando de atenção.", + "panel.health_sev_error": "Erro", + "panel.health_sev_warning": "Aviso", + "panel.health_sev_info": "Nota", + "panel.health_goto": "Mostrar", + "panel.health_count_children": "Crianças", + "panel.health_count_chores": "Tarefas", + "panel.health_count_enabled_chores": "Ativas", + "panel.health_count_rewards": "Recompensas", + "panel.health_count_completions": "Conclusões", + "panel.health_count_badges": "Emblemas", + "panel.health_count_scheduled_changes": "Agendadas", + "panel.health_count_active_unlocks": "Desbloqueios", + "panel.health_count_mandatory_misses": "Falhas", + "panel.health_count_storage": "Armazenamento", + "panel.chore_icon_label": "Imagem", + "child.editor.pre_reader": "Modo só imagens (crianças pequenas)", + "child.editor.pre_reader_labels": "Mostrar nomes no modo imagens", + "common.design.accessible": "Acessível" } diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index e9ec4b2..a548014 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -1538,5 +1538,179 @@ "panel.family_goal_reward": "Recompensa", "panel.family_goal_reward_ph": "uma noite de cinema em família", "notification.family_goal_reached.name": "Objetivo da família alcançado", - "notification.family_goal_reached.description": "Comemora quando os pontos combinados da família atingem o objetivo partilhado. Desativado por predefinição." + "notification.family_goal_reached.description": "Comemora quando os pontos combinados da família atingem o objetivo partilhado. Desativado por predefinição.", + "panel.chore_advanced_weather": "Avançado — condições meteorológicas", + "panel.chore_weather_intro": "Ocultar esta tarefa enquanto o tempo não for adequado. Útil para trabalhos no exterior — uma tarefa cancelada pela chuva não conta como falha obrigatória nem quebra uma sequência.", + "panel.chore_weather_entity_label": "Entidade meteorológica", + "panel.chore_weather_entity_hint": "Deixe vazio para ignorar completamente o tempo.", + "panel.chore_weather_conditions_label": "Ocultar quando o tempo estiver", + "panel.chore_weather_conditions_hint": "Escolha as condições que devem ocultar a tarefa.", + "panel.chore_weather_temp_min_label": "Temperatura mínima", + "panel.chore_weather_temp_min_hint": "Ocultar abaixo disto. Deixe vazio para não ter limite.", + "panel.chore_weather_temp_max_label": "Temperatura máxima", + "panel.chore_weather_temp_max_hint": "Ocultar acima disto. Deixe vazio para não ter limite.", + "panel.chore_weather_wind_max_label": "Velocidade máxima do vento", + "panel.chore_weather_wind_max_hint": "Ocultar acima disto. Deixe vazio para não ter limite.", + "panel.chore_weather_failopen_hint": "Os limites usam as unidades comunicadas pela sua entidade meteorológica. Se a entidade estiver em falta ou indisponível, a tarefa permanece visível.", + "weather.blocked_condition": "Oculta — tempo inadequado", + "weather.blocked_temp_low": "Oculta — demasiado frio", + "weather.blocked_temp_high": "Oculta — demasiado calor", + "weather.blocked_wind": "Oculta — demasiado vento", + "weather.condition_rainy": "Chuvoso", + "weather.condition_pouring": "Chuva forte", + "weather.condition_snowy": "Neve", + "weather.condition_snowy_rainy": "Chuva com neve", + "weather.condition_hail": "Granizo", + "weather.condition_lightning": "Relâmpagos", + "weather.condition_lightning_rainy": "Trovoada", + "weather.condition_fog": "Nevoeiro", + "weather.condition_windy": "Ventoso", + "weather.condition_windy_variant": "Muito ventoso", + "weather.condition_cloudy": "Nublado", + "weather.condition_exceptional": "Tempo severo", + "child.deadline_minutes": "faltam {mins} min", + "child.deadline_hours": "faltam {hours}h {mins}m", + "child.deadline_title": "Faz isto antes de o tempo acabar", + "panel.chore_advanced_scheduled": "Avançado — alterações agendadas", + "panel.chore_scheduled_intro": "Agende uma alteração para uma data futura — «a partir de 1 de setembro vale 20 pontos». Aplicada à meia-noite; se o Home Assistant estiver desligado, é recuperada no arranque seguinte.", + "panel.chore_scheduled_none": "Sem alterações agendadas.", + "panel.chore_scheduled_history": "Já aplicadas", + "panel.chore_scheduled_applied": "Aplicada", + "panel.sched_date_label": "Aplicar em", + "panel.sched_field_label": "Alteração", + "panel.sched_value_label": "Novo valor", + "panel.sched_note_label": "Nota (opcional)", + "panel.sched_note_hint": "Um lembrete do porquê, mostrado junto à alteração agendada.", + "panel.btn_add_scheduled": "Agendar alteração", + "panel.sched_value_yes": "Sim", + "panel.sched_value_no": "Não", + "panel.sched_value_nobody": "Ninguém", + "panel.toast_sched_added": "Alteração agendada", + "panel.toast_sched_removed": "Alteração removida", + "panel.toast_sched_date_required": "Escolha uma data para a alteração", + "panel.toast_sched_value_required": "Introduza um valor para a alteração", + "panel.sched_field_points": "Pontos", + "panel.sched_field_assigned_to": "Atribuída a", + "panel.sched_field_enabled": "Ativada", + "panel.sched_field_requires_approval": "Requer aprovação", + "panel.sched_field_daily_limit": "Limite diário", + "panel.sched_field_due_days": "Dias", + "panel.sched_field_mandatory": "Obrigatória", + "panel.sched_field_mandatory_penalty_points": "Pontos de penalização", + "panel.sched_field_expires_on": "Expira em", + "panel.sched_field_description": "Descrição", + "panel.sched_field_time_category": "Altura do dia", + "panel.sched_field_difficulty": "Dificuldade", + "routine.title": "Rotina de {name}", + "routine.no_child": "Escolhe uma criança para este cartão.", + "routine.task_of": "Tarefa {current} de {total}", + "routine.all_done_count": "Todas as {total} feitas", + "routine.done": "Feito ✓", + "routine.completed": "Concluída", + "routine.sent_for_checking": "Enviado para verificação", + "routine.skip": "Mais tarde", + "routine.next": "Seguinte →", + "routine.back": "← Voltar", + "routine.waiting_for_parent": "⏳ À espera da verificação de um adulto", + "routine.photo_note": "Esta precisa de uma foto — toca nela no teu cartão.", + "routine.finished_title": "Rotina concluída!", + "routine.finished_body": "Boa, {name}. É tudo.", + "routine.earned": "+{points} ganhos", + "routine.awaiting_points": "+{points} assim que um adulto verificar", + "routine.skipped_note": "{count} deixada(s) para depois.", + "routine.start_again": "Recomeçar", + "routine.empty_title": "Nada a fazer", + "routine.empty_body": "Esta rotina já está concluída.", + "child.roulette_spin": "Girar para uma tarefa ×{multiplier}", + "child.roulette_respin": "Girar de novo ({count} restante)", + "child.roulette_no_spins": "Sem giros hoje", + "panel.settings_roulette_section": "Roleta de tarefas", + "panel.settings_roulette_enabled": "Ativar roleta de tarefas", + "panel.settings_roulette_enabled_hint": "Permite a uma criança girar por uma tarefa pendente aleatória que vale pontos extra.", + "panel.settings_roulette_multiplier": "Multiplicador de pontos", + "panel.settings_roulette_spins": "Giros por dia", + "panel.settings_unlock_allowlist": "Lista de permissões de desbloqueio", + "panel.settings_unlock_allowlist_hint": "Só estas entidades podem ser desbloqueadas por uma recompensa. Nada é permitido até adicionares algo aqui.", + "panel.settings_unlock_allowlist_empty": "Nada permitido ainda.", + "panel.settings_unlock_allowlist_add": "Procurar uma entidade para permitir", + "panel.reward_unlock_section": "Avançado — desbloqueio temporário", + "panel.reward_unlock_intro": "Liga algo quando esta recompensa for aprovada e desliga de novo após um tempo definido.", + "panel.reward_unlock_none": "Nada", + "panel.reward_unlock_entity": "Desbloquear", + "panel.reward_unlock_minutes": "Durante quanto tempo (minutos)", + "panel.reward_unlock_minutes_hint": "0 = deixar ligado; terás de desligar tu.", + "panel.reward_unlock_no_allowlist": "Adiciona primeiro uma entidade à lista de permissões nas Definições.", + "panel.tab_insights": "Análises", + "panel.insights_loading": "A calcular…", + "panel.insights_fairness_title": "Quem está a fazer o trabalho?", + "panel.insights_fairness_intro": "{start} a {end}. Uma divisão igual seria {fair}% para cada.", + "panel.insights_last_days": "Últimos {days} dias", + "panel.insights_no_data": "Ainda sem tarefas aprovadas neste período.", + "panel.insights_balanced": "Parece bastante equilibrado.", + "panel.insights_unbalanced": "A carga está desequilibrada.", + "panel.insights_chores": "tarefas", + "panel.insights_status_over": "Faz mais", + "panel.insights_status_under": "Faz menos", + "panel.insights_status_balanced": "Equilibrado", + "panel.insights_status_idle": "Ainda nada", + "panel.insights_fairness_note": "Avaliado pelo número de tarefas, não por pontos, para que uma tarefa mais cara não esconda uma divisão desigual. Assinalado quando uma criança está a mais de {tolerance} pontos de uma parte igual.", + "panel.insights_chore": "tarefa", + "panel.insights_view_fairness": "Equidade", + "panel.insights_view_friction": "Atrito", + "panel.insights_friction_title": "O que não está a funcionar?", + "panel.insights_friction_intro": "{start} a {end}. As tarefas são avaliadas pela frequência com que são feitas face à frequência com que surgiram.", + "panel.insights_friction_none": "Sem tarefas ativas para analisar.", + "panel.insights_friction_healthy": "Está tudo a ser feito.", + "panel.insights_friction_problems": "{count} tarefa(s) a merecer atenção.", + "panel.insights_col_chore": "Tarefa", + "panel.insights_col_done": "Feitas", + "panel.insights_col_rate": "Taxa", + "panel.insights_col_last": "Última vez", + "panel.insights_col_verdict": "Veredito", + "panel.insights_col_suggestion": "Tenta", + "panel.insights_never": "Nunca", + "panel.insights_days_ago": "há {days}d", + "panel.insights_verdict_never": "Nunca feita", + "panel.insights_verdict_stalling": "Parada", + "panel.insights_verdict_struggling": "Irregular", + "panel.insights_verdict_fine": "Bem", + "panel.insights_verdict_unknown": "Incerto", + "panel.insights_suggest_retire": "Remover", + "panel.insights_suggest_reprice": "Aumentar os pontos", + "panel.insights_suggest_reassign": "Dar a outra pessoa", + "panel.insights_suggest_keep": "Deixar como está", + "panel.insights_outstanding_misses": "Falhas obrigatórias por resolver", + "panel.insights_friction_note": "As rejeições não são listadas: o TaskMate apaga a conclusão quando a rejeitas, por isso não há histórico. Os valores esperados são aproximados — rotação, dependências e meteorologia não são reproduzidas.", + "panel.insights_view_projection": "Semana seguinte", + "panel.insights_projection_title": "O que vem aí?", + "panel.insights_projection_intro": "{start} a {end}, com base no horário de cada tarefa.", + "panel.insights_next_days": "Próximos {days} dias", + "panel.insights_col_day": "Dia", + "panel.insights_projected_earn": "até {points} {name}", + "panel.insights_projected_total": "chegaria a {total}", + "panel.insights_projection_unassigned": "{points} pontos não estão atribuídos a ninguém.", + "panel.insights_projection_note": "Um teto, não uma previsão: tarefas abertas a todos são contadas para cada criança elegível, já que o horário não sabe quem chega primeiro. Meteorologia, dependências e disponibilidade não são projetadas — dependem do dia.", + "panel.insights_view_health": "Estado", + "panel.health_title": "Está algo avariado?", + "panel.health_recheck": "Verificar de novo", + "panel.health_all_good": "Nada de errado.", + "panel.health_issues": "{count} coisa(s) a precisar de atenção.", + "panel.health_sev_error": "Erro", + "panel.health_sev_warning": "Aviso", + "panel.health_sev_info": "Nota", + "panel.health_goto": "Mostrar", + "panel.health_count_children": "Crianças", + "panel.health_count_chores": "Tarefas", + "panel.health_count_enabled_chores": "Ativas", + "panel.health_count_rewards": "Recompensas", + "panel.health_count_completions": "Conclusões", + "panel.health_count_badges": "Emblemas", + "panel.health_count_scheduled_changes": "Agendadas", + "panel.health_count_active_unlocks": "Desbloqueios", + "panel.health_count_mandatory_misses": "Falhas", + "panel.health_count_storage": "Armazenamento", + "panel.chore_icon_label": "Imagem", + "child.editor.pre_reader": "Modo só imagens (crianças pequenas)", + "child.editor.pre_reader_labels": "Mostrar nomes no modo imagens", + "common.design.accessible": "Acessível" } diff --git a/custom_components/taskmate/www/taskmate-child-card.js b/custom_components/taskmate/www/taskmate-child-card.js index 7a7026e..45fce5d 100644 --- a/custom_components/taskmate/www/taskmate-child-card.js +++ b/custom_components/taskmate/www/taskmate-child-card.js @@ -91,9 +91,17 @@ class TaskMateChildCard extends LitElement { || this.hass?.states?.[this.config?.entity]?.attributes || {}; const sessions = attrs.active_timed_sessions || []; const hasRunning = sessions.some(s => s.state === 'running' && s.child_id === this.config?.child_id); - if (hasRunning && !this._timerInterval) { + // A reactive chore's countdown (#674) has to keep ticking too, otherwise it + // sits on a stale minute until the next coordinator push. + const now = Date.now(); + const hasDeadline = (attrs.chores || []).some(c => { + if (!c.deadline_at) return false; + const at = new Date(c.deadline_at).getTime(); + return !Number.isNaN(at) && at > now; + }); + if ((hasRunning || hasDeadline) && !this._timerInterval) { this._timerInterval = setInterval(() => { this._timerTick++; this.requestUpdate(); }, 1000); - } else if (!hasRunning && this._timerInterval) { + } else if (!hasRunning && !hasDeadline && this._timerInterval) { this._stopTimerTick(); } } @@ -600,6 +608,23 @@ class TaskMateChildCard extends LitElement { width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; } + + /* Designed-header avatar wrapper: mirror of the classic avatar-container + so the picker works under every design, not just classic. The picker + itself (.avatar-picker) is absolute, so the header is the positioned + anchor. Base styles here win over the shared kit's .tmd-hd because the + card includes them after the kit in static styles(). */ + .tmd-hd { position: relative; } + .tmd-av-wrap { position: relative; display: inline-flex; align-items: center; flex: none; } + .tmd-av-wrap.avatar-clickable { cursor: pointer; } + .tmd-av-edit { + position: absolute; bottom: -2px; right: -2px; + width: 15px; height: 15px; border-radius: 50%; + background: var(--tmd-accent, #7c3aed); color: #fff; + display: grid; place-items: center; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + } + .tmd-av-edit ha-icon { --mdc-icon-size: 10px; color: #fff; } .avatar-edit-dot ha-icon { --mdc-icon-size: 12px; color: white; } .avatar-picker { position: absolute; z-index: 20; margin-top: 56px; @@ -1185,6 +1210,121 @@ class TaskMateChildCard extends LitElement { width: 6px; background: var(--fun-red); } + /* Pre-reader mode (#683) — picture-only, huge targets */ + .pre-reader-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); + gap: 14px; + padding: 4px 0 8px; + } + .pre-tile { + position: relative; + display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 8px; + min-height: 140px; padding: 16px 10px; + border: 3px solid var(--divider-color, #e0e0e0); + border-radius: 26px; + background: var(--card-background-color, #fff); + font-family: inherit; cursor: pointer; + transition: transform 0.12s ease, border-color 0.12s ease; + } + .pre-tile:active { transform: scale(0.96); } + .pre-tile:focus-visible { outline: 4px solid var(--primary-color); outline-offset: 3px; } + .pre-tile-icon ha-icon { --mdc-icon-size: 64px; color: var(--primary-color); } + .pre-tile-stars { display: flex; gap: 2px; } + .pre-tile-stars ha-icon { --mdc-icon-size: 18px; color: #f1c40f; } + .pre-tile-label { + font-size: 0.85rem; font-weight: 700; text-align: center; + color: var(--secondary-text-color); line-height: 1.2; + } + .pre-tile.done { + border-color: var(--success-color, #2ecc71); + background: rgba(46, 204, 113, 0.10); + } + .pre-tile.done .pre-tile-icon ha-icon { opacity: 0.35; } + .pre-tile.done .pre-tile-stars { opacity: 0.35; } + .pre-tile-tick { + position: absolute; inset: 0; + display: grid; place-items: center; + pointer-events: none; + } + .pre-tile-tick ha-icon { + --mdc-icon-size: 72px; + color: var(--success-color, #2ecc71); + } + .pre-tile.locked { opacity: 0.4; cursor: default; } + .pre-tile.loading { opacity: 0.6; } + + /* Chore roulette (#677) */ + .roulette { + display: flex; flex-direction: column; gap: 8px; + margin: 4px 0 12px; + } + /* The --tmd-* tokens only resolve under a designed style; the fallbacks + are the original classic values, so classic is unchanged. */ + .roulette-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + background: var(--tmd-accent, linear-gradient(135deg, #8e44ad, #c0392b)); + color: #fff; border: 0; + border-radius: var(--tmd-radius-sm, 14px); + padding: 12px 16px; + font-family: var(--tmd-font-display, inherit); font-size: 0.95rem; + font-weight: 800; cursor: pointer; width: 100%; + } + .roulette-btn:disabled { opacity: 0.55; cursor: default; } + .roulette-btn.is-spinning ha-icon { animation: tm-roulette-spin 0.6s linear infinite; } + @keyframes tm-roulette-spin { to { transform: rotate(360deg); } } + .roulette-result { + display: flex; align-items: center; gap: 8px; + background: color-mix(in srgb, var(--tmd-accent, #8e44ad) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--tmd-accent, #8e44ad) 35%, transparent); + border-radius: var(--tmd-radius-sm, 12px); padding: 8px 12px; + } + .roulette-mult { + background: var(--tmd-accent, #8e44ad); color: #fff; + border-radius: var(--tmd-radius-sm, 8px); padding: 2px 8px; + font-weight: 900; font-size: 0.85rem; + } + .roulette-name { flex: 1; font-weight: 700; font-size: 0.95rem; } + .roulette-worth { + display: inline-flex; align-items: center; gap: 4px; + font-weight: 800; color: var(--tmd-accent, #8e44ad); + } + .roulette-worth ha-icon { --mdc-icon-size: 16px; } + .roulette-spent { + text-align: center; font-size: 0.8rem; + color: var(--tmd-dim, var(--secondary-text-color)); opacity: 0.8; + } + + /* Reactive-chore countdown (#674). Amber by default, red under 5 min. + On its own line, not inline in .chore-name — that name ellipsises, and + a long one clipped the badge down to just the bonus. */ + .deadline-badge { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 4px; + align-self: flex-start; + background: rgba(255, 152, 0, 0.18); + color: #ef6c00; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.02em; + text-transform: uppercase; + border-radius: 8px; + padding: 2px 7px; + white-space: nowrap; + width: fit-content; + } + .deadline-badge.deadline-urgent { + background: var(--fun-red); + color: #fff; + } + .deadline-bonus { + opacity: 0.85; + font-weight: 900; + } + .mandatory-badge { display: inline-flex; align-items: center; @@ -2091,7 +2231,13 @@ class TaskMateChildCard extends LitElement { ` : ''; })() : ''} - ${childChores.map((chore, index) => html` + ${this._renderRoulette(child, childChores, pointsIcon)} + ${this.config.pre_reader === true ? html` +

+ ${childChores.map((chore, index) => + this._renderPreReaderTile(chore, child, todaysCompletions, index))} +
+ ` : childChores.map((chore, index) => html` ${chore.task_type === 'timed' ? this._renderTimedChoreCard(chore, child, pointsIcon, todaysCompletions, index) : html` @@ -2292,10 +2438,20 @@ class TaskMateChildCard extends LitElement { ${countdown.label}` : ""} `; - const body = - design === "playroom" ? this._designPlayroom(child, rows, remaining, tone) : - design === "console" ? this._designConsole(child, rows, remaining, tone) : - this._designCleanpro(child, rows, remaining, tone); + // Pre-reader mode replaces the chore list with picture tiles. It has to be + // handled here as well as in the classic path, or `pre_reader: true` is + // silently ignored under every designed style — including accessible, + // which is the one a child who needs picture tiles is most likely on. + // The tiles keep the designed shell (header, tokens) around them. + const body = this.config.pre_reader === true + ? html` +
+ ${childChores.map((chore, index) => + this._renderPreReaderTile(chore, child, todaysCompletions, index))} +
` + : design === "playroom" ? this._designPlayroom(child, rows, remaining, tone) : + design === "console" ? this._designConsole(child, rows, remaining, tone) : + this._designCleanpro(child, rows, remaining, tone); return html` ${this._designHeaderFull(child, design, remaining, rows.length, tone, pendingPoints, pointsIcon)} @@ -2316,6 +2472,7 @@ class TaskMateChildCard extends LitElement { ${earnedBadges.length > 5 ? html`+${earnedBadges.length - 5} →` : ""} ` : ""} ${sectionLine} + ${this._renderRoulette(child, childChores, pointsIcon)} ${body} ${this._renderSwappable(allChores, child, pointsIcon)} @@ -2336,13 +2493,25 @@ class TaskMateChildCard extends LitElement { ? (child.level ? `${this._t("child.level_label", { level: child.level })} · ${remaining} ACTIVE` : `${remaining} ACTIVE`) : design === "cleanpro" ? `${remaining} / ${total}` : `${remaining} · ${this._t("child.todays_chores")}`; + // Avatar picker: classic makes the avatar tappable; the designed header + // must too, or the picker only ever works on classic. Same eligibility + // rule (allow_avatar_change + more than one unlocked option). + const opts = child.avatar_options || []; + const canChange = this.config.allow_avatar_change !== false + && opts.filter(o => o.unlocked).length > 1; return html`
- ${this._av(child, tone, 34)} +
this._toggleAvatarPicker() : null} + title="${canChange ? this._t("child.avatar_change") : ""}"> + ${this._av(child, tone, 34)} + ${canChange ? html`` : ""} +
${title}${sub} ${remaining === 0 && total > 0 ? html`🎉` : ""} ${pendingPoints > 0 ? html` +${pendingPoints}` : ""} + ${canChange && this._avatarPickerOpen ? this._renderAvatarPicker(child, opts) : ""}
`; } @@ -2982,6 +3151,144 @@ class TaskMateChildCard extends LitElement { `; } + /** + * Countdown badge for a reactive chore (#674) — "empty the washing machine + * within 30 minutes". Reads the deadline fresh on every render, so it ticks + * down with the coordinator's updates. Hidden once the chore is done. + */ + _renderDeadlineBadge(chore, isCompletedForToday) { + if (!chore.deadline_at || isCompletedForToday) return ''; + const deadline = new Date(chore.deadline_at); + if (Number.isNaN(deadline.getTime())) return ''; + const msLeft = deadline.getTime() - Date.now(); + if (msLeft <= 0) return ''; + + const minsLeft = Math.ceil(msLeft / 60000); + const label = minsLeft >= 60 + ? this._t('child.deadline_hours', { hours: Math.floor(minsLeft / 60), mins: minsLeft % 60 }) + : this._t('child.deadline_minutes', { mins: minsLeft }); + // Under five minutes is the point at which it's worth shouting about. + const urgent = minsLeft <= 5; + const bonus = chore.speed_bonus_points || 0; + + return html` + + ⏱ ${label}${bonus ? html` +${bonus}` : ''} + + `; + } + + /** + * Chore roulette (#677) — an opt-in nudge for the child who has stalled. + * Only renders when the parent enabled it in settings AND the card opts in, + * so an existing dashboard never sprouts a new button unasked. + */ + _renderRoulette(child, childChores, pointsIcon) { + if (this.config.show_roulette !== true) return ''; + const roulette = child.roulette; + if (!roulette) return ''; // switched off in settings + + const picked = roulette.chore_id + ? childChores.find(c => String(c.id) === String(roulette.chore_id)) + : null; + const spinsLeft = roulette.spins_left ?? 0; + const multiplier = roulette.multiplier || 2; + const spinning = this._spinning === child.id; + + return html` +
+ ${picked ? html` +
+ ×${multiplier} + ${picked.name} + + + ${Math.round((picked.effective_points ?? picked.points ?? 0) * multiplier)} + +
+ ` : ''} + ${spinsLeft > 0 ? html` + + ` : html` +
${this._t('child.roulette_no_spins')}
+ `} +
+ `; + } + + async _spinRoulette(child) { + if (this._spinning) return; + this._spinning = child.id; + this.requestUpdate(); + try { + await this.hass.callService('taskmate', 'spin_roulette', { child_id: child.id }); + } catch (err) { + // "No spins left" / "Nothing left to spin for" arrive here as + // ServiceValidationError — show them rather than failing silently. + this.dispatchEvent(new CustomEvent('hass-notification', { + detail: { message: String(err?.message || err) }, bubbles: true, composed: true, + })); + } finally { + this._spinning = null; + this.requestUpdate(); + } + } + + /** + * Pre-reader mode (#683): a picture-only tile for children who can't read + * yet. No chore name, no numbers — a big icon, a row of stars for the + * points, and a huge tick when it's done. + */ + _renderPreReaderTile(chore, child, todaysCompletions = [], choreIndex = 0) { + const dailyLimit = chore.daily_limit || 1; + // Counted the same way the standard row does: pending completions hold a + // place too, and a parent completing on behalf lands under "__parent__". + const childCompletionsToday = todaysCompletions.filter( + (comp) => comp.chore_id === chore.id + && (comp.child_id === child.id || comp.child_id === "__parent__") + && !comp.bonus_subtask_id + ); + const isDone = childCompletionsToday.length >= dailyLimit; + const isLoading = this._loading[chore.id]; + const available = chore._isAvailableForChild !== false && !chore._isLockedPreview; + + // Stars, not digits: a 4-year-old can count pictures, not read "+3". + const points = chore.effective_points ?? chore.points ?? 0; + const stars = Math.max(1, Math.min(5, Math.round(points / 2) || 1)); + + const icon = chore.icon + || this._getTimeCategoryIcon(chore.time_category) + || 'mdi:checkbox-marked-circle-outline'; + + return html` + + `; + } + _renderChoreCard(chore, child, pointsIcon, todaysCompletions = [], choreIndex = 0) { const isLoading = this._loading[chore.id]; const isCelebrating = this._celebrating === chore.id; @@ -3095,6 +3402,7 @@ class TaskMateChildCard extends LitElement {
${chore.name}${chore.mandatory ? html`⚠ ${this._t('child.mandatory')}` : ''}
+ ${this._renderDeadlineBadge(chore, isCompletedForToday)} ${chore.difficulty ? html`${this._t('child.difficulty_' + chore.difficulty) || chore.difficulty}` : ''} ${this.config.show_description && chore.description ? html`
${chore.description}
@@ -4133,6 +4441,8 @@ class TaskMateChildCardEditor extends LitElement { }, { name: 'show_countdown', selector: { boolean: {} } }, { name: 'show_description', selector: { boolean: {} } }, + { name: 'pre_reader', selector: { boolean: {} } }, + { name: 'pre_reader_labels', selector: { boolean: {} } }, { name: 'debug', selector: { boolean: {} } }, ]; } @@ -4148,6 +4458,8 @@ class TaskMateChildCardEditor extends LitElement { elapsed_time_mode: this._t('child.editor.missed_time_chores'), show_countdown: this._t('child.editor.show_countdown'), show_description: this._t('child.editor.show_description'), + pre_reader: this._t('child.editor.pre_reader'), + pre_reader_labels: this._t('child.editor.pre_reader_labels'), debug: this._t('child.editor.show_debug'), }; return labels[entry.name] ?? entry.name; @@ -4178,6 +4490,8 @@ class TaskMateChildCardEditor extends LitElement { elapsed_time_mode: this.config.elapsed_time_mode || 'dim', show_countdown: this.config.show_countdown !== false, show_description: this.config.show_description === true, + pre_reader: this.config.pre_reader === true, + pre_reader_labels: this.config.pre_reader_labels === true, debug: this.config.debug === true, }; diff --git a/custom_components/taskmate/www/taskmate-design.js b/custom_components/taskmate/www/taskmate-design.js index c0c32ef..590833a 100644 --- a/custom_components/taskmate/www/taskmate-design.js +++ b/custom_components/taskmate/www/taskmate-design.js @@ -12,16 +12,17 @@ * playroom — warm, rounded, picture-book * console — dark gamified HUD * cleanpro — calm productivity / SaaS + * accessible — high contrast, colour-blind safe, dyslexia-friendly type * * Mirrors window.__taskmate_localize: exposed globally, no ES module imports. */ (function () { - const IDS = ["classic", "playroom", "console", "cleanpro"]; + const IDS = ["classic", "playroom", "console", "cleanpro", "accessible"]; // Fonts must be loaded at the document level (an @import inside a shadow // stylesheet is honoured, but loading once globally avoids N duplicate fetches). const FONT_IMPORT = - "@import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700;800&family=Nunito:wght@400;600;700;800;900&family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700;800&family=Manrope:wght@500;600;700;800&display=swap');"; + "@import url('https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;600;700;800&family=Nunito:wght@400;600;700;800;900&family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700;800&family=Manrope:wght@500;600;700;800&family=Atkinson+Hyperlegible:wght@400;700&display=swap');"; // Design tokens. IMPORTANT: these are :host-scoped so they can be applied // INSIDE each card's shadow root. A document-level [data-tm-design] rule @@ -59,6 +60,22 @@ Cards stamp data-tm-dark on the host when HA is in dark mode. Each design has a light base (above) and a dark override here, so all three follow HA's light/dark setting. */ +:host([data-tm-design="accessible"]),[data-tm-design="accessible"]{ + /* Colours are the Okabe-Ito colour-blind-safe palette: distinguishable + under protanopia, deuteranopia and tritanopia. Text is near-black on + white (~19:1, comfortably past WCAG AAA) and borders are heavy enough to + carry meaning without relying on hue. Atkinson Hyperlegible is drawn for + low vision — its letterforms stay distinct where similar glyphs (I/l/1, + O/0) usually collapse. */ + --tmd-bg:#FFFFFF;--tmd-surface:#FFFFFF;--tmd-surface-2:#F2F2F2;--tmd-border:#111111; + --tmd-text:#111111;--tmd-dim:#3D3D3D; + --tmd-accent:#0072B2;--tmd-accent2:#D55E00;--tmd-good:#009E73;--tmd-warn:#E69F00;--tmd-bad:#D55E00;--tmd-gold:#E69F00; + --tmd-radius:10px;--tmd-radius-sm:6px;--tmd-shadow:0 0 0 2px #111111;--tmd-hd-text:#fff; + --tmd-font-display:"Atkinson Hyperlegible",sans-serif; + --tmd-font-body:"Atkinson Hyperlegible",sans-serif; + --tmd-font-mono:"Atkinson Hyperlegible",monospace; + --tmd-c1:#0072B2;--tmd-c2:#D55E00;--tmd-c3:#009E73;--tmd-c4:#CC79A7;--tmd-c5:#56B4E9;--tmd-c6:#E69F00; +} :host([data-tm-design="console"][data-tm-dark]),[data-tm-design="console"][data-tm-dark]{ --tmd-bg:#0E1320;--tmd-surface:#161D2E;--tmd-surface-2:#1E2740;--tmd-border:#2A3550; --tmd-text:#EAF0FF;--tmd-dim:#8A97B8; @@ -75,6 +92,16 @@ --tmd-bg:#0F141A;--tmd-surface:#181D25;--tmd-surface-2:#212733;--tmd-border:#2C3340; --tmd-text:#E6EAF1;--tmd-dim:#97A1B0;--tmd-accent:#6E86F5;--tmd-accent2:#1FC7B8; --tmd-shadow:0 1px 2px rgba(0,0,0,.4),0 8px 18px rgba(0,0,0,.35); +} +:host([data-tm-design="accessible"][data-tm-dark]),[data-tm-design="accessible"][data-tm-dark]{ + /* Pure black would bloom on OLED and is harsh for astigmatism; #0A0A0A with + near-white text still clears AAA. Hues are the Okabe-Ito light variants, + which stay distinguishable against a dark ground. */ + --tmd-bg:#0A0A0A;--tmd-surface:#0A0A0A;--tmd-surface-2:#1C1C1C;--tmd-border:#F5F5F5; + --tmd-text:#F5F5F5;--tmd-dim:#CFCFCF; + --tmd-accent:#56B4E9;--tmd-accent2:#E69F00;--tmd-good:#009E73;--tmd-warn:#F0E442;--tmd-bad:#E69F00;--tmd-gold:#F0E442; + --tmd-shadow:0 0 0 2px #F5F5F5; + --tmd-c1:#56B4E9;--tmd-c2:#E69F00;--tmd-c3:#009E73;--tmd-c4:#CC79A7;--tmd-c5:#F0E442;--tmd-c6:#0072B2; }`; // Shared component kit, themed by the tokens above. Every designed card @@ -234,6 +261,7 @@ { value: "playroom", label: label("playroom", "Playroom") }, { value: "console", label: label("console", "Console") }, { value: "cleanpro", label: label("cleanpro", "Clean Pro") }, + { value: "accessible", label: label("accessible", "Accessible") }, ]; } diff --git a/custom_components/taskmate/www/taskmate-family-goal-card.js b/custom_components/taskmate/www/taskmate-family-goal-card.js index d264f09..dea203b 100644 --- a/custom_components/taskmate/www/taskmate-family-goal-card.js +++ b/custom_components/taskmate/www/taskmate-family-goal-card.js @@ -3,6 +3,11 @@ * Shows the shared family co-op goal (combined points across all children) with * a progress bar toward the target and the reward. Reads the overview sensor's * `family_goal` attribute. + * + * Single layout, so it consumes the design tokens directly (like the routine + * card) rather than carrying a second _renderDesigned() template. Every + * --tmd-* reference has the card's original value as its fallback, so classic + * — which defines no tokens — renders exactly as it did before. */ const LitElement = customElements.get("hui-masonry-view") @@ -13,6 +18,7 @@ const html = LitElement.prototype.html; const css = LitElement.prototype.css; const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d); +const DEFAULT_ACCENT = "#16a085"; class TaskMateFamilyGoalCard extends LitElement { static get properties() { @@ -39,18 +45,38 @@ class TaskMateFamilyGoalCard extends LitElement { return fn ? fn(this.hass, key, params) : key; } + /** + * Resolve the active design, stamp data-tm-design on the host (which is what + * makes the :host-scoped token block apply inside this shadow root), and set + * the accent. The accent is set as an inline host property, not from a + * -
+
- ${this.config.title || goal.name || this._t("family_goal.default_title")} + ${this.config.title || goal.name || this._t("family_goal.default_title")}
-
-
-
+
+
+
${progress} / ${target} ${pointsName} ${pct}%
${achieved - ? html`
🎉 ${this._t("family_goal.reached", { reward: goal.reward || "" })}
` + ? html`
🎉 ${this._t("family_goal.reached", { reward: goal.reward || "" })}
` : goal.reward - ? html`
${this._t("family_goal.reward_label", { reward: goal.reward })}
` + ? html`
${this._t("family_goal.reward_label", { reward: goal.reward })}
` : ""}
@@ -82,18 +107,48 @@ class TaskMateFamilyGoalCard extends LitElement { } static get styles() { - return css` - .head { display: flex; align-items: center; gap: 8px; padding: 14px 16px 6px; font-weight: 600; } - .head ha-icon { color: var(--tm-goal-accent); } - .body { padding: 4px 16px 16px; } - .bar { height: 14px; border-radius: 8px; background: var(--secondary-background-color, #eee); overflow: hidden; } - .fill { height: 100%; background: var(--tm-goal-accent); transition: width .4s ease; } - .fill.done { background: #d4ac0d; } - .stats { display: flex; justify-content: space-between; font-size: 0.82rem; color: var(--secondary-text-color); margin-top: 6px; } - .reward { margin-top: 8px; font-size: 0.85rem; color: var(--primary-text-color); } - .reward.done { font-weight: 600; color: #d4ac0d; } - .empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 28px 16px; color: var(--secondary-text-color); } + // Layout classes carry an fg- prefix because the shared design kit defines + // its own .bar (and .stat / .grid / …); a bare .bar here would pick up the + // kit's rules under a designed style. + const base = css` + :host { --fg-accent: var(--tmd-accent, #16a085); } + ha-card { + overflow: hidden; + background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff))); + color: var(--tmd-text, var(--primary-text-color)); + border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px)); + font-family: var(--tmd-font-body, inherit); + } + .fg-head { + display: flex; align-items: center; gap: 8px; + padding: 14px 16px 6px; font-weight: 600; + font-family: var(--tmd-font-display, inherit); + } + .fg-head ha-icon { color: var(--fg-accent); } + .fg-body { padding: 4px 16px 16px; } + .fg-bar { + height: 14px; border-radius: var(--tmd-radius-sm, 8px); + background: var(--tmd-surface-2, var(--secondary-background-color, #eee)); + border: 1px solid var(--tmd-border, transparent); + box-sizing: border-box; + overflow: hidden; + } + .fg-fill { height: 100%; background: var(--fg-accent); transition: width .4s ease; } + .fg-fill.done { background: var(--tmd-gold, #d4ac0d); } + .fg-stats { + display: flex; justify-content: space-between; + font-size: 0.82rem; color: var(--tmd-dim, var(--secondary-text-color)); margin-top: 6px; + } + .fg-reward { margin-top: 8px; font-size: 0.85rem; color: var(--tmd-text, var(--primary-text-color)); } + .fg-reward.done { font-weight: 600; color: var(--tmd-gold, #d4ac0d); } + .fg-empty { + display: flex; flex-direction: column; align-items: center; gap: 8px; + padding: 28px 16px; color: var(--tmd-dim, var(--secondary-text-color)); + } `; + const tokens = window.__taskmate_design && window.__taskmate_design.styles + ? window.__taskmate_design.styles() : null; + return tokens ? [tokens, base] : base; } } diff --git a/custom_components/taskmate/www/taskmate-leaderboard-card.js b/custom_components/taskmate/www/taskmate-leaderboard-card.js index 66eb9eb..caa679c 100644 --- a/custom_components/taskmate/www/taskmate-leaderboard-card.js +++ b/custom_components/taskmate/www/taskmate-leaderboard-card.js @@ -319,7 +319,7 @@ class TaskMateLeaderboardCard extends LitElement { if (entity.state === "unavailable" || entity.state === "unknown") return html`
${this._t('common.unavailable')}
`; const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; - const children = [...(attrs.children || [])]; + const children = [...(attrs.children || [])].filter(c => !c.is_guest); const pointsIcon = attrs.points_icon || "mdi:star"; const pointsName = attrs.points_name || this._t('common.points'); @@ -593,7 +593,7 @@ class TaskMateLeaderboardCard extends LitElement { } const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config.entity)) || entity.attributes || {}; - const children = [...(attrs.children || [])]; + const children = [...(attrs.children || [])].filter(c => !c.is_guest); const pointsName = attrs.points_name || this._t('common.points'); const tz = this.hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone; diff --git a/custom_components/taskmate/www/taskmate-overview-card.js b/custom_components/taskmate/www/taskmate-overview-card.js index 0bb61bd..153ae12 100644 --- a/custom_components/taskmate/www/taskmate-overview-card.js +++ b/custom_components/taskmate/www/taskmate-overview-card.js @@ -411,7 +411,7 @@ class TaskMateOverviewCard extends LitElement { // full pending list (resolved via companion) so the count keeps including // completions left pending from a previous day; fall back to today's // completions only on older backends. - let pendingApprovals = 0; + let pendingApprovals; if (this.config.approvals_entity) { const appEntity = this.hass.states[this.config.approvals_entity]; pendingApprovals = appEntity?.attributes?.chore_completions?.length || 0; @@ -517,7 +517,7 @@ class TaskMateOverviewCard extends LitElement { `; } - let pendingApprovals = 0; + let pendingApprovals; if (this.config.approvals_entity) { const appEntity = this.hass.states[this.config.approvals_entity]; pendingApprovals = appEntity?.attributes?.chore_completions?.length || 0; diff --git a/custom_components/taskmate/www/taskmate-panel.js b/custom_components/taskmate/www/taskmate-panel.js index 63df3cb..2e84ead 100644 --- a/custom_components/taskmate/www/taskmate-panel.js +++ b/custom_components/taskmate/www/taskmate-panel.js @@ -21,6 +21,7 @@ const PANEL_VERSION = (() => { const TABS = [ { id: "children", lk: "panel.tab_children" }, { id: "activity", lk: "panel.tab_activity" }, + { id: "insights", lk: "panel.tab_insights" }, { id: "chores", lk: "panel.tab_chores" }, { id: "rewards", lk: "panel.tab_rewards" }, { id: "penalties", lk: "panel.tab_penalties" }, @@ -94,6 +95,51 @@ const VISIBILITY_OPS = [ { v: "lt", lk: "panel.vis_lt" }, ]; +// Chore fields a change can be scheduled against (#675). Must stay in step with +// SCHEDULED_CHANGE_FIELDS in models.py — the backend rejects anything else. +const SCHEDULED_FIELDS = [ + { v: "points", kind: "number" }, + { v: "assigned_to", kind: "children" }, + { v: "enabled", kind: "bool" }, + { v: "requires_approval", kind: "bool" }, + { v: "daily_limit", kind: "number" }, + { v: "due_days", kind: "days" }, + { v: "mandatory", kind: "bool" }, + { v: "mandatory_penalty_points", kind: "number" }, + { v: "expires_on", kind: "text" }, + { v: "description", kind: "text" }, + { v: "time_category", kind: "select", options: [ + { v: "anytime", lk: "panel.time_anytime" }, + { v: "morning", lk: "panel.time_morning" }, + { v: "afternoon", lk: "panel.time_afternoon" }, + { v: "evening", lk: "panel.time_evening" }, + { v: "night", lk: "panel.time_night" }, + ] }, + { v: "difficulty", kind: "select", options: [ + { v: "easy", lk: "panel.difficulty_easy" }, + { v: "medium", lk: "panel.difficulty_medium" }, + { v: "hard", lk: "panel.difficulty_hard" }, + ] }, +]; + +// Adverse HA weather conditions a chore can be blocked by. The pleasant ones +// (sunny, clear-night, partlycloudy) are deliberately omitted — nobody rains +// off "mow the lawn" because it's sunny. +const WEATHER_CONDITIONS = [ + { v: "rainy", icon: "mdi:weather-rainy" }, + { v: "pouring", icon: "mdi:weather-pouring" }, + { v: "snowy", icon: "mdi:weather-snowy" }, + { v: "snowy-rainy", icon: "mdi:weather-snowy-rainy" }, + { v: "hail", icon: "mdi:weather-hail" }, + { v: "lightning", icon: "mdi:weather-lightning" }, + { v: "lightning-rainy", icon: "mdi:weather-lightning-rainy" }, + { v: "fog", icon: "mdi:weather-fog" }, + { v: "windy", icon: "mdi:weather-windy" }, + { v: "windy-variant", icon: "mdi:weather-windy-variant" }, + { v: "cloudy", icon: "mdi:weather-cloudy" }, + { v: "exceptional", icon: "mdi:alert-outline" }, +]; + const COMPLETION_SOUNDS = [ "none", "coin", "levelup", "fanfare", "chime", "powerup", "undo", "fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7", @@ -423,7 +469,16 @@ class TaskMatePanel extends HTMLElement { if (act === "close-mobile-nav") { this._mobileNavOpen = false; this._render(); return; } if (act === "close-dialog") { this._closeDialog(); return; } if (act === "clear-field") { this._setEntityField(t.dataset.field, ""); this._render(); return; } - if (act === "pick-entity") { this._setEntityField(t.dataset.field, t.dataset.value); this._closeEntityDropdown(); this._render(); return; } + if (act === "pick-entity") { + // The allowlist picker (#678) isn't a dialog field — picking there adds + // the entity to the saved list rather than writing into _dialog.data. + if (t.dataset.field === "_allowlist_pick") { + this._closeEntityDropdown(); + this._addToAllowlist(t.dataset.value); + return; + } + this._setEntityField(t.dataset.field, t.dataset.value); this._closeEntityDropdown(); this._render(); return; + } if (act === "scrim") { if (e.target === this.querySelector(".tm-scrim")) this._closeDialog(); return; @@ -483,6 +538,44 @@ class TaskMatePanel extends HTMLElement { if (act === "toggle-assigned") { this._toggleArrayField("assigned_to", t.dataset.id); return; } if (act === "toggle-depends") { this._toggleArrayField("depends_on", t.dataset.id); return; } if (act === "toggle-calendar") { this._toggleArrayField("publish_calendar_entities", t.dataset.id); return; } + if (act === "toggle-weather-condition") { this._toggleArrayField("weather_block_conditions", t.dataset.id); return; } + if (act === "insights-view") { + this._insightView = t.dataset.id; + this._render(); + return; + } + if (act === "health-refresh") { this._health = null; this._loadHealth(); return; } + if (act === "health-goto") { + // Jump straight to where the problem lives rather than describing it. + const target = { chores: "chores", rewards: "rewards", children: "children", + settings: "settings", activity: "activity" }[t.dataset.id]; + if (target) { this._activeTab = target; this._render(); } + return; + } + if (act === "projection-window") { + this._projectionDays = Number(t.dataset.id); + this._projection = null; + this._loadProjection(this._projectionDays); + return; + } + if (act === "friction-window") { + this._frictionDays = Number(t.dataset.id); + this._friction = null; + this._loadFriction(this._frictionDays); + return; + } + if (act === "insights-window") { + this._insightDays = Number(t.dataset.id); + this._fairness = null; + this._loadFairness(this._insightDays); + return; + } + if (act === "remove-allowlist") { this._removeFromAllowlist(t.dataset.id); return; } + if (act === "add-scheduled") { this._addScheduledChange(); return; } + if (act === "remove-scheduled") { this._removeScheduledChange(t.dataset.id); return; } + if (act === "toggle-scheduled-child" || act === "toggle-scheduled-day") { + this._toggleScheduledListValue(t.dataset.id); return; + } if (act === "add-bonus-subtask") { this._addBonusSubtask(); return; } if (act === "remove-bonus-subtask") { this._removeBonusSubtask(Number(t.dataset.idx)); return; } @@ -727,6 +820,18 @@ class TaskMatePanel extends HTMLElement { if (bcField === "metric") this._render(); // re-render to show/hide value input for bool metrics return; } + // Scheduled-change draft (#675) lives beside the dialog data, not in it — + // it is a separate record, not a property of the chore being edited. + if (this._dialog && t.dataset.field && t.dataset.field.startsWith("_sched_")) { + const key = t.dataset.field.slice("_sched_".length); + const draft = this._schedDraft(); + draft[key] = t.value; + // Switching field changes which value control is shown, and any list + // selection from the previous field no longer applies. + if (key === "field") { draft.value = ""; draft.value_list = []; this._render(); } + return; + } + // Dialog field if (this._dialog && t.dataset.field) { const field = t.dataset.field; @@ -1178,6 +1283,7 @@ class TaskMatePanel extends HTMLElement { due_days: d.due_days || [], daily_limit: Number(d.daily_limit) || 1, completion_sound: d.completion_sound || "coin", + icon: d.icon || "", }); if (!ok) { this._showToast("err", this._t("panel.toast_bulk_add_failed", {error: err})); return; } this._closeDialog(true); @@ -1250,7 +1356,10 @@ class TaskMatePanel extends HTMLElement { assignment_mode: "everyone", assignment_rotation_anchor: "", manual_start_child_id: "", require_availability: false, + icon: "", visibility_entity: "", visibility_state: "on", visibility_operator: "none", + weather_entity: "", weather_block_conditions: [], + weather_temp_min: "", weather_temp_max: "", weather_wind_max: "", enabled: true, expires_on: "", due_time: "", early_bonus: 0, late_penalty: 0, @@ -1271,6 +1380,11 @@ class TaskMatePanel extends HTMLElement { depends_on: [...(c.depends_on || [])], bonus_subtasks: (c.bonus_subtasks || []).map(b => ({...b})), visibility_operator: c.visibility_operator || "none", + weather_block_conditions: [...(c.weather_block_conditions || [])], + // null/undefined limits render as an empty input, not "null" + weather_temp_min: c.weather_temp_min ?? "", + weather_temp_max: c.weather_temp_max ?? "", + weather_wind_max: c.weather_wind_max ?? "", manual_start_child_id: "", } }); } else { @@ -1305,6 +1419,11 @@ class TaskMatePanel extends HTMLElement { visibility_entity: d.visibility_entity || "", visibility_state: d.visibility_state || "on", visibility_operator: d.visibility_operator || "none", + weather_entity: d.weather_entity || "", + weather_block_conditions: d.weather_block_conditions || [], + weather_temp_min: this._optNum(d.weather_temp_min), + weather_temp_max: this._optNum(d.weather_temp_max), + weather_wind_max: this._optNum(d.weather_wind_max), enabled: d.enabled !== false, expires_on: (d.expires_on || "").trim(), due_time: (d.due_time || "").trim(), @@ -1356,7 +1475,8 @@ class TaskMatePanel extends HTMLElement { const blank = { name: "", description: "", cost: 50, icon: "mdi:gift", assigned_to: [], is_jackpot: false, pool_enabled: false, quantity_str: "", expires_at: "", - restock_enabled: false, restock_amount: 0, restock_period: "weekly" }; + restock_enabled: false, restock_amount: 0, restock_period: "weekly", + unlock_entity: "", unlock_minutes: 30 }; if (id) { const r = (this._state.rewards || []).find(x => x.id === id); if (!r) return; @@ -1388,6 +1508,8 @@ class TaskMatePanel extends HTMLElement { quantity: qty, expires_at: (d.expires_at || "").trim() || null, restock_enabled: !!d.restock_enabled, + unlock_entity: d.unlock_entity || "", + unlock_minutes: Math.max(0, Number(d.unlock_minutes) || 0), restock_amount: Math.max(0, Number(d.restock_amount) || 0), restock_period: d.restock_period || "weekly", }; @@ -2213,6 +2335,7 @@ class TaskMatePanel extends HTMLElement { return [ { head: this._t("panel.nav_today"), items: [ { id: "activity", label: this._t("panel.tab_activity"), icon: "mdi:pulse" }, + { id: "insights", label: this._t("panel.tab_insights"), icon: "mdi:chart-box-outline" }, ]}, { head: this._t("panel.nav_manage"), items: [ { id: "children", label: this._t("panel.tab_children"), icon: "mdi:account-multiple" }, @@ -2228,6 +2351,10 @@ class TaskMatePanel extends HTMLElement { ]}, { head: this._t("panel.nav_system"), items: [ { id: "notifications", label: this._t("panel.tab_notifications"), icon: "mdi:bell-outline" }, + // The audit view has always been implemented (and documented in the + // wiki), but it was never listed here — and the nav is built solely + // from this list, so there was no way to open it. + { id: "audit", label: this._t("panel.tab_audit"), icon: "mdi:history" }, { id: "settings", label: this._t("panel.tab_settings"), icon: "mdi:cog-outline" }, ]}, ].map(g => ({ @@ -2379,6 +2506,7 @@ class TaskMatePanel extends HTMLElement { switch (this._activeTab) { case "children": return this._renderChildrenTab(); case "activity": return this._renderActivityTab(); + case "insights": return this._renderInsightsTab(); case "chores": return this._renderChoresTab(); case "rewards": return this._renderRewardsTab(); case "penalties": return this._renderPenBonTab("penalty"); @@ -2395,6 +2523,323 @@ class TaskMatePanel extends HTMLElement { } } + /** + * Insight reports (#679). Computed on demand rather than stored — a stale + * cached report is worse than a slow one — so the tab fetches when opened + * and when the window changes. + */ + _renderInsightsTab() { + const view = this._insightView || "fairness"; + const switcher = ` +
+ ${["fairness", "friction", "projection", "health"].map(v => ` + + `).join("")} +
`; + if (view === "friction") return switcher + this._renderFrictionReport(); + if (view === "projection") return switcher + this._renderProjectionReport(); + if (view === "health") return switcher + this._renderHealthReport(); + return switcher + this._renderFairnessReport(); + } + + /** + * Friction (#680): which chores aren't working, and what to do about each. + * Deliberately leads with a suggestion rather than just a diagnosis. + */ + _renderFrictionReport() { + const report = this._friction; + if (!report) { + this._loadFriction(this._frictionDays || 30); + return `
${this._t("panel.insights_loading")}
`; + } + const days = this._frictionDays || 30; + const problems = report.chores.filter(c => c.verdict !== "fine"); + + return ` +
+
+

${this._t("panel.insights_friction_title")}

+
+ ${[14, 30, 90].map(d => ` + + `).join("")} +
+
+ ${this._t("panel.insights_friction_intro", { start: report.start, end: report.end })} + + ${report.chores.length === 0 ? ` +
${this._t("panel.insights_friction_none")}
+ ` : ` +
+ ${report.problem_count + ? this._t("panel.insights_friction_problems", { count: report.problem_count }) + : this._t("panel.insights_friction_healthy")} +
+ + + + + + + + + + + ${(problems.length ? problems : report.chores).map(c => ` + + + + + + + + + `).join("")} + +
${this._t("panel.insights_col_chore")}${this._t("panel.insights_col_done")}${this._t("panel.insights_col_rate")}${this._t("panel.insights_col_last")}${this._t("panel.insights_col_verdict")}${this._t("panel.insights_col_suggestion")}
${this._esc(c.name)}${c.outstanding_misses + ? ` ⚠ ${c.outstanding_misses}` : ""}${c.completed}${c.expected ? ` / ${c.expected}` : ""}${c.rate === null ? "—" : c.rate + "%"}${c.days_since === null + ? this._t("panel.insights_never") + : this._t("panel.insights_days_ago", { days: c.days_since })}${this._t("panel.insights_verdict_" + c.verdict)}${this._t("panel.insights_suggest_" + c.suggestion)}
+ ${this._t("panel.insights_friction_note")} + `} +
+ `; + } + + /** + * Projection (#681): what the week ahead looks like. A ceiling rather than + * a forecast — shared-pool chores are credited to every eligible child, + * because the schedule can't know who will actually get there first. + */ + _renderProjectionReport() { + const report = this._projection; + if (!report) { + this._loadProjection(this._projectionDays || 7); + return `
${this._t("panel.insights_loading")}
`; + } + const days = this._projectionDays || 7; + const kids = report.children; + const maxPoints = Math.max(1, ...kids.map(k => k.points)); + const pointsName = this._state.settings?.points_name || this._t("common.points"); + + return ` +
+
+

${this._t("panel.insights_projection_title")}

+
+ ${[7, 14, 28].map(d => ` + + `).join("")} +
+
+ ${this._t("panel.insights_projection_intro", { start: report.start, end: report.end })} + +
+ ${kids.map(k => ` +
+
${this._esc(k.name)}
+
+
+ ${this._t("panel.insights_projected_earn", { points: k.points, name: this._esc(pointsName) })} + · ${k.chores} ${this._t(k.chores === 1 ? "panel.insights_chore" : "panel.insights_chores")} + · ${this._t("panel.insights_projected_total", { total: k.projected_total })} +
+
+ `).join("")} +
+ + ${report.unassigned_points > 0 ? ` +
+ ${this._t("panel.insights_projection_unassigned", { points: report.unassigned_points })} +
` : ""} + + + + + ${kids.map(k => ``).join("")} + + + ${report.by_day.map(d => ` + + + ${kids.map(k => { + const cell = d.children.find(c => c.id === k.id) || { points: 0, chores: 0 }; + return ``; + }).join("")} + + `).join("")} + +
${this._t("panel.insights_col_day")}${this._esc(k.name)}
${this._t("panel.day_" + d.weekday.slice(0, 3))} ${d.date.slice(5)}${cell.chores ? `${cell.points} (${cell.chores})` : "—"}
+ ${this._t("panel.insights_projection_note")} +
+ `; + } + + /** + * Health & diagnostics (#682). Every issue carries a severity, a plain + * sentence and where to fix it — a diagnostic that only says "3 problems" + * is one the parent can't act on. + */ + _renderHealthReport() { + const report = this._health; + if (!report) { + this._loadHealth(); + return `
${this._t("panel.insights_loading")}
`; + } + const kb = (report.storage_bytes / 1024).toFixed(1); + const counts = report.counts; + + return ` +
+
+

${this._t("panel.health_title")}

+ +
+ +
+ ${report.healthy + ? this._t("panel.health_all_good") + : this._t("panel.health_issues", { count: report.issues.filter(i => i.severity !== "info").length })} +
+ + ${report.issues.length ? ` +
+ ${report.issues.map(i => ` +
+ ${this._t("panel.health_sev_" + i.severity)} + ${this._esc(i.message)} + +
+ `).join("")} +
+ ` : ""} + +
+ ${Object.entries(counts).map(([key, value]) => ` +
+
${value}
+
${this._t("panel.health_count_" + key)}
+
+ `).join("")} +
+
${kb} KB
+
${this._t("panel.health_count_storage")}
+
+
+
+ `; + } + + async _loadHealth() { + if (this._healthLoading) return; + this._healthLoading = true; + const { ok, res, err } = await this._callWS({ type: "taskmate/reports/health" }); + this._healthLoading = false; + if (!ok) { this._showToast("err", err); return; } + this._health = res; + this._render(); + } + + async _loadProjection(days) { + if (this._projectionLoading) return; + this._projectionLoading = true; + const { ok, res, err } = await this._callWS({ type: "taskmate/reports/projection", days }); + this._projectionLoading = false; + if (!ok) { this._showToast("err", err); return; } + this._projection = res; + this._render(); + } + + async _loadFriction(days) { + if (this._frictionLoading) return; + this._frictionLoading = true; + const { ok, res, err } = await this._callWS({ type: "taskmate/reports/friction", days }); + this._frictionLoading = false; + if (!ok) { this._showToast("err", err); return; } + this._friction = res; + this._render(); + } + + _renderFairnessReport() { + const days = this._insightDays || 7; + const report = this._fairness; + if (!report) { + this._loadFairness(days); + return `
${this._t("panel.insights_loading")}
`; + } + + const windows = [7, 14, 30]; + const maxShare = Math.max(1, ...report.children.map(c => c.share_completions)); + + return ` +
+
+

${this._t("panel.insights_fairness_title")}

+
+ ${windows.map(d => ` + + `).join("")} +
+
+ ${this._t("panel.insights_fairness_intro", { + start: report.start, end: report.end, fair: report.fair_share, + })} + + ${report.total_completions === 0 ? ` +
${this._t("panel.insights_no_data")}
+ ` : ` +
+ ${report.balanced + ? this._t("panel.insights_balanced") + : this._t("panel.insights_unbalanced")} +
+
+ ${report.children.map(c => ` +
+
${this._esc(c.name)}
+
+ + +
+
+ ${c.completions} ${this._t(c.completions === 1 ? "panel.insights_chore" : "panel.insights_chores")} + · ${c.points} ${this._esc(this._state.settings?.points_name || this._t("common.points"))} + · ${c.share_completions}% +
+
${this._t("panel.insights_status_" + c.status)}
+
+ `).join("")} +
+ ${this._t("panel.insights_fairness_note", { tolerance: report.tolerance })} + `} +
+ `; + } + + async _loadFairness(days) { + if (this._fairnessLoading) return; + this._fairnessLoading = true; + const { ok, res, err } = await this._callWS({ type: "taskmate/reports/fairness", days }); + this._fairnessLoading = false; + if (!ok) { this._showToast("err", err); return; } + this._fairness = res; + this._render(); + } + _searchBox(placeholder) { return `
@@ -2441,7 +2886,7 @@ class TaskMatePanel extends HTMLElement {

${this._esc(child.name || this._t("panel.child_unnamed"))}

${this._idBadge(child.id)} -
${child.availability_entity ? `${this._esc(child.availability_entity)}${child.availability_inverted ? ` ${this._t("panel.child_inverted")}` : ""}` : `${this._t("panel.child_no_availability")}`}${child.unavailability_entity ? ` · ${this._t("panel.child_busy_label")} ${this._esc(child.unavailability_entity)}` : ""}
+
${child.availability_entity ? `${this._esc(child.availability_entity)}${child.availability_inverted ? ` ${this._t("panel.child_inverted")}` : ""}` : `${this._t("panel.child_no_availability")}`}${child.unavailability_entity ? ` · ${this._t("panel.child_busy_label")} ${this._esc(child.unavailability_entity)}` : ""}
@@ -3796,6 +4241,33 @@ class TaskMatePanel extends HTMLElement {
+
+
${this._t("panel.settings_unlock_allowlist")}${this._t("panel.settings_unlock_allowlist_hint")}
+
+ ${(s.unlock_allowlist || []).map(e => ` + + ${this._esc(e)} + + + `).join("") || `${this._t("panel.settings_unlock_allowlist_empty")}`} +
+
+ ${this._entityPickerField("", "_allowlist_pick", "", + ["switch", "light", "media_player", "input_boolean", "automation", "script", "fan", "climate"], + this._t("panel.settings_unlock_allowlist_add"))} +
+
+
+
${this._t("panel.settings_roulette_enabled")}${this._t("panel.settings_roulette_enabled_hint")}
+ +
+
+
${this._t("panel.settings_roulette_section")}
+
+ + +
+
${this._t("panel.settings_decay_enabled_label")}${this._t("panel.settings_decay_enabled_hint")}
@@ -4428,7 +4900,10 @@ class TaskMatePanel extends HTMLElement { return this._dialogShell(this._dialog.mode === "add" ? this._t("panel.dialog_add_chore") : this._t("panel.dialog_edit_chore"), [ this._field(this._t("panel.chore_name_label"), "name", d.name, "text"), - this._field(this._t("panel.chore_description_label"), "description", d.description, "text"), + `
+ ${this._field(this._t("panel.chore_description_label"), "description", d.description, "text")} + ${this._iconPickerField(this._t("panel.chore_icon_label"), "icon", d.icon)} +
`, this._select(this._t("panel.chore_task_type_label"), "task_type", d.task_type || "standard", [ { v: "standard", l: this._t("panel.chore_task_type_standard") }, { v: "timed", l: this._t("panel.chore_task_type_timed") }, @@ -4566,12 +5041,224 @@ class TaskMatePanel extends HTMLElement { ${this._switch(this._t("panel.chore_enabled_label"), "enabled", d.enabled !== false)}
`, + this._dialog.mode === "edit" ? this._renderScheduledChanges(d) : "", + `
+ ${this._t("panel.chore_advanced_weather")} +
+ ${this._t("panel.chore_weather_intro")} + ${this._entityPickerField(this._t("panel.chore_weather_entity_label"), "weather_entity", d.weather_entity, ["weather"], + this._t("panel.chore_weather_entity_hint"))} + ${d.weather_entity ? ` +
+ ${this._t("panel.chore_weather_conditions_label")} +
+ ${WEATHER_CONDITIONS.map(w => ` + + `).join("")} +
+ ${this._t("panel.chore_weather_conditions_hint")} +
+
+ ${this._field(this._t("panel.chore_weather_temp_min_label"), "weather_temp_min", d.weather_temp_min, "number", this._t("panel.chore_weather_temp_min_hint"))} + ${this._field(this._t("panel.chore_weather_temp_max_label"), "weather_temp_max", d.weather_temp_max, "number", this._t("panel.chore_weather_temp_max_hint"))} +
+ ${this._field(this._t("panel.chore_weather_wind_max_label"), "weather_wind_max", d.weather_wind_max, "number", this._t("panel.chore_weather_wind_max_hint"))} + ${this._t("panel.chore_weather_failopen_hint")} + ` : ""} +
+
`, ].join(""), ` ` ); } + /** + * Scheduled config changes (#675) — queue an edit for a future date. + * Edit-only: a chore has to exist before a change can be queued against it. + */ + _renderScheduledChanges(d) { + const pending = (this._state.scheduled_changes || []) + .filter(c => c.chore_id === d.id && !c.applied) + .sort((a, b) => a.apply_on.localeCompare(b.apply_on)); + const applied = (this._state.scheduled_changes || []) + .filter(c => c.chore_id === d.id && c.applied) + .sort((a, b) => b.apply_on.localeCompare(a.apply_on)) + .slice(0, 5); + const draft = this._dialog._schedDraft || {}; + const open = this._dialog._openAdvanced?.has("scheduled"); + const children = this._state.children || []; + + const describe = (changes) => Object.entries(changes || {}) + .map(([f, v]) => { + const label = this._t("panel.sched_field_" + f); + const value = f === "assigned_to" + ? (v || []).map(id => this._esc((children.find(c => c.id === id) || {}).name || id)).join(", ") + || this._t("panel.sched_value_nobody") + : Array.isArray(v) ? v.join(", ") + : typeof v === "boolean" ? this._t(v ? "panel.sched_value_yes" : "panel.sched_value_no") + : this._esc(String(v)); + return `${label}: ${value}`; + }).join(" · "); + + return `
+ ${this._t("panel.chore_advanced_scheduled")}${pending.length ? ` ${pending.length}` : ""} +
+ ${this._t("panel.chore_scheduled_intro")} + ${pending.length ? ` +
+ ${pending.map(c => ` +
+
${this._esc(c.apply_on)}
+
${describe(c.changes)}${c.note ? `
${this._esc(c.note)}
` : ""}
+ +
+ `).join("")} +
+ ` : `${this._t("panel.chore_scheduled_none")}`} + +
+
+ ${this._dateField(this._t("panel.sched_date_label"), "_sched_apply_on", draft.apply_on || "")} + ${this._select(this._t("panel.sched_field_label"), "_sched_field", draft.field || "points", + SCHEDULED_FIELDS.map(f => ({ v: f.v, lk: "panel.sched_field_" + f.v })))} +
+ ${this._renderScheduledValueInput(draft, children)} + ${this._field(this._t("panel.sched_note_label"), "_sched_note", draft.note || "", "text", + this._t("panel.sched_note_hint"))} + +
+ + ${applied.length ? ` +
+ ${this._t("panel.chore_scheduled_history")} + ${applied.map(c => ` +
+
${this._esc(c.apply_on)}
+
${describe(c.changes)}
+ +
+ `).join("")} +
+ ` : ""} +
+
`; + } + + /** The value control depends on which field is being scheduled. */ + _renderScheduledValueInput(draft, children) { + const field = draft.field || "points"; + const spec = SCHEDULED_FIELDS.find(f => f.v === field) || SCHEDULED_FIELDS[0]; + if (spec.kind === "children") { + const picked = draft.value_list || []; + return `
+ ${this._t("panel.sched_value_label")} +
+ ${children.map(c => ` + + `).join("")} +
+
`; + } + if (spec.kind === "days") { + const picked = draft.value_list || []; + return `
+ ${this._t("panel.sched_value_label")} +
+ ${DAYS.map(day => ` + + `).join("")} +
+
`; + } + if (spec.kind === "bool") { + return this._select(this._t("panel.sched_value_label"), "_sched_value", draft.value ?? "true", + [{ v: "true", lk: "panel.sched_value_yes" }, { v: "false", lk: "panel.sched_value_no" }]); + } + if (spec.kind === "select") { + return this._select(this._t("panel.sched_value_label"), "_sched_value", draft.value ?? spec.options[0].v, spec.options); + } + return this._field(this._t("panel.sched_value_label"), "_sched_value", draft.value ?? "", + spec.kind === "number" ? "number" : "text"); + } + + _schedDraft() { + if (!this._dialog._schedDraft) this._dialog._schedDraft = { field: "points", value_list: [] }; + return this._dialog._schedDraft; + } + + _toggleScheduledListValue(value) { + const draft = this._schedDraft(); + const list = [...(draft.value_list || [])]; + const i = list.indexOf(value); + if (i >= 0) list.splice(i, 1); else list.push(value); + draft.value_list = list; + this._render(); + } + + async _saveAllowlist(list) { + const { ok, err } = await this._callWS({ type: "taskmate/update_settings", unlock_allowlist: list }); + if (!ok) { this._showToast("err", err); return; } + await this._fetchState(); + this._render(); + } + + _removeFromAllowlist(entityId) { + const current = (this._state.settings || {}).unlock_allowlist || []; + this._saveAllowlist(current.filter(e => e !== entityId)); + } + + _addToAllowlist(entityId) { + const current = (this._state.settings || {}).unlock_allowlist || []; + if (!entityId || current.includes(entityId)) return; + this._saveAllowlist([...current, entityId]); + } + + async _addScheduledChange() { + const d = this._dialog.data; + const draft = this._schedDraft(); + const spec = SCHEDULED_FIELDS.find(f => f.v === (draft.field || "points")) || SCHEDULED_FIELDS[0]; + if (!draft.apply_on) { this._showToast("err", this._t("panel.toast_sched_date_required")); return; } + + let value; + if (spec.kind === "children" || spec.kind === "days") { + value = draft.value_list || []; + } else if (spec.kind === "bool") { + value = (draft.value ?? "true") === "true"; + } else if (spec.kind === "number") { + if (draft.value === "" || draft.value == null) { this._showToast("err", this._t("panel.toast_sched_value_required")); return; } + value = Number(draft.value); + } else { + value = draft.value ?? ""; + } + + const { ok, err } = await this._callWS({ + type: "taskmate/scheduled/add", + chore_id: d.id, + apply_on: draft.apply_on, + changes: { [spec.v]: value }, + note: draft.note || "", + }); + if (!ok) { this._showToast("err", err); return; } + this._dialog._schedDraft = { field: spec.v, value_list: [] }; + this._showToast("ok", this._t("panel.toast_sched_added")); + await this._fetchState(); + this._render(); + } + + async _removeScheduledChange(changeId) { + const { ok, err } = await this._callWS({ type: "taskmate/scheduled/remove", change_id: changeId }); + if (!ok) { this._showToast("err", err); return; } + this._showToast("ok", this._t("panel.toast_sched_removed")); + await this._fetchState(); + this._render(); + } + _renderRewardDialog() { const d = this._dialog.data; const children = this._state.children || []; @@ -4618,12 +5305,39 @@ class TaskMatePanel extends HTMLElement { { v: "monthly", l: this._t("panel.reward_restock_monthly") }, ])}
`, + this._renderRewardUnlock(d), ].join(""), ` ` ); } + /** + * Timed unlock (#678). Only offers entities the parent has allowlisted in + * Settings — a reward must not be able to reach arbitrary devices. + */ + _renderRewardUnlock(d) { + const allow = (this._state.settings || {}).unlock_allowlist || []; + const open = this._dialog._openAdvanced?.has("unlock"); + if (!allow.length) { + return `
+ ${this._t("panel.reward_unlock_section")} +
${this._t("panel.reward_unlock_no_allowlist")}
+
`; + } + const options = [{ v: "", lk: "panel.reward_unlock_none" }] + .concat(allow.map(e => ({ v: e, l: e }))); + return `
+ ${this._t("panel.reward_unlock_section")} +
+ ${this._t("panel.reward_unlock_intro")} + ${this._select(this._t("panel.reward_unlock_entity"), "unlock_entity", d.unlock_entity || "", options)} + ${d.unlock_entity ? this._field(this._t("panel.reward_unlock_minutes"), "unlock_minutes", + d.unlock_minutes ?? 30, "number", this._t("panel.reward_unlock_minutes_hint")) : ""} +
+
`; + } + _renderAvatarCatalogDialog() { const rows = (this._dialog.data.catalog || []); const typeOpts = [ @@ -5162,6 +5876,14 @@ class TaskMatePanel extends HTMLElement { .replace(/"/g, """).replace(/'/g, "'"); } + // Optional numeric field: "" clears the limit (sent as null). 0 is a real + // threshold — a 0 °C minimum is meaningful — so it can't stand in for "unset". + _optNum(value) { + if (value === "" || value === null || value === undefined) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; + } + // Security defense-in-depth: only ever emit our own auth-gated photo endpoint // into an href/src. _esc() escapes HTML metacharacters but does NOT neutralize // a javascript:/external scheme, so guard the URL itself. Signed URLs keep the @@ -5413,6 +6135,10 @@ class TaskMatePanel extends HTMLElement { background: var(--tm-warning-soft); color: var(--tm-warning); border: 1px solid var(--tm-warning-border); + /* 24px high is too small to hit reliably on a phone; this is the + panel's only route to the pending-approvals queue. */ + min-height: 32px; + box-sizing: border-box; padding: 4px 12px 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; @@ -5600,7 +6326,10 @@ class TaskMatePanel extends HTMLElement { .tm-child-name { min-width: 0; flex: 1; } .tm-child-name h3 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: -0.01em; } .tm-meta { color: var(--tm-text-faint); font-size: 12px; margin-top: 2px; word-break: break-word; } - .tm-meta code { font-family: ui-monospace, "SF Mono", Menlo, monospace; background: var(--tm-surface-2); padding: 1px 6px; border-radius: 4px; font-size: 11px; color: var(--tm-text-muted); } + /* Entity ids are long and unbreakable. Left to wrap they split mid-token + and tear the pill background across two lines, so truncate instead — + the full value is on the title attribute. */ + .tm-meta code { font-family: ui-monospace, "SF Mono", Menlo, monospace; background: var(--tm-surface-2); padding: 1px 6px; border-radius: 4px; font-size: 11px; color: var(--tm-text-muted); display: inline-block; max-width: 100%; vertical-align: bottom; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .tm-text-muted { color: var(--tm-text-muted); } .tm-stats-row { @@ -6218,6 +6947,137 @@ class TaskMatePanel extends HTMLElement { } /* Chip multi-select */ + /* Insight reports (#679) */ + .tm-verdict { + border-radius: 10px; padding: 10px 14px; margin: 10px 0 14px; + font-weight: 700; font-size: 13.5px; + } + .tm-verdict-ok { background: rgba(46, 125, 50, 0.12); color: #2e7d32; } + .tm-verdict-warn { background: rgba(239, 108, 0, 0.14); color: #ef6c00; } + .tm-fairness { display: flex; flex-direction: column; gap: 14px; } + .tm-fair-row { display: grid; grid-template-columns: 120px 1fr auto; gap: 10px 12px; align-items: center; } + .tm-fair-name { font-weight: 700; font-size: 14px; } + .tm-fair-bar { + position: relative; height: 14px; border-radius: 99px; + background: var(--tm-surface-0); border: 1px solid var(--tm-border); + overflow: visible; + } + .tm-fair-bar > i { + display: block; height: 100%; border-radius: 99px; + background: var(--tm-accent); min-width: 2px; + } + .tm-fair-bar > i.tm-fair-over { background: #ef6c00; } + .tm-fair-bar > i.tm-fair-under { background: #7e57c2; } + .tm-fair-bar > i.tm-fair-balanced { background: #2e7d32; } + /* Marks where an even split would sit, so "fair" is visible not implied. */ + .tm-fair-target { + position: absolute; top: -3px; width: 2px; height: 20px; + background: var(--tm-text-muted); opacity: 0.65; + } + .tm-fair-figs { grid-column: 2; font-size: 12.5px; color: var(--tm-text-muted); } + .tm-fair-status { + font-size: 11.5px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.03em; white-space: nowrap; + } + .tm-fair-status.tm-fair-over { color: #ef6c00; } + .tm-fair-status.tm-fair-under { color: #7e57c2; } + .tm-fair-status.tm-fair-balanced { color: #2e7d32; } + .tm-fair-status.tm-fair-idle { color: var(--tm-text-muted); } + @media (max-width: 640px) { + .tm-fair-row { grid-template-columns: 1fr auto; } + .tm-fair-bar { grid-column: 1 / -1; } + } + + /* Health & diagnostics (#682) */ + .tm-health-issues { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; } + .tm-health-issue { + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; border-radius: 10px; + background: var(--tm-surface-0); border: 1px solid var(--tm-border); + } + .tm-health-sev { + font-size: 11px; font-weight: 800; text-transform: uppercase; + padding: 2px 8px; border-radius: 999px; white-space: nowrap; + } + .tm-health-error .tm-health-sev { background: rgba(198,40,40,.14); color: #c62828; } + .tm-health-warning .tm-health-sev { background: rgba(239,108,0,.16); color: #ef6c00; } + .tm-health-info .tm-health-sev { background: var(--tm-surface-1, #eee); color: var(--tm-text-muted); } + .tm-health-msg { flex: 1; font-size: 13.5px; line-height: 1.45; } + .tm-btn-small { padding: 4px 10px; font-size: 12px; } + .tm-health-counts { + display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 10px; + } + .tm-health-stat { + background: var(--tm-surface-0); border: 1px solid var(--tm-border); + border-radius: 10px; padding: 10px; text-align: center; + } + .tm-health-num { font-size: 20px; font-weight: 800; font-variant-numeric: tabular-nums; } + .tm-health-label { font-size: 11.5px; color: var(--tm-text-muted); margin-top: 2px; } + + /* Projection report (#681) */ + .tm-proj-totals { display: flex; flex-direction: column; gap: 12px; margin: 12px 0 16px; } + .tm-proj-kid { display: grid; grid-template-columns: 120px 1fr; gap: 6px 12px; align-items: center; } + .tm-proj-kid .tm-fair-figs { grid-column: 2; } + .tm-projection td, .tm-projection th { font-size: 13px; text-align: center; } + .tm-projection td:first-child, .tm-projection th:first-child { text-align: left; } + .tm-proj-date { color: var(--tm-text-muted); font-size: 11.5px; } + .tm-proj-count { color: var(--tm-text-muted); font-size: 11.5px; } + + /* Friction report (#680) */ + .tm-friction td, .tm-friction th { font-size: 13px; } + .tm-fric-verdict { + display: inline-block; padding: 2px 8px; border-radius: 999px; + font-size: 11.5px; font-weight: 800; text-transform: uppercase; + } + .tm-fric-never { background: rgba(198, 40, 40, 0.14); color: #c62828; } + .tm-fric-stalling { background: rgba(239, 108, 0, 0.16); color: #ef6c00; } + .tm-fric-struggling { background: rgba(251, 192, 45, 0.20); color: #a67c00; } + .tm-fric-fine { background: rgba(46, 125, 50, 0.14); color: #2e7d32; } + .tm-fric-unknown { background: var(--tm-surface-0); color: var(--tm-text-muted); } + .tm-fric-misses { color: #ef6c00; font-weight: 800; font-size: 12px; } + + /* Timed unlock allowlist (#678) */ + .tm-setting-stack { flex-direction: column; align-items: stretch; } + .tm-allowlist { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; } + .tm-allow-chip { + display: inline-flex; align-items: center; gap: 6px; + background: var(--tm-surface-0); border: 1px solid var(--tm-border); + border-radius: 999px; padding: 4px 6px 4px 12px; + font-size: 12.5px; font-family: var(--tm-mono, monospace); + } + .tm-allow-chip button { + background: none; border: 0; cursor: pointer; padding: 0 2px; + color: var(--tm-text-muted); font-size: 13px; line-height: 1; + } + + /* Scheduled config changes (#675) */ + .tm-sched-count { + display: inline-block; min-width: 18px; padding: 0 5px; + background: var(--tm-accent); color: #fff; + border-radius: 999px; font-size: 11px; font-weight: 700; + text-align: center; vertical-align: middle; + } + .tm-sched-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } + .tm-sched-row { + display: flex; align-items: flex-start; gap: 10px; + padding: 8px 10px; border-radius: 8px; + background: var(--tm-surface-0); border: 1px solid var(--tm-border); + } + .tm-sched-when { + font-variant-numeric: tabular-nums; font-weight: 700; + font-size: 12.5px; white-space: nowrap; padding-top: 1px; + } + .tm-sched-what { flex: 1; font-size: 12.5px; line-height: 1.5; } + .tm-sched-note { color: var(--tm-text-muted); font-size: 11.5px; margin-top: 2px; } + .tm-sched-add { + border-top: 1px solid var(--tm-border); + padding-top: 10px; margin-top: 4px; + } + .tm-sched-applied { margin-top: 14px; } + .tm-sched-done { opacity: 0.65; } + .tm-sched-tick { color: var(--tm-success, #2e7d32); font-weight: 800; } + .tm-chip-row { display: flex; gap: 6px; flex-wrap: wrap; } .tm-chip-btn { background: var(--tm-surface-0); diff --git a/custom_components/taskmate/www/taskmate-parent-dashboard-card.js b/custom_components/taskmate/www/taskmate-parent-dashboard-card.js index 00e7837..a2e9ab0 100644 --- a/custom_components/taskmate/www/taskmate-parent-dashboard-card.js +++ b/custom_components/taskmate/www/taskmate-parent-dashboard-card.js @@ -109,10 +109,19 @@ class TaskMateParentDashboardCard extends LitElement { display: flex; border-bottom: 1px solid var(--divider-color, #e0e0e0); background: var(--secondary-background-color, #f5f5f5); + /* Flex items refuse to shrink below their content, so on a narrow card + the last tab used to be clipped off the edge with no way to reach it. + Wrapping keeps every tab visible without asking the parent to + discover a horizontal swipe on a card that doesn't look scrollable. */ + flex-wrap: wrap; } .tab-btn { - flex: 1; padding: 10px 8px; + /* Grow to fill a wide card, but keep the label intact when narrow. */ + flex: 1 0 auto; + min-width: 0; + white-space: nowrap; + padding: 10px 8px; background: none; border: none; font-size: 0.78rem; font-weight: 600; color: var(--secondary-text-color); diff --git a/custom_components/taskmate/www/taskmate-photo-gallery-card.js b/custom_components/taskmate/www/taskmate-photo-gallery-card.js index 2964184..328c0a1 100644 --- a/custom_components/taskmate/www/taskmate-photo-gallery-card.js +++ b/custom_components/taskmate/www/taskmate-photo-gallery-card.js @@ -4,6 +4,10 @@ * `photo_gallery` attribute and renders thumbnails. A card's can't send a * bearer token, so each photo path is signed via the `auth/sign_path` WS command * before use. + * + * Single layout, so it consumes the design tokens directly (like the routine + * card). Every --tmd-* reference has the card's original value as its fallback, + * so classic — which defines no tokens — renders exactly as it did before. */ const LitElement = customElements.get("hui-masonry-view") @@ -14,6 +18,7 @@ const html = LitElement.prototype.html; const css = LitElement.prototype.css; const _safeColor = (c, d) => (typeof c === "string" && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : d); +const DEFAULT_ACCENT = "#5d6d7e"; class TaskMatePhotoGalleryCard extends LitElement { static get properties() { @@ -46,6 +51,25 @@ class TaskMatePhotoGalleryCard extends LitElement { return fn ? fn(this.hass, key, params) : key; } + /** + * Resolve the active design, stamp data-tm-design on the host, and set the + * header accent. The accent is an inline host property, not a template + * -
+
${this.config.title || this._t("gallery.default_title")}
${items.length === 0 - ? html`
${this._t("gallery.empty")}
` + ? html`
${this._t("gallery.empty")}
` : html` -
+
${items.map((it) => this._tile(it))}
`} @@ -111,12 +135,12 @@ class TaskMatePhotoGalleryCard extends LitElement { const src = this._signed[it.photo_url]; const when = this._formatDate(it.completed_at); return html` -
+
${src ? html`${it.chore_name}` - : html`
`} -
+ : html`
`} +
${it.child_name} ${it.chore_name} ${when}${it.approved ? "" : " · " + this._t("gallery.pending")} @@ -134,27 +158,51 @@ class TaskMatePhotoGalleryCard extends LitElement { } static get styles() { - return css` - ha-card { overflow: hidden; } - .card-header { - display: flex; align-items: center; gap: 8px; - padding: 12px 16px; color: #fff; font-weight: 600; + // Layout classes carry a pg- prefix because the shared design kit defines + // its own .grid; a bare .grid here would pick up the kit's rules under a + // designed style. Base styles come after the kit in static styles(), so + // they win at equal specificity. + const base = css` + :host { --pg-accent: var(--tmd-accent, #5d6d7e); } + ha-card { + overflow: hidden; + background: var(--tmd-surface, var(--ha-card-background, var(--card-background-color, #fff))); + color: var(--tmd-text, var(--primary-text-color)); + border-radius: var(--tmd-radius, var(--ha-card-border-radius, 12px)); + font-family: var(--tmd-font-body, inherit); } - .grid { + .pg-head { + display: flex; align-items: center; gap: 8px; + padding: 12px 16px; color: var(--tmd-hd-text, #fff); font-weight: 600; + background: var(--pg-accent); + font-family: var(--tmd-font-display, inherit); + } + .pg-grid { display: grid; gap: 10px; padding: 14px; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); } - .tile { display: flex; flex-direction: column; border-radius: 8px; overflow: hidden; background: var(--secondary-background-color, #f5f5f5); } - .tile img, .tile .ph { + .pg-tile { + display: flex; flex-direction: column; + border-radius: var(--tmd-radius-sm, 8px); overflow: hidden; + background: var(--tmd-surface-2, var(--secondary-background-color, #f5f5f5)); + border: 1px solid var(--tmd-border, transparent); + } + .pg-tile img, .pg-tile .pg-ph { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block; } - .tile .ph { display: flex; align-items: center; justify-content: center; color: var(--secondary-text-color); } - .cap { padding: 6px 8px; display: flex; flex-direction: column; gap: 1px; font-size: 0.72rem; } - .cap .who { font-weight: 600; } - .cap .what { color: var(--primary-text-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .cap .when { color: var(--secondary-text-color); } - .empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 32px 16px; color: var(--secondary-text-color); } + .pg-tile .pg-ph { display: flex; align-items: center; justify-content: center; color: var(--tmd-dim, var(--secondary-text-color)); } + .pg-cap { padding: 6px 8px; display: flex; flex-direction: column; gap: 1px; font-size: 0.72rem; } + .pg-cap .who { font-weight: 600; } + .pg-cap .what { color: var(--tmd-text, var(--primary-text-color)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .pg-cap .when { color: var(--tmd-dim, var(--secondary-text-color)); } + .pg-empty { + display: flex; flex-direction: column; align-items: center; gap: 8px; + padding: 32px 16px; color: var(--tmd-dim, var(--secondary-text-color)); + } `; + const tokens = window.__taskmate_design && window.__taskmate_design.styles + ? window.__taskmate_design.styles() : null; + return tokens ? [tokens, base] : base; } } diff --git a/custom_components/taskmate/www/taskmate-rewards-card.js b/custom_components/taskmate/www/taskmate-rewards-card.js index e5402cd..3f3fc91 100644 --- a/custom_components/taskmate/www/taskmate-rewards-card.js +++ b/custom_components/taskmate/www/taskmate-rewards-card.js @@ -1725,7 +1725,7 @@ class TaskMateRewardsCard extends LitElement { // Calculate weighted segments - each segment width = (child's share of goal) * (their progress %) // This way, if a child has 50% share and is at 100% progress, they fill 50% of the bar const segments = childContributions.map((contrib) => { - let width = 0; + let width; if (hasWeightedData && contrib.shareOfGoal > 0) { // Weighted: segment width = share of goal * progress percentage // e.g., 40% share at 50% progress = 20% of bar filled diff --git a/watchman_report.txt b/watchman_report.txt index f36ec3e..9521b1d 100644 --- a/watchman_report.txt +++ b/watchman_report.txt @@ -2,7 +2,7 @@ -== Congratulations, all 36 actions from your config are available! --== Missing 18 entity(ies) from 190 found in your config: +-== Missing 5 entity(ies) from 183 found in your config: +--------------------------------+----------+---------------------------------------------------------------+ | Entity ID | State | Location | +--------------------------------+----------+---------------------------------------------------------------+ @@ -10,37 +10,11 @@ | light.bedroom_wiz_dimmable_whi | unavail | 👥 Group: "Bedroom Lights" | | te_bedroom_1 | | | | sensor.nest_heating_yearly | unknown | 📄 .storage/lovelace.dashboard_temp:220 | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1887,1982 | -| rrent_switch_position_1 | | | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1888,1983 | -| rrent_switch_position_2 | | | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1889,1984 | -| rrent_switch_position_4 | | | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1890,1985 | -| rrent_switch_position_5 | | | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1891,1986 | -| rrent_switch_position_7 | | | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1892,1987 | -| rrent_switch_position_8 | | | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1965 | -| rrent_switch_position_1_2 | | | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1966 | -| rrent_switch_position_2_2 | | | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1967 | -| rrent_switch_position_4_2 | | | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1968 | -| rrent_switch_position_5_2 | | | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1969 | -| rrent_switch_position_7_2 | | | -| sensor.bilresa_scroll_wheel_cu | missing | 📄 automations.yaml:1970 | -| rrent_switch_position_8_2 | | | -| scene.desk_lamp_before_alert | missing | 📄 automations.yaml:2212 | +| scene.desk_lamp_before_alert | missing | 📄 automations.yaml:2177 | | sensor.meter00_daily_grid_ener | missing | 📄 configuration.yaml:51 | | gy | | | -| input_boolean.cync_motion_brid | missing | 📄 configuration.yaml:209 | -| ge | | | +--------------------------------+----------+---------------------------------------------------------------+ --== Report created on 21 Jul 2026 11:08:09 --== Parsed 23 files in 0.49s., ignored 0 files --== Generated in: 0.06s. Validated in: 0.00s. +-== Report created on 21 Jul 2026 14:07:48 +-== Parsed 23 files in 0.27s., ignored 0 files +-== Generated in: 0.02s. Validated in: 0.00s. diff --git a/www/community/lovelace-multiple-entity-row/multiple-entity-row.js b/www/community/lovelace-multiple-entity-row/multiple-entity-row.js index 6a5e343..7bf08fc 100644 --- a/www/community/lovelace-multiple-entity-row/multiple-entity-row.js +++ b/www/community/lovelace-multiple-entity-row/multiple-entity-row.js @@ -1,2 +1,2 @@ /*! For license information please see multiple-entity-row.js.LICENSE.txt */ -(()=>{"use strict";const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,n=Symbol(),i=new WeakMap;class o{constructor(t,e,i){if(this._$cssResult$=!0,i!==n)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const n=this.t;if(e&&void 0===t){const e=void 0!==n&&1===n.length;e&&(t=i.get(n)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&i.set(n,t))}return t}toString(){return this.cssText}}const r=(t,...e)=>{const i=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new o(i,t,n)},a=(n,i)=>{if(e)n.adoptedStyleSheets=i.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of i){const i=document.createElement("style"),o=t.litNonce;void 0!==o&&i.setAttribute("nonce",o),i.textContent=e.cssText,n.appendChild(i)}},s=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return(t=>new o("string"==typeof t?t:t+"",void 0,n))(e)})(t):t,{is:c,defineProperty:l,getOwnPropertyDescriptor:u,getOwnPropertyNames:d,getOwnPropertySymbols:f,getPrototypeOf:h}=Object,p=globalThis,m=p.trustedTypes,v=m?m.emptyScript:"",y=p.reactiveElementPolyfillSupport,b=(t,e)=>t,_={toAttribute(t,e){switch(e){case Boolean:t=t?v:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},g=(t,e)=>!c(t,e),w={attribute:!0,type:String,converter:_,reflect:!1,useDefault:!1,hasChanged:g};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;class A extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=w){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&l(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:o}=u(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const r=i?.call(this);o?.call(this,e),this.requestUpdate(t,r,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??w}static _$Ei(){if(this.hasOwnProperty(b("elementProperties")))return;const t=h(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(b("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(b("properties"))){const t=this.properties,e=[...d(t),...f(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(s(t))}else void 0!==t&&e.push(s(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.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(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return a(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const o=(void 0!==n.converter?.toAttribute?n.converter:_).toAttribute(e,n.type);this._$Em=t,null==o?this.removeAttribute(i):this.setAttribute(i,o),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),o="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:_;this._$Em=i;const r=o.fromAttribute(e,t.type);this[i]=r??this._$Ej?.get(i)??r,this._$Em=null}}requestUpdate(t,e,n,i=!1,o){if(void 0!==t){const r=this.constructor;if(!1===i&&(o=this[t]),n??=r.getPropertyOptions(t),!((n.hasChanged??g)(o,e)||n.useDefault&&n.reflect&&o===this._$Ej?.get(t)&&!this.hasAttribute(r._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:o},r){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,r??e??this[t]),!0!==o||void 0!==r)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}}A.elementStyles=[],A.shadowRootOptions={mode:"open"},A[b("elementProperties")]=new Map,A[b("finalized")]=new Map,y?.({ReactiveElement:A}),(p.reactiveElementVersions??=[]).push("2.1.2");const $=globalThis,S=t=>t,O=$.trustedTypes,x=O?O.createPolicy("lit-html",{createHTML:t=>t}):void 0,E="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,j="?"+C,k=`<${j}>`,P=document,T=()=>P.createComment(""),M=t=>null===t||"object"!=typeof t&&"function"!=typeof t,I=Array.isArray,H=t=>I(t)||"function"==typeof t?.[Symbol.iterator],F="[ \t\n\f\r]",D=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,R=/-->/g,N=/>/g,U=RegExp(`>|${F}(?:([^\\s"'>=/]+)(${F}*=${F}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,V=/"/g,z=/^(?:script|style|textarea|title)$/i,B=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),W=B(1),Z=(B(2),B(3),Symbol.for("lit-noChange")),q=Symbol.for("lit-nothing"),J=new WeakMap,G=P.createTreeWalker(P,129);function K(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==x?x.createHTML(e):e}const Y=(t,e)=>{const n=t.length-1,i=[];let o,r=2===e?"":3===e?"":"",a=D;for(let e=0;e"===c[0]?(a=o??D,l=-1):void 0===c[1]?l=-2:(l=a.lastIndex-c[2].length,s=c[1],a=void 0===c[3]?U:'"'===c[3]?V:L):a===V||a===L?a=U:a===R||a===N?a=D:(a=U,o=void 0);const d=a===U&&t[e+1].startsWith("/>")?" ":"";r+=a===D?n+k:l>=0?(i.push(s),n.slice(0,l)+E+n.slice(l)+C+d):n+C+(-2===l?e:d)}return[K(t,r+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class Q{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let o=0,r=0;const a=t.length-1,s=this.parts,[c,l]=Y(t,e);if(this.el=Q.createElement(c,n),G.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=G.nextNode())&&s.length0){i.textContent=O?O.emptyScript:"";for(let n=0;n2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=q}_$AI(t,e=this,n,i){const o=this.strings;let r=!1;if(void 0===o)t=X(this,t,e,0),r=!M(t)||t!==this._$AH&&t!==Z,r&&(this._$AH=t);else{const i=t;let a,s;for(t=o[0],a=0;a{const i=n?.renderBefore??e;let o=i._$litPart$;if(void 0===o){const t=n?.renderBefore??null;i._$litPart$=o=new et(e.insertBefore(T(),t),t,void 0,n??{})}return o._$AI(t),o})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return Z}}ut._$litElement$=!0,ut.finalized=!0,lt.litElementHydrateSupport?.({LitElement:ut});const dt=lt.litElementPolyfillSupport;dt?.({LitElement:ut}),(lt.litElementVersions??=[]).push("4.2.2");var ft=["unavailable","unknown"],ht="last-changed",pt="last-updated",mt=["relative","total","date","time","datetime"],vt=["entity-id","last-changed","last-updated","last-triggered","position","tilt-position","brightness"],yt=function(t){return t<10?"0".concat(t):t};function bt(t){var e=Math.floor(t/3600),n=Math.floor(t%3600/60),i=Math.floor(t%3600%60);return e>0?"".concat(e,":").concat(yt(n),":").concat(yt(i)):n>0?"".concat(n,":").concat(yt(i)):i>0?""+i:null}function _t(t){return _t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},_t(t)}function gt(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),n.push.apply(n,i)}return n}function wt(t,e,n){return(e=function(t){var e=function(t){if("object"!=_t(t)||!t)return t;var e=t[Symbol.toPrimitive];if(void 0!==e){var n=e.call(t,"string");if("object"!=_t(n))return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}(t);return"symbol"==_t(e)?e:e+""}(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}var At=function(t,e,n){var i=e?function(t){switch(t.number_format){case"comma_decimal":return["en-US","en"];case"decimal_comma":return["de","es","it"];case"space_comma":return["fr","sv","cs"];case"system":return;default:return t.language}}(e):void 0;if(Number.isNaN=Number.isNaN||function t(e){return"number"==typeof e&&t(e)},"none"!==(null==e?void 0:e.number_format)&&!Number.isNaN(Number(t))&&Intl)try{return new Intl.NumberFormat(i,$t(t,n)).format(Number(t))}catch(e){return console.error(e),new Intl.NumberFormat(void 0,$t(t,n)).format(Number(t))}return"string"==typeof t?t:"".concat(function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:2;return Math.round(t*Math.pow(10,e))/Math.pow(10,e)}(t,null==n?void 0:n.maximumFractionDigits).toString()).concat("currency"===(null==n?void 0:n.style)?" ".concat(n.currency):"")},$t=function(t,e){var n=function(t){for(var e=1;e-1?t.split(".")[1].length:0;n.minimumFractionDigits=i,n.maximumFractionDigits=i}return n};function St(t){return St="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},St(t)}var Ot=function(t){return"object"===St(t)&&!Array.isArray(t)&&!!t},xt=function(t,e,n){var i=new Event(e,{bubbles:!0,composed:!0});i.detail=n,t.dispatchEvent(i)},Et=function(t){return!t||ft.includes(t.state)},Ct=function(t,e,n){if(function(t,e){return e.hide_unavailable&&(Et(t)||e.attribute&&![ht,pt].includes(e.attribute)&&void 0===t.attributes[e.attribute])}(t,e))return!0;if(void 0===e.hide_if)return!1;var i;if(Ot(e.hide_if)&&(e.hide_if.entity||e.hide_if.attribute)){var o=e.hide_if.entity?null==n?void 0:n.states[e.hide_if.entity]:t;i=e.hide_if.attribute?null==o?void 0:o.attributes[e.hide_if.attribute]:null==o?void 0:o.state}else i=e.attribute?t.attributes[e.attribute]:t.state;var r=[];if(Ot(e.hide_if)){if(e.hide_if.below&&ie.hide_if.above)return!0;e.hide_if.value&&(r=r.concat(e.hide_if.value))}else r=r.concat(e.hide_if);return r.some(function(t){return"number"==typeof t?t===+i:t===i})},jt=["formatEntityName","formatEntityState","formatEntityAttributeValue"];function kt(t){return kt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},kt(t)}function Pt(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),n.push.apply(n,i)}return n}function Tt(t){for(var e=1;et.length)&&(e=t.length);for(var n=0,i=Array(e);n1&&a.every(function(t){return Vt.test(t)})){var s=o??0;return!isNaN(parseFloat(s))&&isFinite(s)?function(t,e,n,i){var o,r,a=parseFloat(e),s=Rt(e),c=!1,l=function(t){c||(s=void 0,o=t)},u=function(t){var e="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!e){if(Array.isArray(t)||(e=It(t))){e&&(t=e);var n=0,i=function(){};return{s:i,n:function(){return n>=t.length?{done:!0}:{done:!1,value:t[n++]}},e:function(t){throw t},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,r=!0,a=!1;return{s:function(){e=e.call(t)},n:function(){var t=e.next();return r=t.done,t},e:function(t){a=!0,o=t},f:function(){try{r||null==e.return||e.return()}finally{if(a)throw o}}}}(t);try{for(u.s();!(r=u.n()).done;){var d=r.value,f=Ut.exec(d);if(f&&"precision"===f[1])s=parseInt(f[2],10),o=void 0,c=!0;else if(f)a/={kilo:1e3,mega:1e6,milli:.001}[f[1]],void 0!==f[2]?(s=parseInt(f[2],10),o=void 0,c=!0):l(2);else switch(d){case"brightness":a=Math.round(a/255*100),n="%",l(0);break;case"percent":a*=100,n="%",l(2);break;case"invert":a=-a;break;case"position":a=100-a;break;case"celsius_to_fahrenheit":a=1.8*a+32,l(0);break;case"fahrenheit_to_celsius":a=5*(a-32)/9,l(1)}}}catch(t){u.e(t)}finally{u.f()}return"".concat(At(a,i,void 0!==s?{minimumFractionDigits:s,maximumFractionDigits:s}:void 0!==o?{maximumFractionDigits:o}:void 0)).concat(n?" ".concat(n):"")}(a,s,r,t.locale):"".concat(s).concat(r?" ".concat(r):"")}if(n.format&&!function(t){return Nt.some(function(e){return null==t?void 0:t.startsWith(e)})&&!Lt(t)}(n.format)){if(null==o&&(o=zt.includes(n.format)?"":0),zt.includes(n.format))return"".concat(function(t,e){switch(t){case"upper":return e.toUpperCase();case"lower":return e.toLowerCase();case"capitalize":return e.charAt(0).toUpperCase()+e.slice(1);case"title":return e.split(/(\s+)/).map(function(t){return t.charAt(0).toUpperCase()+t.slice(1)}).join("")}}(n.format,String(o))).concat(r?" ".concat(r):"");if(isNaN(parseFloat(o))||!isFinite(o));else if("brightness"===n.format)o=Math.round(o/255*100),r="%";else if("percent"===n.format)o=At(100*o,t.locale,{maximumFractionDigits:2}),r="%";else if("duration"===n.format){var c;o=null!==(c=bt(o))&&void 0!==c?c:"0",r=void 0}else if("duration-m"===n.format){var l;o=null!==(l=bt(o/1e3))&&void 0!==l?l:"0",r=void 0}else if("duration-h"===n.format){var u;o=null!==(u=bt(3600*o))&&void 0!==u?u:"0",r=void 0}else if(Lt(n.format)){var d=function(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var n=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=n){var i,o,r,a,s=[],c=!0,l=!1;try{if(r=(n=n.call(t)).next,0===e){if(Object(n)!==n)return;c=!1}else for(;!(c=(i=r.call(n)).done)&&(s.push(i.value),s.length!==e);c=!0);}catch(t){l=!0,o=t}finally{try{if(!c&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(l)throw o}}return s}}(t,e)||It(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}(Lt(n.format),3),f=d[1],h=d[2];if("precision"===f){var p=parseInt(h,10);o=At(parseFloat(o),t.locale,{minimumFractionDigits:p,maximumFractionDigits:p})}else o=function(t,e,n,i){if(void 0===n)return At(t/e,i,{maximumFractionDigits:2});var o=parseInt(n,10);return At(t/e,i,{minimumFractionDigits:o,maximumFractionDigits:o})}(o,{kilo:1e3,mega:1e6,milli:.001}[f],h,t.locale)}else if("invert"===n.format){var m=Rt(o);o=At(o-2*o,t.locale,void 0!==m?{minimumFractionDigits:m,maximumFractionDigits:m}:void 0)}else if("position"===n.format){var v=Rt(o);o=At(100-o,t.locale,void 0!==v?{minimumFractionDigits:v,maximumFractionDigits:v}:void 0)}else"celsius_to_fahrenheit"===n.format?o=At(1.8*o+32,t.locale,{maximumFractionDigits:0}):"fahrenheit_to_celsius"===n.format&&(o=At(5*(o-32)/9,t.locale,{maximumFractionDigits:1}));return"".concat(o).concat(r?" ".concat(r):"")}var y=Tt(Tt({},e),{},{attributes:Tt(Tt({},e.attributes),{},{unit_of_measurement:r})});return n.attribute?t.formatEntityAttributeValue(y,n.attribute):t.formatEntityState(y)},Jt=function(t){return Ot(null==t?void 0:t.styles)?Object.keys(t.styles).map(function(e){return"".concat(e,": ").concat(t.styles[e],";")}).join(""):""};class Gt{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,n){this._$Ct=t,this._$AM=e,this._$Ci=n}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}}const{I:Kt}=st,Yt={},Qt=(t=>(...e)=>({_$litDirective$:t,values:e}))(class extends Gt{constructor(){super(...arguments),this.key=q}render(t,e){return this.key=t,e}update(t,[e,n]){return e!==this.key&&(((t,e=Yt)=>{t._$AH=e})(t),this.key=e),n}});var Xt,te,ee,ne,ie,oe,re,ae,se,ce,le,ue,de,fe,he,pe,me,ve="__custom__",ye=[{value:"",label:"No format"},{value:"brightness",label:"Brightness (0-255 → %)"},{value:"percent",label:"Percent (value × 100 → x %)"},{value:"duration",label:"Duration (seconds → h:mm:ss)"},{value:"duration-m",label:"Duration (milliseconds)"},{value:"duration-h",label:"Duration (hours)"},{value:"precision0",label:"Precision 0 decimals"},{value:"precision1",label:"Precision 1 decimal"},{value:"precision2",label:"Precision 2 decimals"},{value:"precision3",label:"Precision 3 decimals"},{value:"kilo",label:"Kilo (value / 1,000)"},{value:"mega",label:"Mega (value / 1,000,000)"},{value:"milli",label:"Milli (value × 1,000)"},{value:"invert",label:"Invert (value × -1)"},{value:"position",label:"Position (100 - value)"},{value:"celsius_to_fahrenheit",label:"°C → °F"},{value:"fahrenheit_to_celsius",label:"°F → °C"},{value:"upper",label:"Text: UPPERCASE"},{value:"lower",label:"Text: lowercase"},{value:"capitalize",label:"Text: Capitalize first letter"},{value:"title",label:"Text: Title Case"},{value:"relative",label:"Timestamp: relative"},{value:"total",label:"Timestamp: total"},{value:"date",label:"Timestamp: date"},{value:"time",label:"Timestamp: time"},{value:"datetime",label:"Timestamp: date + time"},{value:ve,label:"Custom…"}],be=new Set(ye.map(function(t){return t.value}).filter(function(t){return t&&t!==ve})),_e=[{name:"entity",required:!0,selector:{entity:{}}},{type:"grid",schema:[{name:"name",selector:{text:{}}},{name:"attribute",selector:{text:{}}}]},{type:"grid",schema:[{name:"unit",selector:{text:{}}},{name:"icon",selector:{icon:{}}}]},{name:"icon_color",selector:{text:{}}},{type:"grid",schema:[{name:"show_state",default:!0,selector:{boolean:{}}},{name:"state_color",selector:{boolean:{}}}]},{type:"grid",schema:[{name:"toggle",selector:{boolean:{}}},{name:"column",selector:{boolean:{}}}]},{type:"grid",schema:[{name:"show_state_first",selector:{boolean:{}}},{name:"hide_unavailable",selector:{boolean:{}}}]},{name:"state_header",selector:{text:{}}},{name:"image",selector:{text:{}}},{name:"format",selector:{select:{mode:"dropdown",options:ye}}}],ge=[{name:"tap_action",selector:{ui_action:{default_action:"more-info"}}},{name:"hold_action",selector:{ui_action:{default_action:"none"}}},{name:"double_tap_action",selector:{ui_action:{default_action:"none"}}}],we=[{name:"entity",selector:{entity:{}}},{type:"grid",schema:[{name:"name",selector:{text:{}}},{name:"attribute",selector:{text:{}}}]},{type:"grid",schema:[{name:"unit",selector:{text:{}}},{name:"icon",selector:{icon:{}}}]},{name:"icon_color",selector:{text:{}}},{type:"grid",schema:[{name:"state_color",selector:{boolean:{}}},{name:"toggle",selector:{boolean:{}}}]},{name:"hide_unavailable",selector:{boolean:{}}},{name:"format",selector:{select:{mode:"dropdown",options:ye}}}],Ae={entity:"Entity",attribute:"Attribute",name:"Name override",unit:"Unit",icon:"Icon",icon_color:"Icon color (CSS value, e.g. red, #ff0000, var(--my-color))",image:"Image URL",format:"Format",show_state:"Show main entity state",show_state_first:"State before entities",state_header:"State header label",state_color:"State color",column:"Column layout",toggle:"Show as toggle",hide_unavailable:"Hide if unavailable",tap_action:"Tap action",hold_action:"Hold action",double_tap_action:"Double-tap action"};function $e(t,e){return e||(e=t.slice(0)),Object.freeze(Object.defineProperties(t,{raw:{value:Object.freeze(e)}}))}function Se(t){return function(t){if(Array.isArray(t))return Fe(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||He(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Oe(t,e){for(var n=0;n=t.length?{done:!0}:{done:!1,value:t[i++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var r,a=!0,s=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return a=t.done,t},e:function(t){s=!0,r=t},f:function(){try{a||null==n.return||n.return()}finally{if(s)throw r}}}}function Ie(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var n=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=n){var i,o,r,a,s=[],c=!0,l=!1;try{if(r=(n=n.call(t)).next,0===e){if(Object(n)!==n)return;c=!1}else for(;!(c=(i=r.call(n)).done)&&(s.push(i.value),s.length!==e);c=!0);}catch(t){l=!0,o=t}finally{try{if(!c&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(l)throw o}}return s}}(t,e)||He(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function He(t,e){if(t){if("string"==typeof t)return Fe(t,e);var n={}.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?Fe(t,e):void 0}}function Fe(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,i=Array(e);ni.length&&(t._selectedTab=i.length),t._keys.clear(),t._updateConfig({entities:i.length?i:void 0})}}),Pe(t,"_moveAdditional",function(e,n){var i;if(t._config){var o=Se(null!==(i=t._config.entities)&&void 0!==i?i:[]),r=e+n;if(!(r<0||r>=o.length)){var a=[o[r],o[e]];o[e]=a[0],o[r]=a[1],t._selectedTab=r+1,t._keys.clear(),t._updateConfig({entities:o})}}}),Pe(t,"_copyAdditional",function(e){var n,i=null===(n=t._config)||void 0===n||null===(n=n.entities)||void 0===n?void 0:n[e];null!=i&&t._writeClipboard("string"==typeof i?{entity:i}:ke({},i))}),Pe(t,"_cutAdditional",function(e){t._copyAdditional(e),t._deleteAdditional(e)}),Pe(t,"_copyMainAsTemplate",function(){if(t._config){var e,n={},i=Me(pn);try{for(i.s();!(e=i.n()).done;){var o=e.value,r=t._config[o];void 0!==r&&(n[o]=r)}}catch(t){i.e(t)}finally{i.f()}t._writeClipboard(n)}}),Pe(t,"_pasteEntity",function(){var e;if(t._config){var n=t._readClipboard();if(n){var i=[].concat(Se(null!==(e=t._config.entities)&&void 0!==e?e:[]),[n]);t._selectedTab=i.length,t._keys.clear(),t._updateConfig({entities:i})}}}),Pe(t,"_secondaryModeChanged",function(e){if(t._config){var n=e.detail.value.mode;if(n!==t._secondaryInfoMode()){var i;switch(n){case"none":i=void 0;break;case"text":i="";break;case"generic":i="last-changed";break;case"entity":i={entity:t._config.entity}}t._updateConfig({secondary_info:i})}}}),Pe(t,"_secondaryTextChanged",function(e){t._updateConfig({secondary_info:e.detail.value.text})}),Pe(t,"_secondaryTokenChanged",function(e){t._updateConfig({secondary_info:e.detail.value.token})}),Pe(t,"_secondaryEntityChanged",function(e){var n,i=Ot(null===(n=t._config)||void 0===n?void 0:n.secondary_info)?t._config.secondary_info.format:void 0;t._updateConfig({secondary_info:t._formatFromForm("secondary",fn(e.detail.value),i)})}),t._selectedTab=0,t._entitiesExpanded=!0,t._stateIconRowsMain=[],t._stateIconRowsAdditional=new Map,t._customFormatScopes=new Set,t}return function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function");t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,writable:!0,configurable:!0}}),Object.defineProperty(t,"prototype",{writable:!1}),e&&Ce(t,e)}(e,t),function(t,e,n){return e&&Oe(t.prototype,e),n&&Oe(t,n),Object.defineProperty(t,"prototype",{writable:!1}),t}(e,[{key:"setConfig",value:function(t){var e,n,i,o=this;this._config=t;var r,a=null!==(e=null===(n=t.entities)||void 0===n?void 0:n.length)&&void 0!==e?e:0;this._selectedTab>a&&(this._selectedTab=a),this._clipboardEntity=this._readClipboard(),this._stateIconRowsMatch(this._stateIconRowsMain,t.state_icon)||(this._stateIconRowsMain=Object.entries(null!==(r=t.state_icon)&&void 0!==r?r:{}));var s=new Map;null===(i=t.entities)||void 0===i||i.forEach(function(t,e){var n=Ot(t)?t.state_icon:void 0,i=o._stateIconRowsAdditional.get(e);i&&o._stateIconRowsMatch(i,n)?s.set(e,i):n&&s.set(e,Object.entries(n))}),this._stateIconRowsAdditional=s}},{key:"_stateIconRowsMatch",value:function(t,e){var n,i={},o=Me(t);try{for(o.s();!(n=o.n()).done;){var r=Ie(n.value,2),a=r[0],s=r[1];a.trim()&&s.trim()&&(i[a.trim()]=s.trim())}}catch(t){o.e(t)}finally{o.f()}var c=null!=e?e:{},l=Object.keys(i);return l.length===Object.keys(c).length&&l.every(function(t){return i[t]===c[t]})}},{key:"_readClipboard",value:function(){try{var t=sessionStorage.getItem(hn);if(!t)return;var e=JSON.parse(t);if(e&&"object"===De(e))return e}catch(t){}}},{key:"_writeClipboard",value:function(t){try{sessionStorage.setItem(hn,JSON.stringify(t)),this._clipboardEntity=t}catch(t){}}},{key:"_hasNativeTabs",get:function(){return!!customElements.get("ha-tab-group")&&!!customElements.get("ha-tab-group-tab")}},{key:"render",value:function(){return this.hass&&this._config?W(Xt||(Xt=$e(["\n \n ",'\n \n\n \n "])),"Entities",this._entitiesExpanded,this._onEntitiesExpandedChanged,this._renderEntitiesPanel(),"4.7.0","2026-07-08T23:41:43.781Z"):q}},{key:"_isCustomFormat",value:function(t,e){return this._customFormatScopes.has(t)||!!e&&!be.has(e)}},{key:"_setCustomFormatScope",value:function(t,e){if(e!==this._customFormatScopes.has(t)){var n=new Set(this._customFormatScopes);e?n.add(t):n.delete(t),this._customFormatScopes=n}}},{key:"_formatToForm",value:function(t,e){return this._isCustomFormat(t,e.format)?ke(ke({},e),{},{format:ve}):e}},{key:"_formatFromForm",value:function(t,e,n){if(e.format===ve){this._setCustomFormatScope(t,!0);var i=ke({},e);return void 0!==n?i.format=n:delete i.format,i}return this._setCustomFormatScope(t,!1),e}},{key:"_renderCustomFormatField",value:function(t,e,n){return this._isCustomFormat(t,e)?W(te||(te=$e(["\n \n "])),this.hass,{custom_format:null!=e?e:""},[{name:"custom_format",selector:{text:{}}}],function(){return"Custom format (e.g. invert, precision3)"},function(t){var e,i=(null!==(e=t.detail.value.custom_format)&&void 0!==e?e:"").trim();n(i||void 0)}):q}},{key:"_keyFor",value:function(t){return this._keys.has(t)||this._keys.set(t,"".concat(t,"-").concat(Math.random().toString(36).slice(2,10))),this._keys.get(t)}},{key:"_renderEntitiesPanel",value:function(){var t,e,n=this,i=null!==(t=null===(e=this._config)||void 0===e?void 0:e.entities)&&void 0!==t?t:[],o=Math.min(this._selectedTab,i.length);return W(ee||(ee=$e(['\n
\n
\n ','\n \n
\n\n
\n ',"\n
\n
\n "])),this._hasNativeTabs?W(ne||(ne=$e(['\n \n \n \n \n
\n
\n
\n \n \n ','\n \n ','\n \n ',"\n
\n "])),"Copy main as template",nn,this._copyMainAsTemplate,"Paste as new entity",on,!this._clipboardEntity,this._pasteEntity,this.hass,this._mainFormData(),_e,this._computeLabel,this._mainValueChanged,this._renderCustomFormatField("main",null===(t=this._config)||void 0===t?void 0:t.format,function(t){return n._updateConfig({format:t})}),this.hass,this._mainFormData(),ge,this._computeLabel,this._mainValueChanged,this._renderSecondaryInfoBlock(),this._renderStateIconRows("main"),this._renderStylesBlock(null===(e=this._config)||void 0===e?void 0:e.styles,function(t){return n._mainStylesChanged(t)}))}},{key:"_renderStylesBlock",value:function(t,e){return W(se||(se=$e(['\n \n ','\n \n \n \n
\n
\n
\n \n \n ','\n \n ',"\n
\n "])),"Move before","M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z",0===t,function(){return i._moveAdditional(t,-1)},"Move after","M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z",t===a,function(){return i._moveAdditional(t,1)},"Copy entity",nn,function(){return i._copyAdditional(t)},"Cut entity","M19,3L13,9L15,11L22,4V3M12,12.5A0.5,0.5 0 0,1 11.5,12A0.5,0.5 0 0,1 12,11.5A0.5,0.5 0 0,1 12.5,12A0.5,0.5 0 0,1 12,12.5M6,20A2,2 0 0,1 4,18C4,16.89 4.9,16 6,16A2,2 0 0,1 8,18C8,19.11 7.1,20 6,20M6,8A2,2 0 0,1 4,6C4,4.89 4.9,4 6,4A2,2 0 0,1 8,6C8,7.11 7.1,8 6,8M9.64,7.64C9.87,7.14 10,6.59 10,6A4,4 0 0,0 6,2A4,4 0 0,0 2,6A4,4 0 0,0 6,10C6.59,10 7.14,9.87 7.64,9.64L10,12L7.64,14.36C7.14,14.13 6.59,14 6,14A4,4 0 0,0 2,18A4,4 0 0,0 6,22A4,4 0 0,0 10,18C10,17.41 9.87,16.86 9.64,16.36L12,14L19,21H22V20L9.64,7.64Z",function(){return i._cutAdditional(t)},"Paste from clipboard",on,!this._clipboardEntity,this._pasteEntity,"Delete entity",en,function(){return i._deleteAdditional(t)},this.hass,this._formatToForm("sub-".concat(t),dn(r)),we,this._computeLabel,function(e){return i._additionalValueChanged(e,t)},this._renderCustomFormatField("sub-".concat(t),r.format,function(e){return i._setAdditionalFormat(t,e)}),this.hass,this._formatToForm("sub-".concat(t),dn(r)),ge,this._computeLabel,function(e){return i._additionalValueChanged(e,t)},this._renderStateIconRows(t),this._renderStylesBlock(r.styles,function(e){return i._additionalStylesChanged(e,t)}))}},{key:"_mainFormData",value:function(){return this._formatToForm("main",dn(ke({show_state:!0},this._config)))}},{key:"_setAdditionalFormat",value:function(t,e){var n;if(this._config){var i=Se(null!==(n=this._config.entities)&&void 0!==n?n:[]),o=i[t],r="string"==typeof o?{entity:o}:ke({},o);e?r.format=e:delete r.format,i[t]=r,this._updateConfig({entities:i})}}},{key:"_secondaryInfoMode",value:function(){var t,e=null===(t=this._config)||void 0===t?void 0:t.secondary_info;return null==e?"none":"object"===De(e)?"entity":vt.includes(e)?"generic":"text"}},{key:"_renderSecondaryInfoBlock",value:function(){var t,e=this,n=this._secondaryInfoMode(),i=null===(t=this._config)||void 0===t?void 0:t.secondary_info;return W(de||(de=$e(["\n
\n \n ","\n ","\n ","\n
\n "])),this.hass,{mode:n},rn,function(){return"Mode"},this._secondaryModeChanged,"text"===n?W(fe||(fe=$e(['
\n \n \n \n
\n ',"\n
\n "])),Bt(this.config.icon_color),this._hass,n,this.renderSecondaryInfo(),!0,this.config.column?"entities-column":"entities-row",this.config.show_state_first?W(Ue||(Ue=yn(["","",""])),this.renderMainEntity(),this.entities.map(function(e,n){return t.renderEntity(e.stateObj,e,n)})):W(Le||(Le=yn(["","",""])),this.entities.map(function(e,n){return t.renderEntity(e.stateObj,e,n)}),this.renderMainEntity()))}},{key:"renderSecondaryInfo",value:function(){if(!this.config.secondary_info||"string"==typeof(t=this.config.secondary_info)&&vt.includes(t)||Ct(this.info,this.config.secondary_info,this._hass))return null;var t;if("string"==typeof this.config.secondary_info)return W(Ve||(Ve=yn(["",""])),this.config.secondary_info);var e=Zt(this.info,this.config.secondary_info);return W(ze||(ze=yn([""," ",""])),e,this.renderValue(this.info,this.config.secondary_info))}},{key:"renderMainEntity",value:function(){if(!1===this.config.show_state)return null;if(Ct(this.stateObj,this.config,this._hass))return this.config.default?W(Be||(Be=yn(['
\n ',"\n
","
\n
"])),Jt(this.config),this.config.state_header&&W(We||(We=yn(["",""])),this.config.state_header),this.config.default):null;var t=this.getGestureHandlers("main",this.config.entity,this.config);return W(Ze||(Ze=yn(['\n ',"\n
","
\n
"])),Jt(this.config),null==t?void 0:t.onDown,null==t?void 0:t.onUp,null==t?void 0:t.onCancel,xn,xn,xn,xn,xn,xn,this.config.state_header&&W(qe||(qe=yn(["",""])),this.config.state_header),this.renderValue(this.stateObj,this.config))}},{key:"renderEntity",value:function(t,e,n){if(!t||Ct(t,e,this._hass))return e.default?W(Je||(Je=yn(['
\n ',"\n
","
\n
"])),Jt(e),e.name,e.default):null;var i=this.getGestureHandlers("sub-".concat(n),t.entity_id,e);return W(Ge||(Ge=yn(['\n ',"\n
\n ","\n
\n
"])),Jt(e),null==i?void 0:i.onDown,null==i?void 0:i.onUp,null==i?void 0:i.onCancel,xn,xn,xn,xn,xn,xn,Zt(t,e),e.icon||Ot(e.state_icon)?this.renderIcon(t,e):this.renderValue(t,e))}},{key:"renderValue",value:function(t,e){if(!0===e.toggle)return W(Ke||(Ke=yn([''])),t,this._hass);var n;if(e.attribute&&[ht,pt].includes(e.attribute))return W(Ye||(Ye=yn([""])),this._hass,t[null===(n=e.attribute)||void 0===n?void 0:n.replace("-","_")]);if(e.format&&mt.includes(e.format)){var i,o=e.attribute?null!==(i=t.attributes[e.attribute])&&void 0!==i?i:t[e.attribute]:t.state,r=new Date(o);return r instanceof Date&&!isNaN(r.getTime())?W(Qe||(Qe=yn([""])),this._hass,r,e.format):o}return qt(this._hass,t,e)}},{key:"renderIcon",value:function(t,e){var n,i=null!==(n=Wt(t,e))&&void 0!==n?n:!0===e.icon?t.attributes.icon||null:e.icon;return W(Xe||(Xe=yn([''])),Bt(e.icon_color),this._hass,t,i,e.state_color)}},{key:"renderWarning",value:function(){return W(tn||(tn=yn(["\n ","\n "])),this._hass.localize("ui.panel.lovelace.warning.entity_not_found","entity",this.config.entity))}},{key:"getGestureHandlers",value:function(t,e,n){var i,o,r,a,s,c,l,u=this;return!0===n.toggle?null:(this._actionHandlers.has(t)||this._actionHandlers.set(t,(i=function(t,i){return u.dispatchAction(e,n,t,i)},o={hasHold:!!n.hold_action,hasDoubleTap:!!n.double_tap_action},s=o.hasHold,c=o.hasDoubleTap,l=!1,{onDown:function(){l=!1,s&&(r=setTimeout(function(){l=!0},500))},onUp:function(){if(clearTimeout(r),l)return l=!1,void i(!0,!1);c?a?(clearTimeout(a),a=void 0,i(!1,!0)):a=setTimeout(function(){a=void 0,i(!1,!1)},250):i(!1,!1)},onCancel:function(){clearTimeout(r)}})),this._actionHandlers.get(t))}},{key:"dispatchAction",value:function(t,e,n,i){var o,r=i?e.double_tap_action:n?e.hold_action:null!==(o=e.tap_action)&&void 0!==o?o:{action:"more-info"};if(r&&"none"!==r.action){var a=i?"double_tap":n?"hold":"tap";xt(this,"hass-action",{config:gn({entity:r.entity||t},"".concat(a,"_action"),r),action:a})}}}],[{key:"getConfigElement",value:function(){return document.createElement("multiple-entity-row-editor")}},{key:"getStubConfig",value:function(t,e){var n,i,o=null!==(n=null!==(i=null==e?void 0:e.find(function(t){return t.startsWith("sensor.")}))&&void 0!==i?i:null==e?void 0:e[0])&&void 0!==n?n:"";return{entity:o}}},{key:"properties",get:function(){return{_hass:Object,config:Object,stateObj:Object}}},{key:"styles",get:function(){return r(Ft||(Ft=function(t,e){return e||(e=t.slice(0)),Object.freeze(Object.defineProperties(t,{raw:{value:Object.freeze(e)}}))}(["\n .icon-small {\n width: auto;\n }\n .entity {\n text-align: center;\n cursor: pointer;\n }\n .entity span {\n font-size: 10px;\n color: var(--multiple-entity-row-header-color, var(--secondary-text-color));\n }\n .entities-row {\n flex-direction: row;\n display: inline-flex;\n justify-content: space-between;\n align-items: center;\n }\n .entities-row .entity {\n margin-right: 16px;\n }\n .entities-row .entity:last-of-type {\n margin-right: 0;\n }\n .entities-column {\n flex-direction: column;\n display: flex;\n align-items: flex-end;\n justify-content: space-evenly;\n }\n .entities-column .entity div {\n display: inline-block;\n vertical-align: middle;\n }\n"])))}}])}(ut);customElements.define("multiple-entity-row",En),window.customCards=window.customCards||[],window.customCards.push({type:"multiple-entity-row",name:"Multiple Entity Row",description:"Show multiple entity states and attributes on a single entity row"})})(); \ No newline at end of file +(()=>{"use strict";const t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,n=Symbol(),i=new WeakMap;class o{constructor(t,e,i){if(this._$cssResult$=!0,i!==n)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e}get styleSheet(){let t=this.o;const n=this.t;if(e&&void 0===t){const e=void 0!==n&&1===n.length;e&&(t=i.get(n)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&i.set(n,t))}return t}toString(){return this.cssText}}const r=(t,...e)=>{const i=1===t.length?t[0]:e.reduce((e,n,i)=>e+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+t[i+1],t[0]);return new o(i,t,n)},a=(n,i)=>{if(e)n.adoptedStyleSheets=i.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of i){const i=document.createElement("style"),o=t.litNonce;void 0!==o&&i.setAttribute("nonce",o),i.textContent=e.cssText,n.appendChild(i)}},s=e?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const n of t.cssRules)e+=n.cssText;return(t=>new o("string"==typeof t?t:t+"",void 0,n))(e)})(t):t,{is:c,defineProperty:l,getOwnPropertyDescriptor:u,getOwnPropertyNames:d,getOwnPropertySymbols:h,getPrototypeOf:f}=Object,p=globalThis,m=p.trustedTypes,v=m?m.emptyScript:"",y=p.reactiveElementPolyfillSupport,b=(t,e)=>t,_={toAttribute(t,e){switch(e){case Boolean:t=t?v:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,e){let n=t;switch(e){case Boolean:n=null!==t;break;case Number:n=null===t?null:Number(t);break;case Object:case Array:try{n=JSON.parse(t)}catch(t){n=null}}return n}},g=(t,e)=>!c(t,e),w={attribute:!0,type:String,converter:_,reflect:!1,useDefault:!1,hasChanged:g};Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;class A extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,e=w){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(t)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(t,e),!e.noAccessor){const n=Symbol(),i=this.getPropertyDescriptor(t,n,e);void 0!==i&&l(this.prototype,t,i)}}static getPropertyDescriptor(t,e,n){const{get:i,set:o}=u(this.prototype,t)??{get(){return this[e]},set(t){this[e]=t}};return{get:i,set(e){const r=i?.call(this);o?.call(this,e),this.requestUpdate(t,r,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)??w}static _$Ei(){if(this.hasOwnProperty(b("elementProperties")))return;const t=f(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties)}static finalize(){if(this.hasOwnProperty(b("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(b("properties"))){const t=this.properties,e=[...d(t),...h(t)];for(const n of e)this.createProperty(n,t[n])}const t=this[Symbol.metadata];if(null!==t){const e=litPropertyMetadata.get(t);if(void 0!==e)for(const[t,n]of e)this.elementProperties.set(t,n)}this._$Eh=new Map;for(const[t,e]of this.elementProperties){const n=this._$Eu(t,e);void 0!==n&&this._$Eh.set(n,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(t){const e=[];if(Array.isArray(t)){const n=new Set(t.flat(1/0).reverse());for(const t of n)e.unshift(s(t))}else void 0!==t&&e.push(s(t));return e}static _$Eu(t,e){const n=e.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof t?t.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(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this))}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.()}removeController(t){this._$EO?.delete(t)}_$E_(){const t=new Map,e=this.constructor.elementProperties;for(const n of e.keys())this.hasOwnProperty(n)&&(t.set(n,this[n]),delete this[n]);t.size>0&&(this._$Ep=t)}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return a(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(t=>t.hostConnected?.())}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.())}attributeChangedCallback(t,e,n){this._$AK(t,n)}_$ET(t,e){const n=this.constructor.elementProperties.get(t),i=this.constructor._$Eu(t,n);if(void 0!==i&&!0===n.reflect){const o=(void 0!==n.converter?.toAttribute?n.converter:_).toAttribute(e,n.type);this._$Em=t,null==o?this.removeAttribute(i):this.setAttribute(i,o),this._$Em=null}}_$AK(t,e){const n=this.constructor,i=n._$Eh.get(t);if(void 0!==i&&this._$Em!==i){const t=n.getPropertyOptions(i),o="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:_;this._$Em=i;const r=o.fromAttribute(e,t.type);this[i]=r??this._$Ej?.get(i)??r,this._$Em=null}}requestUpdate(t,e,n,i=!1,o){if(void 0!==t){const r=this.constructor;if(!1===i&&(o=this[t]),n??=r.getPropertyOptions(t),!((n.hasChanged??g)(o,e)||n.useDefault&&n.reflect&&o===this._$Ej?.get(t)&&!this.hasAttribute(r._$Eu(t,n))))return;this.C(t,e,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(t,e,{useDefault:n,reflect:i,wrapped:o},r){n&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,r??e??this[t]),!0!==o||void 0!==r)||(this._$AL.has(t)||(this.hasUpdated||n||(e=void 0),this._$AL.set(t,e)),!0===i&&this._$Em!==t&&(this._$Eq??=new Set).add(t))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,e]of this._$Ep)this[t]=e;this._$Ep=void 0}const t=this.constructor.elementProperties;if(t.size>0)for(const[e,n]of t){const{wrapped:t}=n,i=this[e];!0!==t||this._$AL.has(e)||void 0===i||this.C(e,void 0,n,i)}}let t=!1;const e=this._$AL;try{t=this.shouldUpdate(e),t?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(e){throw t=!1,this._$EM(),e}t&&this._$AE(e)}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return!0}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM()}updated(t){}firstUpdated(t){}}A.elementStyles=[],A.shadowRootOptions={mode:"open"},A[b("elementProperties")]=new Map,A[b("finalized")]=new Map,y?.({ReactiveElement:A}),(p.reactiveElementVersions??=[]).push("2.1.2");const $=globalThis,S=t=>t,O=$.trustedTypes,x=O?O.createPolicy("lit-html",{createHTML:t=>t}):void 0,E="$lit$",C=`lit$${Math.random().toFixed(9).slice(2)}$`,k="?"+C,j=`<${k}>`,P=document,T=()=>P.createComment(""),M=t=>null===t||"object"!=typeof t&&"function"!=typeof t,I=Array.isArray,H=t=>I(t)||"function"==typeof t?.[Symbol.iterator],F="[ \t\n\f\r]",D=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,R=/-->/g,N=/>/g,U=RegExp(`>|${F}(?:([^\\s"'>=/]+)(${F}*=${F}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),L=/'/g,V=/"/g,z=/^(?:script|style|textarea|title)$/i,B=t=>(e,...n)=>({_$litType$:t,strings:e,values:n}),W=B(1),Z=(B(2),B(3),Symbol.for("lit-noChange")),q=Symbol.for("lit-nothing"),J=new WeakMap,G=P.createTreeWalker(P,129);function K(t,e){if(!I(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==x?x.createHTML(e):e}const Y=(t,e)=>{const n=t.length-1,i=[];let o,r=2===e?"":3===e?"":"",a=D;for(let e=0;e"===c[0]?(a=o??D,l=-1):void 0===c[1]?l=-2:(l=a.lastIndex-c[2].length,s=c[1],a=void 0===c[3]?U:'"'===c[3]?V:L):a===V||a===L?a=U:a===R||a===N?a=D:(a=U,o=void 0);const d=a===U&&t[e+1].startsWith("/>")?" ":"";r+=a===D?n+j:l>=0?(i.push(s),n.slice(0,l)+E+n.slice(l)+C+d):n+C+(-2===l?e:d)}return[K(t,r+(t[n]||"")+(2===e?"":3===e?"":"")),i]};class Q{constructor({strings:t,_$litType$:e},n){let i;this.parts=[];let o=0,r=0;const a=t.length-1,s=this.parts,[c,l]=Y(t,e);if(this.el=Q.createElement(c,n),G.currentNode=this.el.content,2===e||3===e){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(i=G.nextNode())&&s.length0){i.textContent=O?O.emptyScript:"";for(let n=0;n2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=q}_$AI(t,e=this,n,i){const o=this.strings;let r=!1;if(void 0===o)t=X(this,t,e,0),r=!M(t)||t!==this._$AH&&t!==Z,r&&(this._$AH=t);else{const i=t;let a,s;for(t=o[0],a=0;a{const i=n?.renderBefore??e;let o=i._$litPart$;if(void 0===o){const t=n?.renderBefore??null;i._$litPart$=o=new et(e.insertBefore(T(),t),t,void 0,n??{})}return o._$AI(t),o})(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return Z}}ut._$litElement$=!0,ut.finalized=!0,lt.litElementHydrateSupport?.({LitElement:ut});const dt=lt.litElementPolyfillSupport;dt?.({LitElement:ut}),(lt.litElementVersions??=[]).push("4.2.2");var ht=["unavailable","unknown"],ft="last-changed",pt="last-updated",mt=["relative","total","date","time","datetime"],vt=["entity-id","last-changed","last-updated","last-triggered","position","tilt-position","brightness"],yt=function(t){return t<10?"0".concat(t):t};function bt(t){var e=Math.floor(t/3600),n=Math.floor(t%3600/60),i=Math.floor(t%3600%60);return e>0?"".concat(e,":").concat(yt(n),":").concat(yt(i)):n>0?"".concat(n,":").concat(yt(i)):i>0?""+i:null}function _t(t){return _t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},_t(t)}function gt(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),n.push.apply(n,i)}return n}function wt(t,e,n){return(e=function(t){var e=function(t){if("object"!=_t(t)||!t)return t;var e=t[Symbol.toPrimitive];if(void 0!==e){var n=e.call(t,"string");if("object"!=_t(n))return n;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}(t);return"symbol"==_t(e)?e:e+""}(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}var At=function(t,e,n){var i=e?function(t){switch(t.number_format){case"comma_decimal":return["en-US","en"];case"decimal_comma":return["de","es","it"];case"space_comma":return["fr","sv","cs"];case"system":return;default:return t.language}}(e):void 0;if(Number.isNaN=Number.isNaN||function t(e){return"number"==typeof e&&t(e)},"none"!==(null==e?void 0:e.number_format)&&!Number.isNaN(Number(t))&&Intl)try{return new Intl.NumberFormat(i,$t(t,n)).format(Number(t))}catch(e){return console.error(e),new Intl.NumberFormat(void 0,$t(t,n)).format(Number(t))}return"string"==typeof t?t:"".concat(function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:2;return Math.round(t*Math.pow(10,e))/Math.pow(10,e)}(t,null==n?void 0:n.maximumFractionDigits).toString()).concat("currency"===(null==n?void 0:n.style)?" ".concat(n.currency):"")},$t=function(t,e){var n=function(t){for(var e=1;e-1?t.split(".")[1].length:0;n.minimumFractionDigits=i,n.maximumFractionDigits=i}return n};function St(t){return St="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},St(t)}var Ot=function(t){return"object"===St(t)&&!Array.isArray(t)&&!!t},xt=function(t,e,n){var i=new Event(e,{bubbles:!0,composed:!0});i.detail=n,t.dispatchEvent(i)},Et=function(t){return!t||ht.includes(t.state)},Ct=function(t,e,n){if(function(t,e){return e.hide_unavailable&&(Et(t)||e.attribute&&![ft,pt].includes(e.attribute)&&void 0===t.attributes[e.attribute])}(t,e))return!0;if(void 0===e.hide_if)return!1;var i;if(Ot(e.hide_if)&&(e.hide_if.entity||e.hide_if.attribute)){var o=e.hide_if.entity?null==n?void 0:n.states[e.hide_if.entity]:t;i=e.hide_if.attribute?null==o?void 0:o.attributes[e.hide_if.attribute]:null==o?void 0:o.state}else i=e.attribute?t.attributes[e.attribute]:t.state;var r=[];if(Ot(e.hide_if)){if(e.hide_if.below&&ie.hide_if.above)return!0;e.hide_if.value&&(r=r.concat(e.hide_if.value))}else r=r.concat(e.hide_if);return r.some(function(t){return"number"==typeof t?t===+i:t===i})},kt=["formatEntityName","formatEntityState","formatEntityAttributeValue"];function jt(t){return jt="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},jt(t)}function Pt(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),n.push.apply(n,i)}return n}function Tt(t){for(var e=1;et.length)&&(e=t.length);for(var n=0,i=Array(e);n1&&a.every(function(t){return Vt.test(t)})){var s=o??0;return!isNaN(parseFloat(s))&&isFinite(s)?function(t,e,n,i){var o,r,a=parseFloat(e),s=Rt(e),c=!1,l=function(t){c||(s=void 0,o=t)},u=function(t){var e="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(!e){if(Array.isArray(t)||(e=It(t))){e&&(t=e);var n=0,i=function(){};return{s:i,n:function(){return n>=t.length?{done:!0}:{done:!1,value:t[n++]}},e:function(t){throw t},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,r=!0,a=!1;return{s:function(){e=e.call(t)},n:function(){var t=e.next();return r=t.done,t},e:function(t){a=!0,o=t},f:function(){try{r||null==e.return||e.return()}finally{if(a)throw o}}}}(t);try{for(u.s();!(r=u.n()).done;){var d=r.value,h=Ut.exec(d);if(h&&"precision"===h[1])s=parseInt(h[2],10),o=void 0,c=!0;else if(h)a/={kilo:1e3,mega:1e6,milli:.001}[h[1]],void 0!==h[2]?(s=parseInt(h[2],10),o=void 0,c=!0):l(2);else switch(d){case"brightness":a=Math.round(a/255*100),n="%",l(0);break;case"percent":a*=100,n="%",l(2);break;case"invert":a=-a;break;case"position":a=100-a;break;case"celsius_to_fahrenheit":a=1.8*a+32,l(0);break;case"fahrenheit_to_celsius":a=5*(a-32)/9,l(1)}}}catch(t){u.e(t)}finally{u.f()}return"".concat(At(a,i,void 0!==s?{minimumFractionDigits:s,maximumFractionDigits:s}:void 0!==o?{maximumFractionDigits:o}:void 0)).concat(n?" ".concat(n):"")}(a,s,r,t.locale):"".concat(s).concat(r?" ".concat(r):"")}if(n.format&&!function(t){return Nt.some(function(e){return null==t?void 0:t.startsWith(e)})&&!Lt(t)}(n.format)){if(null==o&&(o=zt.includes(n.format)?"":0),zt.includes(n.format))return"".concat(function(t,e){switch(t){case"upper":return e.toUpperCase();case"lower":return e.toLowerCase();case"capitalize":return e.charAt(0).toUpperCase()+e.slice(1);case"title":return e.split(/(\s+)/).map(function(t){return t.charAt(0).toUpperCase()+t.slice(1)}).join("")}}(n.format,String(o))).concat(r?" ".concat(r):"");if(isNaN(parseFloat(o))||!isFinite(o));else if("brightness"===n.format)o=Math.round(o/255*100),r="%";else if("percent"===n.format)o=At(100*o,t.locale,{maximumFractionDigits:2}),r="%";else if("duration"===n.format){var c;o=null!==(c=bt(o))&&void 0!==c?c:"0",r=void 0}else if("duration-m"===n.format){var l;o=null!==(l=bt(o/1e3))&&void 0!==l?l:"0",r=void 0}else if("duration-h"===n.format){var u;o=null!==(u=bt(3600*o))&&void 0!==u?u:"0",r=void 0}else if(Lt(n.format)){var d=function(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var n=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=n){var i,o,r,a,s=[],c=!0,l=!1;try{if(r=(n=n.call(t)).next,0===e){if(Object(n)!==n)return;c=!1}else for(;!(c=(i=r.call(n)).done)&&(s.push(i.value),s.length!==e);c=!0);}catch(t){l=!0,o=t}finally{try{if(!c&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(l)throw o}}return s}}(t,e)||It(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}(Lt(n.format),3),h=d[1],f=d[2];if("precision"===h){var p=parseInt(f,10);o=At(parseFloat(o),t.locale,{minimumFractionDigits:p,maximumFractionDigits:p})}else o=function(t,e,n,i){if(void 0===n)return At(t/e,i,{maximumFractionDigits:2});var o=parseInt(n,10);return At(t/e,i,{minimumFractionDigits:o,maximumFractionDigits:o})}(o,{kilo:1e3,mega:1e6,milli:.001}[h],f,t.locale)}else if("invert"===n.format){var m=Rt(o);o=At(o-2*o,t.locale,void 0!==m?{minimumFractionDigits:m,maximumFractionDigits:m}:void 0)}else if("position"===n.format){var v=Rt(o);o=At(100-o,t.locale,void 0!==v?{minimumFractionDigits:v,maximumFractionDigits:v}:void 0)}else"celsius_to_fahrenheit"===n.format?o=At(1.8*o+32,t.locale,{maximumFractionDigits:0}):"fahrenheit_to_celsius"===n.format&&(o=At(5*(o-32)/9,t.locale,{maximumFractionDigits:1}));return"".concat(o).concat(r?" ".concat(r):"")}var y=Tt(Tt({},e),{},{attributes:Tt(Tt({},e.attributes),{},{unit_of_measurement:r})});if(n.attribute){if(void 0!==n.unit){var b=null==o?"":isNaN(o)?o:At(o,t.locale);return"".concat(b).concat(r?" ".concat(r):"")}return qt(t.formatEntityAttributeValue(y,n.attribute))}return qt(t.formatEntityState(y))},Gt=function(t){return Ot(null==t?void 0:t.styles)?Object.keys(t.styles).map(function(e){return"".concat(e,": ").concat(t.styles[e],";")}).join(""):""};class Kt{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,n){this._$Ct=t,this._$AM=e,this._$Ci=n}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}}const{I:Yt}=st,Qt={},Xt=(t=>(...e)=>({_$litDirective$:t,values:e}))(class extends Kt{constructor(){super(...arguments),this.key=q}render(t,e){return this.key=t,e}update(t,[e,n]){return e!==this.key&&(((t,e=Qt)=>{t._$AH=e})(t),this.key=e),n}});var te,ee,ne,ie,oe,re,ae,se,ce,le,ue,de,he,fe,pe,me,ve,ye="__custom__",be=[{value:"",label:"No format"},{value:"brightness",label:"Brightness (0-255 → %)"},{value:"percent",label:"Percent (value × 100 → x %)"},{value:"duration",label:"Duration (seconds → h:mm:ss)"},{value:"duration-m",label:"Duration (milliseconds)"},{value:"duration-h",label:"Duration (hours)"},{value:"precision0",label:"Precision 0 decimals"},{value:"precision1",label:"Precision 1 decimal"},{value:"precision2",label:"Precision 2 decimals"},{value:"precision3",label:"Precision 3 decimals"},{value:"kilo",label:"Kilo (value / 1,000)"},{value:"mega",label:"Mega (value / 1,000,000)"},{value:"milli",label:"Milli (value × 1,000)"},{value:"invert",label:"Invert (value × -1)"},{value:"position",label:"Position (100 - value)"},{value:"celsius_to_fahrenheit",label:"°C → °F"},{value:"fahrenheit_to_celsius",label:"°F → °C"},{value:"upper",label:"Text: UPPERCASE"},{value:"lower",label:"Text: lowercase"},{value:"capitalize",label:"Text: Capitalize first letter"},{value:"title",label:"Text: Title Case"},{value:"relative",label:"Timestamp: relative"},{value:"total",label:"Timestamp: total"},{value:"date",label:"Timestamp: date"},{value:"time",label:"Timestamp: time"},{value:"datetime",label:"Timestamp: date + time"},{value:ye,label:"Custom…"}],_e=new Set(be.map(function(t){return t.value}).filter(function(t){return t&&t!==ye})),ge=[{name:"entity",required:!0,selector:{entity:{}}},{type:"grid",schema:[{name:"name",selector:{text:{}}},{name:"attribute",selector:{text:{}}}]},{type:"grid",schema:[{name:"unit",selector:{text:{}}},{name:"icon",selector:{icon:{}}}]},{name:"icon_color",selector:{text:{}}},{type:"grid",schema:[{name:"show_state",default:!0,selector:{boolean:{}}},{name:"state_color",selector:{boolean:{}}}]},{type:"grid",schema:[{name:"toggle",selector:{boolean:{}}},{name:"column",selector:{boolean:{}}}]},{type:"grid",schema:[{name:"show_state_first",selector:{boolean:{}}},{name:"hide_unavailable",selector:{boolean:{}}}]},{name:"state_header",selector:{text:{}}},{name:"image",selector:{text:{}}},{name:"format",selector:{select:{mode:"dropdown",options:be}}}],we=[{name:"tap_action",selector:{ui_action:{default_action:"more-info"}}},{name:"hold_action",selector:{ui_action:{default_action:"none"}}},{name:"double_tap_action",selector:{ui_action:{default_action:"none"}}}],Ae=[{name:"entity",selector:{entity:{}}},{type:"grid",schema:[{name:"name",selector:{text:{}}},{name:"attribute",selector:{text:{}}}]},{type:"grid",schema:[{name:"unit",selector:{text:{}}},{name:"icon",selector:{icon:{}}}]},{name:"icon_color",selector:{text:{}}},{type:"grid",schema:[{name:"state_color",selector:{boolean:{}}},{name:"toggle",selector:{boolean:{}}}]},{name:"hide_unavailable",selector:{boolean:{}}},{name:"format",selector:{select:{mode:"dropdown",options:be}}}],$e={entity:"Entity",attribute:"Attribute",name:"Name override",unit:"Unit",icon:"Icon",icon_color:"Icon color (CSS value, e.g. red, #ff0000, var(--my-color))",image:"Image URL",format:"Format",show_state:"Show main entity state",show_state_first:"State before entities",state_header:"State header label",state_color:"State color",column:"Column layout",toggle:"Show as toggle",hide_unavailable:"Hide if unavailable",tap_action:"Tap action",hold_action:"Hold action",double_tap_action:"Double-tap action"};function Se(t,e){return e||(e=t.slice(0)),Object.freeze(Object.defineProperties(t,{raw:{value:Object.freeze(e)}}))}function Oe(t){return function(t){if(Array.isArray(t))return De(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||Fe(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function xe(t,e){for(var n=0;n=t.length?{done:!0}:{done:!1,value:t[i++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var r,a=!0,s=!1;return{s:function(){n=n.call(t)},n:function(){var t=n.next();return a=t.done,t},e:function(t){s=!0,r=t},f:function(){try{a||null==n.return||n.return()}finally{if(s)throw r}}}}function He(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var n=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=n){var i,o,r,a,s=[],c=!0,l=!1;try{if(r=(n=n.call(t)).next,0===e){if(Object(n)!==n)return;c=!1}else for(;!(c=(i=r.call(n)).done)&&(s.push(i.value),s.length!==e);c=!0);}catch(t){l=!0,o=t}finally{try{if(!c&&null!=n.return&&(a=n.return(),Object(a)!==a))return}finally{if(l)throw o}}return s}}(t,e)||Fe(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function Fe(t,e){if(t){if("string"==typeof t)return De(t,e);var n={}.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?De(t,e):void 0}}function De(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,i=Array(e);ni.length&&(t._selectedTab=i.length),t._keys.clear(),t._updateConfig({entities:i.length?i:void 0})}}),Te(t,"_moveAdditional",function(e,n){var i;if(t._config){var o=Oe(null!==(i=t._config.entities)&&void 0!==i?i:[]),r=e+n;if(!(r<0||r>=o.length)){var a=[o[r],o[e]];o[e]=a[0],o[r]=a[1],t._selectedTab=r+1,t._keys.clear(),t._updateConfig({entities:o})}}}),Te(t,"_copyAdditional",function(e){var n,i=null===(n=t._config)||void 0===n||null===(n=n.entities)||void 0===n?void 0:n[e];null!=i&&t._writeClipboard("string"==typeof i?{entity:i}:Pe({},i))}),Te(t,"_cutAdditional",function(e){t._copyAdditional(e),t._deleteAdditional(e)}),Te(t,"_copyMainAsTemplate",function(){if(t._config){var e,n={},i=Ie(mn);try{for(i.s();!(e=i.n()).done;){var o=e.value,r=t._config[o];void 0!==r&&(n[o]=r)}}catch(t){i.e(t)}finally{i.f()}t._writeClipboard(n)}}),Te(t,"_pasteEntity",function(){var e;if(t._config){var n=t._readClipboard();if(n){var i=[].concat(Oe(null!==(e=t._config.entities)&&void 0!==e?e:[]),[n]);t._selectedTab=i.length,t._keys.clear(),t._updateConfig({entities:i})}}}),Te(t,"_secondaryModeChanged",function(e){if(t._config){var n=e.detail.value.mode;if(n!==t._secondaryInfoMode()){var i;switch(n){case"none":i=void 0;break;case"text":i="";break;case"generic":i="last-changed";break;case"entity":i={entity:t._config.entity}}t._updateConfig({secondary_info:i})}}}),Te(t,"_secondaryTextChanged",function(e){t._updateConfig({secondary_info:e.detail.value.text})}),Te(t,"_secondaryTokenChanged",function(e){t._updateConfig({secondary_info:e.detail.value.token})}),Te(t,"_secondaryEntityChanged",function(e){var n,i=Ot(null===(n=t._config)||void 0===n?void 0:n.secondary_info)?t._config.secondary_info.format:void 0;t._updateConfig({secondary_info:t._formatFromForm("secondary",fn(e.detail.value),i)})}),t._selectedTab=0,t._entitiesExpanded=!0,t._stateIconRowsMain=[],t._stateIconRowsAdditional=new Map,t._customFormatScopes=new Set,t}return function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function");t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,writable:!0,configurable:!0}}),Object.defineProperty(t,"prototype",{writable:!1}),e&&ke(t,e)}(e,t),function(t,e,n){return e&&xe(t.prototype,e),n&&xe(t,n),Object.defineProperty(t,"prototype",{writable:!1}),t}(e,[{key:"setConfig",value:function(t){var e,n,i,o=this;this._config=t;var r,a=null!==(e=null===(n=t.entities)||void 0===n?void 0:n.length)&&void 0!==e?e:0;this._selectedTab>a&&(this._selectedTab=a),this._clipboardEntity=this._readClipboard(),this._stateIconRowsMatch(this._stateIconRowsMain,t.state_icon)||(this._stateIconRowsMain=Object.entries(null!==(r=t.state_icon)&&void 0!==r?r:{}));var s=new Map;null===(i=t.entities)||void 0===i||i.forEach(function(t,e){var n=Ot(t)?t.state_icon:void 0,i=o._stateIconRowsAdditional.get(e);i&&o._stateIconRowsMatch(i,n)?s.set(e,i):n&&s.set(e,Object.entries(n))}),this._stateIconRowsAdditional=s}},{key:"_stateIconRowsMatch",value:function(t,e){var n,i={},o=Ie(t);try{for(o.s();!(n=o.n()).done;){var r=He(n.value,2),a=r[0],s=r[1];a.trim()&&s.trim()&&(i[a.trim()]=s.trim())}}catch(t){o.e(t)}finally{o.f()}var c=null!=e?e:{},l=Object.keys(i);return l.length===Object.keys(c).length&&l.every(function(t){return i[t]===c[t]})}},{key:"_readClipboard",value:function(){try{var t=sessionStorage.getItem(pn);if(!t)return;var e=JSON.parse(t);if(e&&"object"===Re(e))return e}catch(t){}}},{key:"_writeClipboard",value:function(t){try{sessionStorage.setItem(pn,JSON.stringify(t)),this._clipboardEntity=t}catch(t){}}},{key:"_hasNativeTabs",get:function(){return!!customElements.get("ha-tab-group")&&!!customElements.get("ha-tab-group-tab")}},{key:"render",value:function(){return this.hass&&this._config?W(te||(te=Se(["\n \n ",'\n \n\n \n "])),"Entities",this._entitiesExpanded,this._onEntitiesExpandedChanged,this._renderEntitiesPanel(),"4.7.1","2026-07-23T00:32:46.387Z"):q}},{key:"_isCustomFormat",value:function(t,e){return this._customFormatScopes.has(t)||!!e&&!_e.has(e)}},{key:"_setCustomFormatScope",value:function(t,e){if(e!==this._customFormatScopes.has(t)){var n=new Set(this._customFormatScopes);e?n.add(t):n.delete(t),this._customFormatScopes=n}}},{key:"_formatToForm",value:function(t,e){return this._isCustomFormat(t,e.format)?Pe(Pe({},e),{},{format:ye}):e}},{key:"_formatFromForm",value:function(t,e,n){if(e.format===ye){this._setCustomFormatScope(t,!0);var i=Pe({},e);return void 0!==n?i.format=n:delete i.format,i}return this._setCustomFormatScope(t,!1),e}},{key:"_renderCustomFormatField",value:function(t,e,n){return this._isCustomFormat(t,e)?W(ee||(ee=Se(["\n \n "])),this.hass,{custom_format:null!=e?e:""},[{name:"custom_format",selector:{text:{}}}],function(){return"Custom format (e.g. invert, precision3)"},function(t){var e,i=(null!==(e=t.detail.value.custom_format)&&void 0!==e?e:"").trim();n(i||void 0)}):q}},{key:"_keyFor",value:function(t){return this._keys.has(t)||this._keys.set(t,"".concat(t,"-").concat(Math.random().toString(36).slice(2,10))),this._keys.get(t)}},{key:"_renderEntitiesPanel",value:function(){var t,e,n=this,i=null!==(t=null===(e=this._config)||void 0===e?void 0:e.entities)&&void 0!==t?t:[],o=Math.min(this._selectedTab,i.length);return W(ne||(ne=Se(['\n
\n
\n ','\n \n
\n\n
\n ',"\n
\n
\n "])),this._hasNativeTabs?W(ie||(ie=Se(['\n \n \n \n \n
\n
\n
\n \n \n ','\n \n ','\n \n ',"\n
\n "])),"Copy main as template",on,this._copyMainAsTemplate,"Paste as new entity",rn,!this._clipboardEntity,this._pasteEntity,this.hass,this._mainFormData(),ge,this._computeLabel,this._mainValueChanged,this._renderCustomFormatField("main",null===(t=this._config)||void 0===t?void 0:t.format,function(t){return n._updateConfig({format:t})}),this.hass,this._mainFormData(),we,this._computeLabel,this._mainValueChanged,this._renderSecondaryInfoBlock(),this._renderStateIconRows("main"),this._renderStylesBlock(null===(e=this._config)||void 0===e?void 0:e.styles,function(t){return n._mainStylesChanged(t)}))}},{key:"_renderStylesBlock",value:function(t,e){return W(ce||(ce=Se(['\n \n ','\n \n \n \n
\n
\n
\n \n \n ','\n \n ',"\n \n "])),"Move before","M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z",0===t,function(){return i._moveAdditional(t,-1)},"Move after","M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z",t===a,function(){return i._moveAdditional(t,1)},"Copy entity",on,function(){return i._copyAdditional(t)},"Cut entity","M19,3L13,9L15,11L22,4V3M12,12.5A0.5,0.5 0 0,1 11.5,12A0.5,0.5 0 0,1 12,11.5A0.5,0.5 0 0,1 12.5,12A0.5,0.5 0 0,1 12,12.5M6,20A2,2 0 0,1 4,18C4,16.89 4.9,16 6,16A2,2 0 0,1 8,18C8,19.11 7.1,20 6,20M6,8A2,2 0 0,1 4,6C4,4.89 4.9,4 6,4A2,2 0 0,1 8,6C8,7.11 7.1,8 6,8M9.64,7.64C9.87,7.14 10,6.59 10,6A4,4 0 0,0 6,2A4,4 0 0,0 2,6A4,4 0 0,0 6,10C6.59,10 7.14,9.87 7.64,9.64L10,12L7.64,14.36C7.14,14.13 6.59,14 6,14A4,4 0 0,0 2,18A4,4 0 0,0 6,22A4,4 0 0,0 10,18C10,17.41 9.87,16.86 9.64,16.36L12,14L19,21H22V20L9.64,7.64Z",function(){return i._cutAdditional(t)},"Paste from clipboard",rn,!this._clipboardEntity,this._pasteEntity,"Delete entity",nn,function(){return i._deleteAdditional(t)},this.hass,this._formatToForm("sub-".concat(t),hn(r)),Ae,this._computeLabel,function(e){return i._additionalValueChanged(e,t)},this._renderCustomFormatField("sub-".concat(t),r.format,function(e){return i._setAdditionalFormat(t,e)}),this.hass,this._formatToForm("sub-".concat(t),hn(r)),we,this._computeLabel,function(e){return i._additionalValueChanged(e,t)},this._renderStateIconRows(t),this._renderStylesBlock(r.styles,function(e){return i._additionalStylesChanged(e,t)}))}},{key:"_mainFormData",value:function(){return this._formatToForm("main",hn(Pe({show_state:!0},this._config)))}},{key:"_setAdditionalFormat",value:function(t,e){var n;if(this._config){var i=Oe(null!==(n=this._config.entities)&&void 0!==n?n:[]),o=i[t],r="string"==typeof o?{entity:o}:Pe({},o);e?r.format=e:delete r.format,i[t]=r,this._updateConfig({entities:i})}}},{key:"_secondaryInfoMode",value:function(){var t,e=null===(t=this._config)||void 0===t?void 0:t.secondary_info;return null==e?"none":"object"===Re(e)?"entity":vt.includes(e)?"generic":"text"}},{key:"_renderSecondaryInfoBlock",value:function(){var t,e=this,n=this._secondaryInfoMode(),i=null===(t=this._config)||void 0===t?void 0:t.secondary_info;return W(he||(he=Se(["\n
\n \n ","\n ","\n ","\n
\n "])),this.hass,{mode:n},an,function(){return"Mode"},this._secondaryModeChanged,"text"===n?W(fe||(fe=Se(['
\n \n \n \n
\n ',"\n
\n "])),Bt(this.config.icon_color),this._hass,n,this.renderSecondaryInfo(),!0,this.config.column?"entities-column":"entities-row",this.config.show_state_first?W(Le||(Le=bn(["","",""])),this.renderMainEntity(),this.entities.map(function(e,n){return t.renderEntity(e.stateObj,e,n)})):W(Ve||(Ve=bn(["","",""])),this.entities.map(function(e,n){return t.renderEntity(e.stateObj,e,n)}),this.renderMainEntity()))}},{key:"renderSecondaryInfo",value:function(){if(!this.config.secondary_info||"string"==typeof(t=this.config.secondary_info)&&vt.includes(t)||Ct(this.info,this.config.secondary_info,this._hass))return null;var t;if("string"==typeof this.config.secondary_info)return W(ze||(ze=bn(["",""])),this.config.secondary_info);var e=Zt(this.info,this.config.secondary_info);return W(Be||(Be=bn([""," ",""])),e,this.renderValue(this.info,this.config.secondary_info))}},{key:"renderMainEntity",value:function(){if(!1===this.config.show_state)return null;if(Ct(this.stateObj,this.config,this._hass))return this.config.default?W(We||(We=bn(['
\n ',"\n
","
\n
"])),Gt(this.config),this.renderMainHeader(),this.config.default):null;var t=this.getGestureHandlers("main",this.config.entity,this.config);return W(Ze||(Ze=bn(['\n ',"\n
","
\n
"])),Gt(this.config),null==t?void 0:t.onDown,null==t?void 0:t.onUp,null==t?void 0:t.onCancel,En,En,En,En,En,En,this.renderMainHeader(),this.renderValue(this.stateObj,this.config))}},{key:"renderEntity",value:function(t,e,n){var i;if(!t||Ct(t,e,this._hass))return e.default?W(qe||(qe=bn(['
\n ',"\n
","
\n
"])),Gt(e),t?Zt(t,e):e.name,e.default):null;var o=this.getGestureHandlers("sub-".concat(n),t.entity_id,e);return W(Je||(Je=bn(['\n ',"\n
\n ","\n
\n "])),Gt(e),null==o?void 0:o.onDown,null==o?void 0:o.onUp,null==o?void 0:o.onCancel,En,En,En,En,En,En,null!==(i=Zt(t,e))&&void 0!==i?i:this.headerPlaceholder(),e.icon||Ot(e.state_icon)?this.renderIcon(t,e):this.renderValue(t,e))}},{key:"renderMainHeader",value:function(){var t,e=null!==(t=this.config.state_header)&&void 0!==t?t:this.headerPlaceholder();return e?W(Ge||(Ge=bn(["",""])),e):null}},{key:"headerPlaceholder",value:function(){return void 0!==this.config.state_header||this.entities.some(function(t){return!1!==t.name&&(t.name||t.entity)})?" ":null}},{key:"renderValue",value:function(t,e){if(!0===e.toggle)return this.renderToggle(t,e);var n=e.attribute&&[ft,pt].includes(e.attribute);if(e.format&&mt.includes(e.format)){var i,o=n?t[e.attribute.replace("-","_")]:e.attribute?null!==(i=t.attributes[e.attribute])&&void 0!==i?i:t[e.attribute]:t.state,r=new Date(o);return r instanceof Date&&!isNaN(r.getTime())?W(Ke||(Ke=bn([""])),this._hass,r,e.format):o}return n?W(Ye||(Ye=bn([""])),this._hass,t[e.attribute.replace("-","_")]):Jt(this._hass,t,e)}},{key:"renderToggle",value:function(t,e){var n,i=this,o=null===(n=e.tap_action)||void 0===n?void 0:n.confirmation;if(!o)return W(Qe||(Qe=bn([''])),t,this._hass);var r={handleEvent:function(n){var r,a;n.stopPropagation(),n.preventDefault();var s=Ot(o)&&(null===(r=o.exemptions)||void 0===r?void 0:r.some(function(t){var e;return t.user===(null===(e=i._hass.user)||void 0===e?void 0:e.id)})),c=Ot(o)&&o.text||"Are you sure you want to toggle ".concat(null!==(a=Zt(t,e))&&void 0!==a?a:t.entity_id,"?");(s||confirm(c))&&i._hass.callService("homeassistant","toggle",{entity_id:t.entity_id})},capture:!0};return W(Xe||(Xe=bn(["\n '])),r,t,this._hass)}},{key:"renderIcon",value:function(t,e){var n,i=null!==(n=Wt(t,e))&&void 0!==n?n:!0===e.icon?t.attributes.icon||null:e.icon;return W(tn||(tn=bn([''])),Bt(e.icon_color),this._hass,t,i,e.state_color)}},{key:"renderWarning",value:function(){return W(en||(en=bn(["\n ","\n "])),this._hass.localize("ui.panel.lovelace.warning.entity_not_found","entity",this.config.entity))}},{key:"getGestureHandlers",value:function(t,e,n){var i,o,r,a,s,c,l,u=this;return!0===n.toggle?null:(this._actionHandlers.has(t)||this._actionHandlers.set(t,(i=function(t,i){return u.dispatchAction(e,n,t,i)},o={hasHold:!!n.hold_action,hasDoubleTap:!!n.double_tap_action},s=o.hasHold,c=o.hasDoubleTap,l=!1,{onDown:function(){l=!1,s&&(r=setTimeout(function(){l=!0},500))},onUp:function(){if(clearTimeout(r),l)return l=!1,void i(!0,!1);c?a?(clearTimeout(a),a=void 0,i(!1,!0)):a=setTimeout(function(){a=void 0,i(!1,!1)},250):i(!1,!1)},onCancel:function(){clearTimeout(r)}})),this._actionHandlers.get(t))}},{key:"dispatchAction",value:function(t,e,n,i){var o,r=i?e.double_tap_action:n?e.hold_action:null!==(o=e.tap_action)&&void 0!==o?o:{action:"more-info"};if(r&&"none"!==r.action){var a=i?"double_tap":n?"hold":"tap";xt(this,"hass-action",{config:wn({entity:r.entity||t},"".concat(a,"_action"),r),action:a})}}}],[{key:"getConfigElement",value:function(){return document.createElement("multiple-entity-row-editor")}},{key:"getStubConfig",value:function(t,e){var n,i,o=null!==(n=null!==(i=null==e?void 0:e.find(function(t){return t.startsWith("sensor.")}))&&void 0!==i?i:null==e?void 0:e[0])&&void 0!==n?n:"";return{entity:o}}},{key:"properties",get:function(){return{_hass:Object,config:Object,stateObj:Object}}},{key:"styles",get:function(){return r(Ft||(Ft=function(t,e){return e||(e=t.slice(0)),Object.freeze(Object.defineProperties(t,{raw:{value:Object.freeze(e)}}))}(["\n .icon-small {\n width: auto;\n }\n .entity {\n text-align: center;\n cursor: pointer;\n }\n .entity span {\n font-size: 10px;\n color: var(--multiple-entity-row-header-color, var(--secondary-text-color));\n }\n .entities-row {\n flex-direction: row;\n display: inline-flex;\n justify-content: space-between;\n align-items: center;\n }\n .entities-row .entity {\n margin-right: 16px;\n }\n .entities-row .entity:last-of-type {\n margin-right: 0;\n }\n .entities-column {\n flex-direction: column;\n display: flex;\n align-items: flex-end;\n justify-content: space-evenly;\n }\n .entities-column .entity div {\n display: inline-block;\n vertical-align: middle;\n }\n"])))}}])}(ut);customElements.define("multiple-entity-row",Cn),window.customCards=window.customCards||[],window.customCards.push({type:"multiple-entity-row",name:"Multiple Entity Row",description:"Show multiple entity states and attributes on a single entity row"})})(); \ No newline at end of file diff --git a/www/community/lovelace-multiple-entity-row/multiple-entity-row.js.gz b/www/community/lovelace-multiple-entity-row/multiple-entity-row.js.gz index 4818e2e4f1df8171e598c72362f89fee75a276ca..fbbe1641572b046540550a9a48b41b7d430ed411 100644 GIT binary patch delta 19717 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 Y3?#*}Aa;e09&iHvzd!o>EY~Li00Y%L*8l(j delta 19354 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