diff --git a/.HA_VERSION b/.HA_VERSION index 3f77693..2398eb2 100644 --- a/.HA_VERSION +++ b/.HA_VERSION @@ -1 +1 @@ -2026.7.1 \ No newline at end of file +2026.7.2 \ No newline at end of file diff --git a/.cache/brands/integrations/hacs/dark_logo.png b/.cache/brands/integrations/hacs/dark_logo.png new file mode 100644 index 0000000..cffa310 Binary files /dev/null and b/.cache/brands/integrations/hacs/dark_logo.png differ diff --git a/.cache/brands/integrations/homekit_controller/dark_logo.png b/.cache/brands/integrations/homekit_controller/dark_logo.png new file mode 100644 index 0000000..defa5d0 Binary files /dev/null and b/.cache/brands/integrations/homekit_controller/dark_logo.png differ diff --git a/.cache/brands/integrations/matter/dark_logo.png b/.cache/brands/integrations/matter/dark_logo.png new file mode 100644 index 0000000..0e69c0b Binary files /dev/null and b/.cache/brands/integrations/matter/dark_logo.png differ diff --git a/.cache/brands/integrations/matter/logo.png b/.cache/brands/integrations/matter/logo.png new file mode 100644 index 0000000..38cce00 Binary files /dev/null and b/.cache/brands/integrations/matter/logo.png differ diff --git a/.cache/brands/integrations/mqtt/dark_logo.png b/.cache/brands/integrations/mqtt/dark_logo.png new file mode 100644 index 0000000..b1e1d89 Binary files /dev/null and b/.cache/brands/integrations/mqtt/dark_logo.png differ diff --git a/.cache/brands/integrations/nest/dark_logo.png b/.cache/brands/integrations/nest/dark_logo.png new file mode 100644 index 0000000..20507c6 Binary files /dev/null and b/.cache/brands/integrations/nest/dark_logo.png differ diff --git a/.cache/brands/integrations/wiz/dark_logo.png b/.cache/brands/integrations/wiz/dark_logo.png new file mode 100644 index 0000000..296848b Binary files /dev/null and b/.cache/brands/integrations/wiz/dark_logo.png differ diff --git a/.cache/brands/integrations/wiz/logo.png b/.cache/brands/integrations/wiz/logo.png new file mode 100644 index 0000000..79ee3ae Binary files /dev/null and b/.cache/brands/integrations/wiz/logo.png differ diff --git a/.ha_run.lock b/.ha_run.lock index e5a67a4..0ddadf7 100644 --- a/.ha_run.lock +++ b/.ha_run.lock @@ -1 +1 @@ -{"pid": 71, "version": 1, "ha_version": "2026.7.1", "start_ts": 1783610735.5698383} \ No newline at end of file +{"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784075696.7721176} \ No newline at end of file diff --git a/automations.yaml b/automations.yaml index c3628ec..608e423 100644 --- a/automations.yaml +++ b/automations.yaml @@ -278,29 +278,68 @@ milliseconds: 0 mode: single - id: '1781024962776' - alias: 'Office: Turn On Lights on Motion then Door Open' - description: Triggers when office door opens, but only if living room motion happened - in the last 30 seconds and light is off. + 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. triggers: - trigger: state entity_id: binary_sensor.office_door_contact to: 'on' - conditions: - - condition: time - after: '10:00:00' - before: '20:00:00' - - condition: state - entity_id: light.playroom_light - state: 'off' - - condition: template - value_template: '{{ (now() - states.binary_sensor.basement.last_changed).total_seconds() - < 30 }}' + 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: - - action: light.turn_on - target: - entity_id: light.playroom_light - data: {} - mode: single + - choose: + - conditions: + - condition: time + after: '10:00:00' + before: '20:00:00' + - condition: state + entity_id: light.playroom_light + 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 }}' + sequence: + - action: light.turn_on + target: + entity_id: light.playroom_light + data: {} + - conditions: + - condition: trigger + id: turn_off_logic + - condition: state + entity_id: light.playroom_light + state: 'on' + sequence: + - action: light.turn_off + target: + entity_id: light.playroom_light + data: {} + - action: notify.notify + data: + message: Office Lights Turned Off due to inactivity + title: ⚡️ Office Lights + mode: restart - id: '1781034724187' alias: 'Office: Turn Off Lights on Exit' description: Turns off office lights 2 minutes after the door closes, but only if @@ -323,6 +362,11 @@ target: entity_id: light.playroom_light data: {} + - action: notify.notify + metadata: {} + data: + message: Office Lights Turned Off + title: ⚡️ Office Lights mode: single - id: '1781102033733' alias: Water Leak Detected @@ -491,18 +535,6 @@ data: notification_id: dishwasher_filter_reminder mode: single -- id: '1781285278462' - alias: 'Tablet: Dim Screen at Night v2' - description: Shuts the tablet screen off completely at night - triggers: - - at: 01:00:00 - trigger: time - actions: - - action: notify.mobile_app_sm_t387w - data: - message: command_screen_off_timeout - data: - command: 1000 - id: '1781285326960' alias: 'Tablet: Restore Screen in Morning v2' description: Wakes the tablet and restores brightness/timeout @@ -733,10 +765,6 @@ data: title: ☕ Coffee Time message: Someone is making coffee. - - action: notify.persistent_notification - data: - title: ☕ Coffee Time - message: Someone is making coffee. - conditions: - condition: trigger id: coffee_finished @@ -775,7 +803,8 @@ alias: 'Notification: Pool Sensor Low Battery' description: Alert when the pool temperature sensor battery drops below 15% triggers: - - entity_id: sensor.pool_temperature_battery + - entity_id: + - sensor.pool_battery below: 15 trigger: numeric_state conditions: [] @@ -803,8 +832,8 @@ - condition: or conditions: - condition: template - value_template: '{{ states(''sensor.google_travel_time_work_work'') | float(0) - > 35 }}' + value_template: '{{ states(''sensor.google_travel_time_work'') | float(0) > + 35 }}' - condition: template value_template: '{{ states(''sensor.waze_travel_time'') | float(0) > 35 }}' actions: @@ -816,17 +845,18 @@ - action: notify.notify data: title: "\U0001F6A8 Commute Delay Alert!" - message: "Traffic is heavier than usual this morning. \nGoogle Maps: {{ states('sensor.google_travel_time_work_work') - }} mins. Waze: {{ states('sensor.waze_to_work_travel_time') }} mins." + message: "Traffic to Work is heavier than usual this morning. \n- Google Maps: + {{ states('sensor.google_travel_time_work') }} mins.\n- Waze: {{ states('sensor.waze_to_work_travel_time') + }} mins." data: image: /local/commute_snapshot.jpg enabled: false - action: notify.persistent_notification data: title: "\U0001F6A8 Commute Delay Alert!" - message: "Traffic is heavier than usual this morning. \nGoogle Maps: {{ states('sensor.google_travel_time_work_work') - }} mins. Waze: {{ states('sensor.waze_to_work_travel_time') }} mins.\n![Commute - Map](/local/commute_snapshot.jpg)" + message: "Traffic to Work is heavier than usual this morning. \n- Google Maps: + {{ states('sensor.google_travel_time_work') }} mins. \n- Waze: {{ states('sensor.waze_to_work_travel_time') + }} mins.\n![Commute Map](/local/commute_snapshot.jpg)" mode: single - *id001 - *id001 @@ -866,7 +896,7 @@ - condition: or conditions: - condition: numeric_state - entity_id: sensor.google_travel_time_work_work + entity_id: sensor.google_travel_time_work above: 35 - condition: numeric_state entity_id: sensor.waze_travel_time @@ -875,40 +905,21 @@ - action: notify.notify data: title: "\U0001F697 Morning Commute" - message: "Google: {{ states('sensor.google_travel_time_work_work') }} min\nWaze: - {{ states('sensor.waze_to_work_travel_time') }} min\n{% set g = states('sensor.google_travel_time_work_work') + message: "Google: {{ states('sensor.google_travel_time_work') }} min\nWaze: + {{ states('sensor.waze_to_work_travel_time') }} min\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: '1783049020714' - alias: Aqara-Magic-Cube-T1-Pro-(CTP-R01) - Franco - description: '' - use_blueprint: - path: SirGoodenough/Zigbee2MQTT-Aqara-Magic-Cube-T1-Pro-CTP-R01-Xiaomi-Lumi.yaml - input: - topic: zigbee2mqtt/Ziggy - action_slide_face_1: - - action: light.toggle - metadata: {} - target: - device_id: 2f8c5ab2ca0d4be31919fba6df365813 - data: - brightness_pct: 25 - action_doubletap_face_1: [] - action_rotate_cw_face_1: - - action: script.cube_dimmer_control - metadata: {} - data: - light: light.playroom_light - angle: -20 - action_rotate_ccw_face_1: [] - action_flipped90_face_1: [] - 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: @@ -947,7 +958,7 @@ - camera_motion - camera_sound - camera_person - enabled: true + enabled: false conditions: - condition: template value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', @@ -961,7 +972,7 @@ - delay: hours: 0 minutes: 0 - seconds: 5 + seconds: 3 milliseconds: 0 - action: homeassistant.update_entity target: @@ -990,8 +1001,9 @@ data: task_name: Blink Shed Camera Analysis entity_id: ai_task.google_ai_task - instructions: Describe what you see in this image in brief. Focus on any people, - objects, animals or activities. Text needs to be max 240 characters. + 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 @@ -1229,6 +1241,12 @@ - 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'', @@ -1242,7 +1260,7 @@ - delay: hours: 0 minutes: 0 - seconds: 5 + seconds: 3 milliseconds: 0 - action: homeassistant.update_entity target: @@ -1271,8 +1289,9 @@ data: task_name: Nest Front Camera Analysis entity_id: ai_task.google_ai_task - instructions: Describe what you see in this image in brief. Focus on any people, - objects, animals or activities. Text needs to be max 240 characters. + 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 @@ -1436,3 +1455,905 @@ notification_id: '{{ notification_id }}' mode: queued max: 10 +- id: '1783628026617' + alias: 'Garage Door: Open Notification' + description: '' + triggers: + - trigger: state + entity_id: + - binary_sensor.garage_door_contact + to: + - 'on' + conditions: [] + actions: + - action: notify.notify + metadata: {} + data: + message: 'Garage Door is Opened ' + title: Garage Door + mode: single +- id: '1783628088925' + alias: 'Garage Door: Closed Notification' + description: '' + triggers: + - trigger: state + entity_id: + - binary_sensor.garage_door_contact + to: + - 'off' + from: + - 'on' + conditions: [] + actions: + - action: notify.notify + metadata: {} + data: + message: Garage Door Closed + title: Garage Door + mode: single +- id: '1783643893067' + alias: Blink Shed Camera Motion Triggered + description: Triggers local actions when the front Blink camera senses motion + triggers: + - entity_id: input_boolean.blink_shed_camera_motion + to: 'on' + trigger: state + conditions: [] + actions: + - data: + title: Shed Door Alert + message: Motion detected on the Shed Blink camera! + action: notify.persistent_notification + mode: single +- id: '1783647055563' + alias: Motion Backyard - AI Description + description: Takes a snapshot when the Nest camera detects motion, a person, an + animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent + notification. + triggers: + - trigger: state + entity_id: + - input_boolean.blink_backyard_camera_motion + to: + - 'on' + enabled: true + - trigger: event + event_type: state_changed + event_data: + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: state + entity_id: event.living_room_living_room_camera_motion + enabled: false + - trigger: state + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: event.received + target: + entity_id: event.entryway_entryway_camera_motion + options: + event_type: + - camera_motion + - camera_person + - camera_sound + enabled: false + - trigger: event.received + target: + device_id: 290f56e5a655e3af82cbf3cf9fd22406 + options: + event_type: + - camera_motion + - camera_person + - camera_sound + - ring + enabled: false + - trigger: event.received + target: + device_id: 679d52e0d16cf48648d11dd024b961f8 + options: + event_type: + - camera_motion + - camera_sound + - camera_person + enabled: false + conditions: + - condition: template + value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', + ''person'', ''animal'', ''chime''] }}' + enabled: false + actions: + - action: blink.trigger_camera + target: + entity_id: camera.backyard + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 3 + milliseconds: 0 + - action: homeassistant.update_entity + target: + entity_id: camera.backyard + - variables: + snapshot_filename: blink_backyard_{{ now().strftime('%Y%m%d_%H%M%S') }}.jpg + - variables: + notification_id: blink_backyard_{{ now().strftime('%Y%m%d_%H%M%S_%f') }} + - action: camera.snapshot + target: + entity_id: camera.backyard + data: + filename: /media/{{ snapshot_filename }} + - action: camera.snapshot + target: + entity_id: camera.backyard + 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: Blink Backyard Camera Analysis + entity_id: ai_task.google_ai_task + instructions: Describe what you see in this image in brief, but make it a little + humorous. Focus on any people, objects, animals or activities. Text needs + to be max 240 characters. + attachments: + - media_content_id: media-source://media_source/local/{{ snapshot_filename }} + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + continue_on_error: true + data: + title: Motion Detected - Backyard + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: persistent_notification.create + data: + title: Motion Detected - Backyard + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/{{ snapshot_filename }})' + notification_id: '{{ notification_id }}' + mode: queued + max: 10 +- id: '1783649304797' + alias: Motion Inside Shed - AI Description + description: Takes a snapshot when the Nest camera detects motion, a person, an + animal, or a doorbell press, analyzes it with AI, and sends a mobile and persistent + notification. + triggers: + - trigger: state + entity_id: + - input_boolean.blink_inside_shed_camera_motion + to: + - 'on' + enabled: true + - trigger: event + event_type: state_changed + event_data: + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: state + entity_id: event.living_room_living_room_camera_motion + enabled: false + - trigger: state + entity_id: event.front_door_front_door_motion + enabled: false + - trigger: event.received + target: + entity_id: event.entryway_entryway_camera_motion + options: + event_type: + - camera_motion + - camera_person + - camera_sound + enabled: false + - trigger: event.received + target: + device_id: 290f56e5a655e3af82cbf3cf9fd22406 + options: + event_type: + - camera_motion + - camera_person + - camera_sound + - ring + enabled: false + - trigger: event.received + target: + device_id: 679d52e0d16cf48648d11dd024b961f8 + options: + event_type: + - camera_motion + - camera_sound + - camera_person + enabled: false + conditions: + - condition: template + value_template: '{{ trigger.event.data.new_state.attributes.event_type in [''motion'', + ''person'', ''animal'', ''chime''] }}' + enabled: false + actions: + - action: blink.trigger_camera + target: + entity_id: camera.backyard_tree + data: {} + - delay: + hours: 0 + minutes: 0 + seconds: 3 + milliseconds: 0 + - action: homeassistant.update_entity + target: + entity_id: camera.backyard_tree + - 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: 4 + milliseconds: 0 + - action: ai_task.generate_data + continue_on_error: true + data: + task_name: Blink InShed Camera Analysis + entity_id: ai_task.google_ai_task + instructions: Describe what you see in this image in brief, but make it a little + humorous. Focus on any people, objects, animals or activities. Text needs + to be max 240 characters. + attachments: + - media_content_id: media-source://media_source/local/{{ snapshot_filename }} + media_content_type: image/jpeg + response_variable: ai_profile + - action: notify.notify + continue_on_error: true + data: + title: Motion Detected - Backyard Inside Shed + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }}' + - action: persistent_notification.create + data: + title: Motion Detected - Backyard Inside Shed + message: '{{ ai_profile.data if ai_profile is defined and ''data'' in ai_profile + else ''Motion detected, but the AI Task integration failed to return a response.'' + }} + + ![Snapshot](/local/{{ snapshot_filename }})' + notification_id: '{{ notification_id }}' + mode: queued + max: 10 +- id: '1783736251030' + alias: 'Office: Teams Webhook Busy Light' + description: Triggers light based on Windows 11 Teams log file status changes + triggers: + - webhook_id: office_teams_meeting_status_webhook_secret_99 + allowed_methods: + - POST + local_only: false + trigger: webhook + conditions: [] + actions: + - choose: + - conditions: + - condition: template + value_template: '{{ trigger.json.status in [''busy'', ''do not disturb''] + }}' + sequence: + - target: + entity_id: light.thirdreality_night_light + data: + brightness_pct: 100 + rgb_color: + - 255 + - 0 + - 0 + action: light.turn_on + - conditions: + - condition: template + value_template: '{{ trigger.json.status in [''available'', ''offline'', ''away''] + }}' + sequence: + - target: + entity_id: light.thirdreality_night_light + data: + brightness_pct: 20 + rgb_color: + - 255 + - 255 + - 255 + action: light.turn_on + mode: single +- id: '1783737753660' + alias: 'Office: Manual Meeting Status Busy Light - Working Hours' + description: 'Work hours: Toggle controls Red/Green (Motion ignored). After hours: + Pure motion night light (Toggle ignored).' + triggers: + - id: status_changed + entity_id: input_boolean.in_a_meeting + trigger: state + - id: motion_detected + entity_id: binary_sensor.night_light_occupancy + trigger: state + to: 'on' + - id: motion_cleared + entity_id: binary_sensor.night_light_occupancy + trigger: state + to: 'off' + conditions: [] + actions: + - choose: + - conditions: + - condition: trigger + id: status_changed + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'on' + - condition: 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: 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: 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: 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: single +- id: '1783738120074' + alias: 'Office: Webhook Toggle Meeting Status' + description: Toggles the meeting status boolean when the secret webhook URL is opened + triggers: + - webhook_id: toggle_my_meeting_status_secret_abc123 + allowed_methods: + - POST + - GET + local_only: false + trigger: webhook + conditions: [] + actions: + - target: + entity_id: input_boolean.in_a_meeting + action: input_boolean.toggle + mode: single +- id: '1783806626578' + alias: 'Office: TEST Meeting Status Lights' + description: 'DEDICATED TESTING: Forces Red/Green colors anytime the switch is toggled. + Disable production automation before using.' + triggers: + - entity_id: input_boolean.in_a_meeting + trigger: state + conditions: [] + actions: + - choose: + - conditions: + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'on' + sequence: + - target: + entity_id: light.night_light + data: + brightness_pct: 100 + rgb_color: + - 255 + - 0 + - 0 + action: light.turn_on + - conditions: + - condition: state + entity_id: input_boolean.in_a_meeting + state: 'off' + sequence: + - target: + entity_id: light.night_light + data: + brightness_pct: 20 + rgb_color: + - 5 + - 245 + - 45 + action: light.turn_on + mode: single +- id: '1783824363227' + alias: Downstairs Bathroom Light Control + description: Turn on bathroom switch when occupied, turn off after 30 minutes of + no occupancy. + triggers: + - entity_id: binary_sensor.downstairs_bathroom_occupancy + to: 'on' + id: motion_detected + trigger: state + - entity_id: binary_sensor.downstairs_bathroom_occupancy + to: 'off' + for: + hours: 0 + minutes: 30 + seconds: 0 + id: motion_clear + trigger: state + conditions: [] + actions: + - choose: + - conditions: + - condition: trigger + id: motion_detected + sequence: + - target: + entity_id: switch.downstairs_bathroom_switch + action: switch.turn_on + - conditions: + - condition: trigger + id: motion_clear + sequence: + - target: + entity_id: switch.downstairs_bathroom_switch + action: switch.turn_off + mode: restart +- id: '1783896963375' + alias: 'Office: Smart Button Toggles Meeting Status - IKEA BILRESA' + description: Toggles the meeting input boolean when the smart button is pressed + triggers: + - trigger: event + event_type: state_changed + event_data: + entity_id: event.office_button_button_1 + conditions: [] + actions: + - target: + entity_id: input_boolean.in_a_meeting + action: input_boolean.toggle + mode: single +- id: '1783901121760' + alias: Ikea BILRESA scroll wheel - Bedroom Switch + description: '' + use_blueprint: + path: BoGu5/kea BILRESA scroll wheel (sensors for scroll, events for press).yaml + input: + remote: c7289314a586fc3739316abfeee0cdf5 + click_action_ch1: + - action: light.toggle + metadata: {} + target: + entity_id: light.bedroom_lights + data: {} + scroll_wheel_target_ch1: + - light.bedroom_lights + scroll_wheel_mode_ch1: 'lights: color temp only' +- id: '1783904189553' + alias: IKEA Bilresa Scrollwheel - Bedroom Light Control V1 - Atomic + description: '' + use_blueprint: + path: stone13/ikea_bilresa_atomic_v1.yaml + input: + light_entity: light.bedroom_lights + sensor_1: sensor.bilresa_scroll_wheel_current_switch_position_1 + sensor_2: sensor.bilresa_scroll_wheel_current_switch_position_2 + sensor_4: sensor.bilresa_scroll_wheel_current_switch_position_4 + sensor_5: sensor.bilresa_scroll_wheel_current_switch_position_5 + sensor_7: sensor.bilresa_scroll_wheel_current_switch_position_7 + sensor_8: sensor.bilresa_scroll_wheel_current_switch_position_8 + event_taste_1: event.bedroom_switch_button_3 + event_taste_2: event.bedroom_switch_button_6 + event_taste_3: event.bedroom_switch_button_9 + scroll_logic_4_5: brightness + scroll_logic_7_8: brightness +- id: '1783905541495' + alias: Ikea Bilresa Switch Bedroom Light Control + description: '' + use_blueprint: + path: jhol-byte/Ikea_bilresa_scroll_wheel.yaml + input: + remote: c7289314a586fc3739316abfeee0cdf5 + click_action_ch1: + - action: light.toggle + metadata: {} + target: + entity_id: light.bedroom_lights + data: {} + scroll_wheel_target_ch1: + - light.bedroom_lights +- id: '1783906561328' + alias: IKEA BILRESA - Light Controller for PLAYROOM TEST2 + description: '' + use_blueprint: + path: SvKRO/ha-blueprint-ikea-bilresa-scrollwheel_en.yaml + input: + remote_device: 353fb9eb9f1b2a138e468b02fb9fbf06 + input_click_1: + - action: light.toggle + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: {} + input_light_1: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + input_mode_1: brightness + input_long_1: [] + input_double_1: [] + input_sens_1: 15 + input_transition_1: 34 + input_light_2: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + input_sens_2: 15 + input_light_3: + entity_id: light.bedroom_lights + input_sens_3: 10 + input_transition_2: 0 +- id: '1783909375854' + alias: 'Office: Playroom Light Toggle - IKEA BILRESA' + description: Toggles the meeting input boolean when the smart button is pressed + triggers: + - trigger: event + event_type: state_changed + event_data: + entity_id: event.office_button_button_2 + conditions: [] + actions: + - action: light.toggle + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: {} + mode: single +- id: '1783915438772' + alias: IKEA Bilresa Scrollwheel TEST FRANCO - Light Control + description: '' + use_blueprint: + path: thetestspecimen/ikea-bilresa-scroll-wheel.yaml + input: + event_button_1: event.bilresa_switch_button_3 + event_button_2: event.bilresa_switch_button_6 + event_button_3: event.bilresa_switch_button_9 + sensor_1: sensor.bilresa_scroll_wheel_current_switch_position_1_2 + sensor_2: sensor.bilresa_scroll_wheel_current_switch_position_2_2 + sensor_4: sensor.bilresa_scroll_wheel_current_switch_position_4_2 + sensor_5: sensor.bilresa_scroll_wheel_current_switch_position_5_2 + sensor_7: sensor.bilresa_scroll_wheel_current_switch_position_7_2 + sensor_8: sensor.bilresa_scroll_wheel_current_switch_position_8_2 + global_light: + - light.living_room_living_room_home_living_room +- id: '1783946723701' + alias: IKEA E2490 BILRESA Scroll Wheel (Zigbee2MQTT + ZHA) + description: '' + use_blueprint: + path: mcinnes01/ikea-e2490-bilresa-scroll-wheel.yaml + input: + controller_device: e36f0c170f2b917e78f3d33a34b5bb35 + scroll_target: light.playroom_light +- id: '1783960311392' + alias: Ikea_bilresa_scroll_wheel TEST + description: '' + use_blueprint: + path: thetestspecimen/ikea-bilresa-scroll-wheel.yaml + input: + event_button_1: event.bilresa_scroll_wheel_button_3 + event_button_2: event.bedroom_switch_button_6 + event_button_3: event.bilresa_scroll_wheel_button_9 + sensor_1: sensor.bilresa_scroll_wheel_current_switch_position_1 + sensor_2: sensor.bilresa_scroll_wheel_current_switch_position_2 + sensor_4: sensor.bilresa_scroll_wheel_current_switch_position_4 + sensor_5: sensor.bilresa_scroll_wheel_current_switch_position_5 + sensor_7: sensor.bilresa_scroll_wheel_current_switch_position_7 + sensor_8: sensor.bilresa_scroll_wheel_current_switch_position_8 + global_light: + - light.playroom_light + light_z1: [] +- id: '1783960650360' + alias: 'Bedroom: Ikea Bilresa Scrollwheel - Light Control' + description: '' + use_blueprint: + path: jhol-byte/Ikea_bilresa_scroll_wheel.yaml + input: + remote: c7289314a586fc3739316abfeee0cdf5 + click_action_ch1: + - action: light.toggle + metadata: {} + target: + entity_id: light.bedroom_lights + data: {} + scroll_wheel_target_ch1: + - light.bedroom_lights + scroll_wheel_mode_ext_ch1: instant + long_click_action_ch1: + - action: light.turn_on + metadata: {} + target: + entity_id: light.bedroom_lights + data: + brightness_pct: 80 + double_click_action_ch1: + - action: light.toggle + metadata: {} + target: + entity_id: light.bedroom_lights + data: + brightness_pct: 25 +- id: '1783962246983' + alias: 'OFFICE: IKEA BILRESA E2489 Dual Button (Matter) - In Meeting and Playroom + Lights' + description: Ikea BILRESA E2489 Dual Button controls in Meeting toggle, and playroom + lights + use_blueprint: + path: censay/ikea-bilresa-e2489-matter-smart-button.yaml + input: + target_device: 0c00a80e3106a9d7089f44c4781e0187 + button1_event: event.office_button_button_1 + button2_event: event.office_button_button_2 + button1_single: + - action: input_boolean.toggle + metadata: {} + target: + entity_id: input_boolean.in_a_meeting + data: {} + button2_single: + - action: light.toggle + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: + brightness_pct: 35 + button2_double: + - action: light.turn_on + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: + brightness_pct: 80 +- id: '1783972932942' + alias: 'Notification: Office Meeting Status' + description: Sends a notification when entering or leaving a meeting + triggers: + - entity_id: input_boolean.in_a_meeting + to: 'on' + id: meeting_started + trigger: state + - entity_id: input_boolean.in_a_meeting + to: 'off' + id: meeting_ended + trigger: state + conditions: [] + actions: + - choose: + - 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" + data: + push: + sound: critical + - conditions: + - condition: trigger + id: meeting_ended + sequence: + - action: notify.notify + data: + title: Meeting Ended + message: "Your status is now set to: \U0001F7E2 AVAILABLE" +- id: '1784037118280' + alias: 'Blink Safeguard: Auto Reset Stuck Toggles' + description: Forces stuck Blink helper toggles to turn off after 2 minutes and logs + the event + triggers: + - trigger: state + entity_id: + - input_boolean.blink_backyard_camera_motion + - input_boolean.blink_front_camera_motion + - input_boolean.blink_inside_shed_camera_motion + - input_boolean.blink_shed_camera_motion + to: 'on' + for: + minutes: 2 + conditions: [] + actions: + - action: logbook.log + data: + name: Blink Safeguard + message: Alexa failed to clear motion. Forcing auto-reset. + entity_id: '{{ trigger.entity_id }}' + - action: input_boolean.turn_off + target: + entity_id: '{{ trigger.entity_id }}' + mode: parallel +- id: '1784070582456' + alias: 'Office: Lamp and lights control with IKEA Scroll Wheel' + description: '' + use_blueprint: + path: jhol-byte/Ikea_bilresa_scroll_wheel.yaml + input: + remote: c9c7e25e54eb6ac497a6791fe9cf70fe + click_action_ch1: + - action: light.toggle + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: {} + scroll_wheel_target_ch1: + - light.playroom_light + scroll_wheel_mode_ext_ch1: instant + long_click_action_ch1: + - action: light.turn_on + metadata: {} + target: + entity_id: light.playroom_light + data: + brightness_step_pct: 30 + double_click_action_ch1: + - action: input_boolean.toggle + metadata: {} + target: + entity_id: input_boolean.meeting_lamp_override + data: {} + click_action_ch2: + - action: light.toggle + metadata: {} + target: + entity_id: light.office_lamp + data: + color_temp_kelvin: 5564 + scroll_wheel_target_ch2: + - light.office_lamp + scroll_wheel_mode_ext_ch2: instant + double_click_action_ch2: + - action: input_boolean.toggle + metadata: {} + target: + entity_id: input_boolean.meeting_lamp_override + data: {} + scroll_wheel_mode_ch2: 'lights: dim' + on_hold_action_ch2: + - action: light.turn_on + metadata: {} + target: + entity_id: light.office_lamp + data: + color_temp_kelvin: 5765 + triple_click_action_ch2: + - action: light.turn_on + metadata: {} + target: + entity_id: light.office_lamp + data: + color_temp_kelvin: 2348 + click_action_ch3: + - action: light.toggle + metadata: {} + target: + entity_id: light.office_lamp + data: {} + - action: light.toggle + metadata: {} + target: + device_id: 2f8c5ab2ca0d4be31919fba6df365813 + data: {} + scroll_wheel_target_ch3: + - light.office_lamp + - light.playroom_light + scroll_wheel_mode_ext_ch3: instant +- id: '1784073759556' + alias: 'Office: Desk Lamp Meeting Alert' + description: Flashes the desk lamp color temperature for a few seconds when a meeting + status changes, unless the override helper is active. + triggers: + - entity_id: input_boolean.in_a_meeting + trigger: state + conditions: + - condition: state + entity_id: input_boolean.meeting_lamp_override + state: 'off' + actions: + - action: scene.create + data: + scene_id: desk_lamp_before_alert + snapshot_entities: + - light.office_lamp + - action: light.turn_on + target: + entity_id: light.office_lamp + data: + brightness_pct: 100 + color_temp_kelvin: 2261 + - delay: 00:00:03 + - action: scene.turn_on + target: + entity_id: scene.desk_lamp_before_alert + data: {} + mode: restart diff --git a/blueprints/automation/BoGu5/kea BILRESA scroll wheel (sensors for scroll, events for press).yaml b/blueprints/automation/BoGu5/kea BILRESA scroll wheel (sensors for scroll, events for press).yaml new file mode 100644 index 0000000..fba7c40 --- /dev/null +++ b/blueprints/automation/BoGu5/kea BILRESA scroll wheel (sensors for scroll, events for press).yaml @@ -0,0 +1,484 @@ +blueprint: + name: Ikea BILRESA scroll wheel (sensors for scroll, events for press) + description: "Ikea BILRESA blueprint met 3 kanalen.\n\n- Scroll links/rechts gebruikt + sensor.*current_switch_position_* (pulse 0/1).\n Alleen rising edge (0->1) triggert, + dus per klik exact 1 stap en direct.\n- Druk op scrollwiel blijft event.* gebruiken + zodat single/double/long/hold blijft werken.\n" + domain: automation + input: + remote: + name: Remote + description: Ikea BILRESA remote control device + selector: + device: + multiple: false + channel1_section: + name: Kanaal 1 + collapsed: true + input: + click_action_ch1: + name: 'Kanaal 1: click actie' + default: [] + selector: + action: {} + double_click_action_ch1: + name: 'Kanaal 1: double click actie' + default: [] + selector: + action: {} + long_click_action_ch1: + name: 'Kanaal 1: long press actie' + default: [] + selector: + action: {} + on_hold_action_ch1: + name: 'Kanaal 1: on-hold actie' + description: Herhaalt totdat losgelaten, zie on_hold_delay + default: [] + selector: + action: {} + scroll_wheel_target_ch1: + name: 'Kanaal 1: scroll target(s)' + description: Light of media_player die je met het wiel wilt bedienen + default: [] + selector: + entity: + multiple: true + filter: + - domain: + - light + - media_player + reorder: false + scroll_wheel_mode_ch1: + name: 'Kanaal 1: scroll mode' + default: 'lights: dim' + selector: + select: + options: + - 'lights: dim' + - 'lights: color temp and color hue' + - 'lights: color temp only' + - 'lights: color hue only' + - 'media: volume control' + custom_value: false + multiple: false + sort: false + channel2_section: + name: Kanaal 2 + collapsed: true + input: + click_action_ch2: + name: 'Kanaal 2: click actie' + default: [] + selector: + action: {} + double_click_action_ch2: + name: 'Kanaal 2: double click actie' + default: [] + selector: + action: {} + long_click_action_ch2: + name: 'Kanaal 2: long press actie' + default: [] + selector: + action: {} + on_hold_action_ch2: + name: 'Kanaal 2: on-hold actie' + description: Herhaalt totdat losgelaten, zie on_hold_delay + default: [] + selector: + action: {} + scroll_wheel_target_ch2: + name: 'Kanaal 2: scroll target(s)' + default: [] + selector: + entity: + multiple: true + filter: + - domain: + - light + - media_player + reorder: false + scroll_wheel_mode_ch2: + name: 'Kanaal 2: scroll mode' + default: 'lights: dim' + selector: + select: + options: + - 'lights: dim' + - 'lights: color temp and color hue' + - 'lights: color temp only' + - 'lights: color hue only' + - 'media: volume control' + custom_value: false + multiple: false + sort: false + channel3_section: + name: Kanaal 3 + collapsed: true + input: + click_action_ch3: + name: 'Kanaal 3: click actie' + default: [] + selector: + action: {} + double_click_action_ch3: + name: 'Kanaal 3: double click actie' + default: [] + selector: + action: {} + long_click_action_ch3: + name: 'Kanaal 3: long press actie' + default: [] + selector: + action: {} + on_hold_action_ch3: + name: 'Kanaal 3: on-hold actie' + description: Herhaalt totdat losgelaten, zie on_hold_delay + default: [] + selector: + action: {} + scroll_wheel_target_ch3: + name: 'Kanaal 3: scroll target(s)' + default: [] + selector: + entity: + multiple: true + filter: + - domain: + - light + - media_player + reorder: false + scroll_wheel_mode_ch3: + name: 'Kanaal 3: scroll mode' + default: 'lights: dim' + selector: + select: + options: + - 'lights: dim' + - 'lights: color temp and color hue' + - 'lights: color temp only' + - 'lights: color hue only' + - 'media: volume control' + custom_value: false + multiple: false + sort: false + global_section: + name: Globale instellingen + collapsed: true + input: + dim_step_pct: + name: Dimmer stapgrootte (%) + default: 5 + selector: + number: + mode: box + step: 1.0 + dim_min_pct: + name: Minimum brightness (%) + default: 5 + selector: + number: + mode: box + step: 1.0 + min_color_temp: + name: Minimum kleurtemperatuur (K) + default: 2200 + selector: + number: + mode: box + step: 1.0 + max_color_temp: + name: Maximum kleurtemperatuur (K) + default: 4000 + selector: + number: + mode: box + step: 1.0 + color_temp_step: + name: Kleurtemperatuur stap (K) + default: 100 + selector: + number: + mode: box + step: 1.0 + color_hue_step: + name: Hue stap (graden, 0..360) + default: 4 + selector: + number: + mode: box + step: 1.0 + color_saturation: + name: Saturation (%) + default: 100 + selector: + number: + mode: box + step: 1.0 + on_hold_delay: + name: On-hold delay (sec) + default: 0.1 + selector: + number: + mode: box + step: 0.1 + volume_step_pct: + name: Volume stap (%) + default: 2 + selector: + number: + mode: box + step: 1.0 + volume_max_pct: + name: Max volume (%) + default: 50 + selector: + number: + mode: box + step: 1.0 + source_url: https://raw.githubusercontent.com/BoGu5/ha-blueprints/a688916fc161affcb2577e1ed90bb4f7ecf778fb/kea%20BILRESA%20scroll%20wheel%20(sensors%20for%20scroll%2C%20events%20for%20press) +alias: Ikea_BILRESA_scroll_wheel +mode: queued +max: 50 +max_exceeded: silent +trigger: +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ var_press_event_ch1 }}' + id: press_ch1 +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ var_press_event_ch2 }}' + id: press_ch2 +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ var_press_event_ch3 }}' + id: press_ch3 +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ var_scroll_right_sensor_ch1 }}' + id: scroll_right_ch1 +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ var_scroll_right_sensor_ch2 }}' + id: scroll_right_ch2 +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ var_scroll_right_sensor_ch3 }}' + id: scroll_right_ch3 +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ var_scroll_left_sensor_ch1 }}' + id: scroll_left_ch1 +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ var_scroll_left_sensor_ch2 }}' + id: scroll_left_ch2 +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ var_scroll_left_sensor_ch3 }}' + id: scroll_left_ch3 +condition: +- condition: template + value_template: "{% set o = trigger.event.data.old_state %} {% set n = trigger.event.data.new_state + %} {{ o is not none and n is not none\n and o.state not in ['unavailable','unknown']\n + \ and n.state not in ['unavailable','unknown'] }}" +variables: + targets_entities_ch1: !input scroll_wheel_target_ch1 + targets_entities_ch2: !input scroll_wheel_target_ch2 + targets_entities_ch3: !input scroll_wheel_target_ch3 + scroll_wheel_mode_ch1: !input scroll_wheel_mode_ch1 + scroll_wheel_mode_ch2: !input scroll_wheel_mode_ch2 + scroll_wheel_mode_ch3: !input scroll_wheel_mode_ch3 + var_min_pct: !input dim_min_pct + var_dim_step_pct: !input dim_step_pct + var_color_hue_step: !input color_hue_step + var_color_saturation: !input color_saturation + var_min_color_temp: !input min_color_temp + var_max_color_temp: !input max_color_temp + var_color_temp_step: !input color_temp_step + var_on_hold_delay: !input on_hold_delay + var_volume_step_pct: !input volume_step_pct + var_volume_max_pct: !input volume_max_pct +action: +- variables: + channel: '{% if trigger.id in [''press_ch1'',''scroll_left_ch1'',''scroll_right_ch1''] + %}ch1 {% elif trigger.id in [''press_ch2'',''scroll_left_ch2'',''scroll_right_ch2''] + %}ch2 {% elif trigger.id in [''press_ch3'',''scroll_left_ch3'',''scroll_right_ch3''] + %}ch3 {% else %}unknown{% endif %}' + is_press: '{{ trigger.id is match(''^press_'') }}' + is_scroll: '{{ trigger.id is match(''^scroll_'') }}' + scroll_dir: '{% if trigger.id in [''scroll_left_ch1'',''scroll_left_ch2'',''scroll_left_ch3''] + %}left {% elif trigger.id in [''scroll_right_ch1'',''scroll_right_ch2'',''scroll_right_ch3''] + %}right {% else %}none{% endif %}' + pulse_rising: '{% set o = trigger.event.data.old_state.state %} {% set n = trigger.event.data.new_state.state + %} {{ (o in [''0'',''off''] and n in [''1'',''on'']) }}' + scroll_steps: '{{ 1 if (is_scroll and pulse_rising) else 0 }}' + press_event_type: "{% if is_press %}\n {{ trigger.event.data.new_state.attributes.event_type + | default('') }}\n{% else %}\n {{ '' }}\n{% endif %}" +- choose: + - conditions: + - condition: template + value_template: '{{ is_press and press_event_type == ''multi_press_1'' }}' + sequence: + - choose: + - conditions: '{{ channel == ''ch1'' }}' + sequence: !input click_action_ch1 + - conditions: '{{ channel == ''ch2'' }}' + sequence: !input click_action_ch2 + - conditions: '{{ channel == ''ch3'' }}' + sequence: !input click_action_ch3 + - conditions: + - condition: template + value_template: '{{ is_press and press_event_type == ''multi_press_2'' }}' + sequence: + - choose: + - conditions: '{{ channel == ''ch1'' }}' + sequence: !input double_click_action_ch1 + - conditions: '{{ channel == ''ch2'' }}' + sequence: !input double_click_action_ch2 + - conditions: '{{ channel == ''ch3'' }}' + sequence: !input double_click_action_ch3 + - conditions: + - condition: template + value_template: '{{ is_press and press_event_type == ''long_press'' }}' + sequence: + - choose: + - conditions: '{{ channel == ''ch1'' }}' + sequence: + - sequence: !input long_click_action_ch1 + - repeat: + while: "{{ repeat.index < 500\n and state_attr(var_press_event_ch1, + 'event_type') == 'long_press' }}" + sequence: + - sequence: !input on_hold_action_ch1 + - delay: '{{ var_on_hold_delay }}' + - conditions: '{{ channel == ''ch2'' }}' + sequence: + - sequence: !input long_click_action_ch2 + - repeat: + while: "{{ repeat.index < 500\n and state_attr(var_press_event_ch2, + 'event_type') == 'long_press' }}" + sequence: + - sequence: !input on_hold_action_ch2 + - delay: '{{ var_on_hold_delay }}' + - conditions: '{{ channel == ''ch3'' }}' + sequence: + - sequence: !input long_click_action_ch3 + - repeat: + while: "{{ repeat.index < 500\n and state_attr(var_press_event_ch3, + 'event_type') == 'long_press' }}" + sequence: + - sequence: !input on_hold_action_ch3 + - delay: '{{ var_on_hold_delay }}' + - conditions: + - condition: template + value_template: '{{ is_scroll and scroll_steps > 0 and scroll_dir in [''left'',''right''] + }}' + sequence: + - repeat: + for_each: '{{ (targets_entities_ch1 if channel == ''ch1'' else (targets_entities_ch2 + if channel == ''ch2'' else targets_entities_ch3)) }}' + sequence: + - choose: + - conditions: + - condition: template + value_template: '{{ (scroll_wheel_mode_ch1 if channel == ''ch1'' else + (scroll_wheel_mode_ch2 if channel == ''ch2'' else scroll_wheel_mode_ch3)) + == ''lights: dim'' }}' + sequence: + - if: + - condition: template + value_template: '{{ is_state(repeat.item, ''on'') }}' + then: + - action: light.turn_on + target: + entity_id: '{{ repeat.item }}' + data: + brightness_step_pct: "{% if scroll_dir == 'right' %}\n {{ scroll_steps + * var_dim_step_pct }}\n{% else %}\n {{ -1 * ([scroll_steps * + var_dim_step_pct,\n state_attr(repeat.item, 'brightness') + * 100 / 254 - var_min_pct] | min) }}\n{% endif %}" + transition: 0.1 + - conditions: + - condition: template + value_template: "{% set mode = (scroll_wheel_mode_ch1 if channel == + 'ch1' else (scroll_wheel_mode_ch2 if channel == 'ch2' else scroll_wheel_mode_ch3)) + %} {{ mode == 'lights: color temp only'\n or (mode == 'lights: color + temp and color hue' and state_attr(repeat.item, 'color_temp_kelvin') + is not none) }}" + sequence: + - action: script.light_color_temp_helper + data: + mode: '{{ ''down'' if scroll_dir == ''left'' else ''up'' }}' + min_color_temp: '{{ var_min_color_temp }}' + max_color_temp: '{{ var_max_color_temp }}' + color_temp_step: '{{ var_color_temp_step * scroll_steps }}' + target_light: '{{ repeat.item }}' + - conditions: + - condition: template + value_template: "{% set mode = (scroll_wheel_mode_ch1 if channel == + 'ch1' else (scroll_wheel_mode_ch2 if channel == 'ch2' else scroll_wheel_mode_ch3)) + %} {{ mode == 'lights: color hue only'\n or (mode == 'lights: color + temp and color hue' and state_attr(repeat.item, 'color_temp_kelvin') + is none) }}" + sequence: + - action: script.light_color_hs_helper + data: + target_light: '{{ repeat.item }}' + color_hue_step: '{{ var_color_hue_step * scroll_steps }}' + color_saturation: '{{ var_color_saturation }}' + mode: '{{ ''down'' if scroll_dir == ''left'' else ''up'' }}' + - conditions: + - condition: template + value_template: '{{ (scroll_wheel_mode_ch1 if channel == ''ch1'' else + (scroll_wheel_mode_ch2 if channel == ''ch2'' else scroll_wheel_mode_ch3)) + == ''media: volume control'' }}' + sequence: + - action: media_player.volume_set + target: + entity_id: '{{ repeat.item }}' + data: + volume_level: "{{ [[\n (state_attr(repeat.item, 'volume_level') + | float(0)) * 100\n + (1 if scroll_dir == 'right' else -1) * + scroll_steps * var_volume_step_pct,\n var_volume_max_pct\n ] + | min, 0] | max / 100 }}" +trigger_variables: + var_remote: !input remote + var_press_event_ch1: "{% for item in device_entities(var_remote) %}\n {% set m + = item | regex_findall(find='^event\\\\..+_3$') %}\n {% if m | length > 0 %}{{ + m[0] }}{% endif %}\n{% endfor %}" + var_press_event_ch2: "{% for item in device_entities(var_remote) %}\n {% set m + = item | regex_findall(find='^event\\\\..+_6$') %}\n {% if m | length > 0 %}{{ + m[0] }}{% endif %}\n{% endfor %}" + var_press_event_ch3: "{% for item in device_entities(var_remote) %}\n {% set m + = item | regex_findall(find='^event\\\\..+_9$') %}\n {% if m | length > 0 %}{{ + m[0] }}{% endif %}\n{% endfor %}" + var_scroll_right_sensor_ch1: "{% for item in device_entities(var_remote) %}\n {% + set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_1$') + %}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}" + var_scroll_left_sensor_ch1: "{% for item in device_entities(var_remote) %}\n {% + set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_2$') + %}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}" + var_scroll_right_sensor_ch2: "{% for item in device_entities(var_remote) %}\n {% + set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_4$') + %}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}" + var_scroll_left_sensor_ch2: "{% for item in device_entities(var_remote) %}\n {% + set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_5$') + %}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}" + var_scroll_right_sensor_ch3: "{% for item in device_entities(var_remote) %}\n {% + set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_7$') + %}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}" + var_scroll_left_sensor_ch3: "{% for item in device_entities(var_remote) %}\n {% + set m = item | regex_findall(find='^sensor\\\\..+current_switch_position_8$') + %}\n {% if m | length > 0 %}{{ m[0] }}{% endif %}\n{% endfor %}" diff --git a/blueprints/automation/SvKRO/ha-blueprint-ikea-bilresa-scrollwheel_en.yaml b/blueprints/automation/SvKRO/ha-blueprint-ikea-bilresa-scrollwheel_en.yaml new file mode 100644 index 0000000..c92cdb1 --- /dev/null +++ b/blueprints/automation/SvKRO/ha-blueprint-ikea-bilresa-scrollwheel_en.yaml @@ -0,0 +1,624 @@ +blueprint: + name: IKEA BILRESA - Light Controller (v1.3) + description: 'Light control for IKEA BILRESA (Matter). Supports dynamic scrolling, + scene cycling, and multi-click actions for up to three channels. + + ' + domain: automation + input: + remote_device: + name: Remote Control + description: The IKEA BILRESA scroll wheel (Matter) used as the trigger. + selector: + device: + filter: + - manufacturer: IKEA of Sweden + model: BILRESA scroll wheel + multiple: false + header_1: + name: Channel 1 + default: '' + selector: + constant: + value: '' + group_scroll_1: + name: Scroll Wheel Settings + icon: mdi:rotate-right + collapsed: true + input: + input_light_1: + name: Light / Light Group + description: Select the light or group to be controlled. + default: {} + selector: + target: + entity: + - domain: + - light + input_mode_1: + name: Scroll Function + description: Define which value is changed when turning the wheel. + default: brightness + selector: + select: + mode: dropdown + options: + - label: Brightness + value: brightness + - label: Color + value: hs_color + - label: Color temperature + value: temp_kelvin + custom_value: false + multiple: false + sort: false + input_sens_1: + name: Multiplier + description: Define how much the value should change with each scroll step. + default: 5 + selector: + number: + min: 1.0 + max: 50.0 + unit_of_measurement: x + mode: slider + step: 1.0 + input_transition_1: + name: Transition duration + description: Define how long the transition between changes should take. + default: 200 + selector: + number: + min: 0.0 + max: 5000.0 + unit_of_measurement: ms + mode: slider + step: 1.0 + group_single_click_1: + name: Single Click + icon: mdi:gesture-tap + collapsed: true + input: + input_click_mode_1: + name: Click Mode + description: Choose between a single action or cycling through scenes. + default: action + selector: + select: + mode: dropdown + options: + - label: Perform single action + value: action + - label: Scene cycler + value: scene_cycle + custom_value: false + multiple: false + sort: false + input_scenes_1: + name: Scene Selection + description: Select the scenes to cycle through sequentially. + default: [] + selector: + entity: + domain: + - scene + multiple: true + reorder: false + input_click_1: + name: Action on Click + description: Action to perform when in 'Single Action' mode. + default: [] + selector: + action: {} + group_multi_click_1: + name: Additional Button Functions + icon: mdi:plus-circle-outline + collapsed: true + input: + input_double_1: + name: Double Click + description: Action to perform on double press. + default: [] + selector: + action: {} + input_triple_1: + name: Triple Click + description: Action to perform on triple press. + default: [] + selector: + action: {} + input_long_1: + name: Hold + description: Action to perform when holding the button. + default: [] + selector: + action: {} + input_release_1: + name: Release + description: Action to perform when releasing the button after a long press. + default: [] + selector: + action: {} + header_2: + name: Channel 2 + default: '' + selector: + constant: + value: '' + group_scroll_2: + name: Scroll Wheel Settings + icon: mdi:rotate-right + collapsed: true + input: + input_light_2: + name: Light / Light Group + description: Select the light or group to be controlled. + default: {} + selector: + target: + entity: + - domain: + - light + input_mode_2: + name: Scroll Function + description: Define which value is changed when turning the wheel. + default: brightness + selector: + select: + mode: dropdown + options: + - label: Brightness + value: brightness + - label: Color + value: hs_color + - label: Color temperature + value: temp_kelvin + custom_value: false + multiple: false + sort: false + input_sens_2: + name: Multiplier + description: Define how much the value should change with each scroll step. + default: 5 + selector: + number: + min: 1.0 + max: 50.0 + unit_of_measurement: x + mode: slider + step: 1.0 + input_transition_2: + name: Transition duration + description: Define how long the transition between changes should take. + default: 200 + selector: + number: + min: 0.0 + max: 5000.0 + unit_of_measurement: ms + mode: slider + step: 1.0 + group_single_click_2: + name: Single Click + icon: mdi:gesture-tap + collapsed: true + input: + input_click_mode_2: + name: Click Mode + description: Choose between a single action or cycling through scenes. + default: action + selector: + select: + mode: dropdown + options: + - label: Perform single action + value: action + - label: Scene cycler + value: scene_cycle + custom_value: false + multiple: false + sort: false + input_scenes_2: + name: Scene Selection + description: Select the scenes to cycle through sequentially. + default: [] + selector: + entity: + domain: + - scene + multiple: true + reorder: false + input_click_2: + name: Action on Click + description: Action to perform when in 'Single Action' mode. + default: [] + selector: + action: {} + group_multi_click_2: + name: Additional Button Functions + icon: mdi:plus-circle-outline + collapsed: true + input: + input_double_2: + name: Double Click + description: Action to perform on double press. + default: [] + selector: + action: {} + input_triple_2: + name: Triple Click + description: Action to perform on triple press. + default: [] + selector: + action: {} + input_long_2: + name: Hold + description: Action to perform when holding the button. + default: [] + selector: + action: {} + input_release_2: + name: Release + description: Action to perform when releasing the button after a long press. + default: [] + selector: + action: {} + header_3: + name: Channel 3 + default: '' + selector: + constant: + value: '' + group_scroll_3: + name: Scroll Wheel Settings + icon: mdi:rotate-right + collapsed: true + input: + input_light_3: + name: Light / Light Group + description: Select the light or group to be controlled. + default: {} + selector: + target: + entity: + - domain: + - light + input_mode_3: + name: Scroll Function + description: Define which value is changed when turning the wheel. + default: brightness + selector: + select: + mode: dropdown + options: + - label: Brightness + value: brightness + - label: Color + value: hs_color + - label: Color temperature + value: temp_kelvin + custom_value: false + multiple: false + sort: false + input_sens_3: + name: Multiplier + description: Define how much the value should change with each scroll step. + default: 5 + selector: + number: + min: 1.0 + max: 50.0 + unit_of_measurement: x + mode: slider + step: 1.0 + input_transition_3: + name: Transition duration + description: Define how long the transition between changes should take. + default: 200 + selector: + number: + min: 0.0 + max: 5000.0 + unit_of_measurement: ms + mode: slider + step: 1.0 + group_single_click_3: + name: Single Click + icon: mdi:gesture-tap + collapsed: true + input: + input_click_mode_3: + name: Click Mode + description: Choose between a single action or cycling through scenes. + default: action + selector: + select: + mode: dropdown + options: + - label: Perform single action + value: action + - label: Scene cycler + value: scene_cycle + custom_value: false + multiple: false + sort: false + input_scenes_3: + name: Scene Selection + description: Select the scenes to cycle through sequentially. + default: [] + selector: + entity: + domain: + - scene + multiple: true + reorder: false + input_click_3: + name: Action on Click + description: Action to perform when in 'Single Action' mode. + default: [] + selector: + action: {} + group_multi_click_3: + name: Additional Button Functions + icon: mdi:plus-circle-outline + collapsed: true + input: + input_double_3: + name: Double Click + description: Action to perform on double press. + default: [] + selector: + action: {} + input_triple_3: + name: Triple Click + description: Action to perform on triple press. + default: [] + selector: + action: {} + input_long_3: + name: Hold + description: Action to perform when holding the button. + default: [] + selector: + action: {} + input_release_3: + name: Release + description: Action to perform when releasing the button after a long press. + default: [] + selector: + action: {} + source_url: https://gist.github.com/SvKRO/52f246362a19f0297fd18969b383181b +mode: single +max_exceeded: silent +trigger_variables: + var_remote: !input remote_device + var_events: '{{ device_entities(var_remote) | select(''match'', ''event\.'') | sort + | list }} + + ' + temp_kanal1_rechts: '{{ var_events[0] if var_events | length > 0 else ''none'' }}' + temp_kanal1_links: '{{ var_events[1] if var_events | length > 1 else ''none'' }}' + temp_kanal1_klick: '{{ var_events[2] if var_events | length > 2 else ''none'' }}' + temp_kanal2_rechts: '{{ var_events[3] if var_events | length > 3 else ''none'' }}' + temp_kanal2_links: '{{ var_events[4] if var_events | length > 4 else ''none'' }}' + temp_kanal2_klick: '{{ var_events[5] if var_events | length > 5 else ''none'' }}' + temp_kanal3_rechts: '{{ var_events[6] if var_events | length > 6 else ''none'' }}' + temp_kanal3_links: '{{ var_events[7] if var_events | length > 7 else ''none'' }}' + temp_kanal3_klick: '{{ var_events[8] if var_events | length > 8 else ''none'' }}' +trigger: +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ temp_kanal1_links }}' + id: trigger_kanal1_links +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ temp_kanal1_rechts }}' + id: trigger_kanal1_rechts +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ temp_kanal1_klick }}' + id: trigger_kanal1_klick +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ temp_kanal2_links }}' + id: trigger_kanal2_links +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ temp_kanal2_rechts }}' + id: trigger_kanal2_rechts +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ temp_kanal2_klick }}' + id: trigger_kanal2_klick +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ temp_kanal3_links }}' + id: trigger_kanal3_links +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ temp_kanal3_rechts }}' + id: trigger_kanal3_rechts +- trigger: event + event_type: state_changed + event_data: + entity_id: '{{ temp_kanal3_klick }}' + id: trigger_kanal3_klick +action: +- condition: template + value_template: "{% if trigger.event.data.new_state is none %}\n false\n{% else + %}\n {% set old_attr = trigger.event.data.old_state.attributes if trigger.event.data.old_state + is not none else {} %}\n {% set new_attr = trigger.event.data.new_state.attributes + %}\n {% set old_ts = old_attr.get('last_reported', 'none') %}\n {% set new_ts + = new_attr.get('last_reported', 'new') %}\n {{ old_ts != new_ts }}\n{% endif + %}\n" +- variables: + m_1: !input input_mode_1 + m_2: !input input_mode_2 + m_3: !input input_mode_3 + s_1: !input input_sens_1 + s_2: !input input_sens_2 + s_3: !input input_sens_3 + l_1: !input input_light_1 + l_2: !input input_light_2 + l_3: !input input_light_3 + tr_1: !input input_transition_1 + tr_2: !input input_transition_2 + tr_3: !input input_transition_3 + action_kanal_id: '{{ trigger.id.split(''_'')[1] }}' + event_attr: '{{ trigger.event.data.new_state.attributes | to_json | from_json + }}' + ticks: '{{ event_attr.get(''totalNumberOfPressesCounted'') | default(1) | int + }}' + action_event_type: '{{ event_attr.get(''event_type'') | default(''none'') }}' + curr_mode: '{% if action_kanal_id == ''kanal1'' %}{{ m_1 }}{% elif action_kanal_id + == ''kanal2'' %}{{ m_2 }}{% else %}{{ m_3 }}{% endif %}' + base_sens: '{% if action_kanal_id == ''kanal1'' %}{{ s_1 }}{% elif action_kanal_id + == ''kanal2'' %}{{ s_2 }}{% else %}{{ s_3 }}{% endif %}' + step_val: '{% set val = base_sens * ticks %}{{ val if trigger.id.endswith(''_rechts'') + else val * -1 }}' + transition_ms: '{% if action_kanal_id == ''kanal1'' %}{{ tr_1 }}{% elif action_kanal_id + == ''kanal2'' %}{{ tr_2 }}{% else %}{{ tr_3 }}{% endif %}' + transition_s: '{{ transition_ms / 1000 }}' + curr_target_obj: '{% if action_kanal_id == ''kanal1'' %}{{ l_1 }} {% elif action_kanal_id + == ''kanal2'' %}{{ l_2 }} {% else %}{{ l_3 }}{% endif %} + + ' + curr_target_list: "{% set t = curr_target_obj %} {% set ns = namespace(entities=[]) + %}\n{% set e_ids = t.entity_id if t.entity_id is defined else [] %} {% set ns.entities + = [e_ids] if e_ids is string else e_ids %}\n{% set d_ids = t.device_id if t.device_id + is defined else [] %} {% set d_ids = [d_ids] if d_ids is string else d_ids %} + {% for d in d_ids %}\n {% set ns.entities = ns.entities + (device_entities(d) + | select('match', 'light\\.') | list) %}\n{% endfor %}\n{% set a_ids = t.area_id + if t.area_id is defined else [] %} {% set a_ids = [a_ids] if a_ids is string + else a_ids %} {% for a in a_ids %}\n {% set ns.entities = ns.entities + (area_entities(a) + | select('match', 'light\\.') | list) %}\n{% endfor %}\n{% set lb_ids = t.label_id + if t.label_id is defined else [] %} {% set lb_ids = [lb_ids] if lb_ids is string + else lb_ids %} {% for l in lb_ids %}\n {% set ns.entities = ns.entities + (label_entities(l) + | select('match', 'light\\.') | list) %}\n{% endfor %}\n{{ ns.entities | unique + | reject('none') | list }}\n" + curr_target: '{{ curr_target_list | first | default(''none'') }}' +- choose: + - conditions: '{{ trigger.id.endswith(''_rechts'') or trigger.id.endswith(''_links'') + }}' + sequence: + - choose: + - conditions: '{{ curr_mode == ''brightness'' }}' + sequence: + - variables: + target_value: '{% set current = state_attr(curr_target, ''brightness'') + | int(128) %} {{ [([current + (step_val | int * 2.55 | int), 0] | max), + 255] | min }} + + ' + - service: light.turn_on + target: + entity_id: '{{ curr_target_list }}' + data: + transition: '{{ transition_s }}' + brightness: '{{ target_value }}' + - conditions: '{{ curr_mode == ''temp_kelvin'' }}' + sequence: + - variables: + target_value: '{% set curr = state_attr(curr_target, ''color_temp_kelvin'') + | int(3000) %} {% set min_v = state_attr(curr_target, ''min_color_temp_kelvin'') + | int(2000) %} {% set max_v = state_attr(curr_target, ''max_color_temp_kelvin'') + | int(6535) %} {{ [([curr + (step_val * 10) | int, min_v] | max), max_v] + | min }} + + ' + - service: light.turn_on + target: + entity_id: '{{ curr_target_list }}' + data: + transition: '{{ transition_s }}' + color_temp_kelvin: '{{ target_value }}' + - conditions: '{{ curr_mode == ''hs_color'' }}' + sequence: + - variables: + target_value: '{% set hs = state_attr(curr_target, ''hs_color'') | default([0, + 100], true) %} {{ [((hs[0] | float(0) + step_val) % 360) | round(2), + hs[1] | float(100)] }} + + ' + - service: light.turn_on + target: + entity_id: '{{ curr_target_list }}' + data: + transition: '{{ transition_s }}' + hs_color: '{{ target_value }}' + - conditions: '{{ trigger.id.endswith(''_klick'') }}' + sequence: + - variables: + cm_1: !input input_click_mode_1 + cm_2: !input input_click_mode_2 + cm_3: !input input_click_mode_3 + cs_1: !input input_scenes_1 + cs_2: !input input_scenes_2 + cs_3: !input input_scenes_3 + c_mode: '{% if action_kanal_id == ''kanal1'' %}{{ cm_1 }}{% elif action_kanal_id + == ''kanal2'' %}{{ cm_2 }}{% else %}{{ cm_3 }}{% endif %}' + c_scenes: '{% if action_kanal_id == ''kanal1'' %}{{ cs_1 }}{% elif action_kanal_id + == ''kanal2'' %}{{ cs_2 }}{% else %}{{ cs_3 }}{% endif %}' + - choose: + - conditions: '{{ action_event_type == ''multi_press_1'' }}' + sequence: + - choose: + - conditions: '{{ c_mode == ''scene_cycle'' and c_scenes | count > 0 }}' + sequence: + - service: scene.turn_on + target: + entity_id: '{% set count = c_scenes | count %}{% set idx = (as_timestamp(now()) + | int // 2) % count %}{{ c_scenes[idx] }}' + - conditions: '{{ c_mode == ''action'' }}' + sequence: + - choose: + - conditions: '{{ action_kanal_id == ''kanal1'' }}' + sequence: !input input_click_1 + - conditions: '{{ action_kanal_id == ''kanal2'' }}' + sequence: !input input_click_2 + - conditions: '{{ action_kanal_id == ''kanal3'' }}' + sequence: !input input_click_3 + - conditions: '{{ action_event_type == ''multi_press_2'' }}' + sequence: + - choose: + - conditions: '{{ action_kanal_id == ''kanal1'' }}' + sequence: !input input_double_1 + - conditions: '{{ action_kanal_id == ''kanal2'' }}' + sequence: !input input_double_2 + - conditions: '{{ action_kanal_id == ''kanal3'' }}' + sequence: !input input_double_3 + - conditions: '{{ action_event_type == ''multi_press_3'' }}' + sequence: + - choose: + - conditions: '{{ action_kanal_id == ''kanal1'' }}' + sequence: !input input_triple_1 + - conditions: '{{ action_kanal_id == ''kanal2'' }}' + sequence: !input input_triple_2 + - conditions: '{{ action_kanal_id == ''kanal3'' }}' + sequence: !input input_triple_3 + - conditions: '{{ action_event_type == ''long_press'' }}' + sequence: + - choose: + - conditions: '{{ action_kanal_id == ''kanal1'' }}' + sequence: !input input_long_1 + - conditions: '{{ action_kanal_id == ''kanal2'' }}' + sequence: !input input_long_2 + - conditions: '{{ action_kanal_id == ''kanal3'' }}' + sequence: !input input_long_3 + - conditions: '{{ action_event_type == ''long_release'' }}' + sequence: + - choose: + - conditions: '{{ action_kanal_id == ''kanal1'' }}' + sequence: !input input_release_1 + - conditions: '{{ action_kanal_id == ''kanal2'' }}' + sequence: !input input_release_2 + - conditions: '{{ action_kanal_id == ''kanal3'' }}' + sequence: !input input_release_3 diff --git a/blueprints/automation/censay/ikea-bilresa-e2489-matter-smart-button.yaml b/blueprints/automation/censay/ikea-bilresa-e2489-matter-smart-button.yaml new file mode 100644 index 0000000..d9ac37a --- /dev/null +++ b/blueprints/automation/censay/ikea-bilresa-e2489-matter-smart-button.yaml @@ -0,0 +1,171 @@ +blueprint: + name: IKEA BILRESA E2489 Dual Button (Matter) + author: censay + description: 'Full-featured automation for the IKEA BILRESA E2489 Matter dual-button + remote. Supports single press, double press, and long press (on press and on release) + for both buttons. Uses event entities exposed by Home Assistant for Matter devices. + Single-press actions are prioritized for reliability. + + ' + domain: automation + source_url: https://raw.githubusercontent.com/censay/haos-blueprints/refs/heads/master/ikea-bilresa-e2489-matter-smart-button/ikea-bilresa-e2489-matter-smart-button.yaml + homeassistant: + min_version: 2025.12.1 + input: + target_device: + name: BILRESA Button Device + description: 'Select the IKEA BILRESA E2489 matter device. + + ' + selector: + device: + filter: + - manufacturer: IKEA of Sweden + multiple: false + button1_event: + name: Button 1 – Event Entity + description: 'Select the event entity for Button 1 (for example: event.bilresa_dual_button_button_1_2). + + ' + selector: + entity: + domain: + - event + multiple: false + reorder: false + button2_event: + name: Button 2 – Event Entity + description: 'Select the event entity for Button 2 (for example: event.bilresa_dual_button_button_2_2). + + ' + selector: + entity: + domain: + - event + multiple: false + reorder: false + button1_single: + name: Button 1 – Single Press + description: Action for Button 1 single press (multi_press_1). + default: [] + selector: + action: {} + button1_double: + name: Button 1 – Double Press + description: Action for Button 1 double press (multi_press_2). + default: [] + selector: + action: {} + button1_long_press: + name: Button 1 – Long Press (on press) + description: Action for Button 1 long press start (long_press). + default: [] + selector: + action: {} + button1_long: + name: Button 1 – Long Press (on release) + description: Action for Button 1 long press completion (long_release). + default: [] + selector: + action: {} + button2_single: + name: Button 2 – Single Press + description: Action for Button 2 single press (multi_press_1). + default: [] + selector: + action: {} + button2_double: + name: Button 2 – Double Press + description: Action for Button 2 double press (multi_press_2). + default: [] + selector: + action: {} + button2_long_press: + name: Button 2 – Long Press (on press) + description: Action for Button 2 long press start (long_press). + default: [] + selector: + action: {} + button2_long: + name: Button 2 – Long Press (on release) + description: Action for Button 2 long press completion (long_release). + default: [] + selector: + action: {} +variables: + button1_entity: !input button1_event + button2_entity: !input button2_event +trigger: +- platform: state + id: button1 + entity_id: !input button1_event + not_from: unavailable + not_to: unavailable +- platform: state + id: button2 + entity_id: !input button2_event + not_from: unavailable + not_to: unavailable +condition: [] +action: +- variables: + press_type: '{{ trigger.to_state.attributes.event_type }}' + trigger_id: '{{ trigger.id }}' +- choose: + - conditions: + - condition: template + value_template: '{{ trigger_id == ''button1'' and press_type == ''multi_press_1'' + }} + + ' + sequence: !input button1_single + - conditions: + - condition: template + value_template: '{{ trigger_id == ''button1'' and press_type == ''multi_press_2'' + }} + + ' + sequence: !input button1_double + - conditions: + - condition: template + value_template: '{{ trigger_id == ''button1'' and press_type == ''long_press'' + }} + + ' + sequence: !input button1_long_press + - conditions: + - condition: template + value_template: '{{ trigger_id == ''button1'' and press_type == ''long_release'' + }} + + ' + sequence: !input button1_long + - conditions: + - condition: template + value_template: '{{ trigger_id == ''button2'' and press_type == ''multi_press_1'' + }} + + ' + sequence: !input button2_single + - conditions: + - condition: template + value_template: '{{ trigger_id == ''button2'' and press_type == ''multi_press_2'' + }} + + ' + sequence: !input button2_double + - conditions: + - condition: template + value_template: '{{ trigger_id == ''button2'' and press_type == ''long_press'' + }} + + ' + sequence: !input button2_long_press + - conditions: + - condition: template + value_template: '{{ trigger_id == ''button2'' and press_type == ''long_release'' + }} + + ' + sequence: !input button2_long +mode: restart diff --git a/blueprints/automation/custom/bilresa-dual-button_z2m.yaml b/blueprints/automation/custom/bilresa-dual-button_z2m.yaml new file mode 100644 index 0000000..b7d3176 --- /dev/null +++ b/blueprints/automation/custom/bilresa-dual-button_z2m.yaml @@ -0,0 +1,99 @@ +blueprint: + name: Zigbee2MQTT - IKEA Bilresa Dual Button + description: Control actions using an IKEA Bilresa remote via Zigbee2MQTT based on MQTT topic payloads. + domain: automation + input: + remote_name: + name: Zigbee2MQTT Friendly Name + description: The friendly name of the Bilresa remote in Zigbee2MQTT (used for the MQTT topic). + selector: + text: {} + on_click: + name: Top Button - Single Click + default: [] + selector: + action: {} + on_double_click: + name: Top Button - Double Click + default: [] + selector: + action: {} + on_hold: + name: Top Button - Long Press (Hold) + default: [] + selector: + action: {} + off_click: + name: Bottom Button - Single Click + default: [] + selector: + action: {} + off_double_click: + name: Bottom Button - Double Click + default: [] + selector: + action: {} + off_hold: + name: Bottom Button - Long Press (Hold) + default: [] + selector: + action: {} + stop_hold: + name: Button Released (Stop) + description: Action to run when a long press is released. + default: [] + selector: + action: {} + +trigger: + - platform: mqtt + topic: "{{ 'zigbee2mqtt/' ~ remote_id if '/' not in remote_id else remote_id }}" + # We dynamically evaluate the topic in the trigger instead of matching in conditions + # to avoid waking up the automation for unrelated MQTT traffic. + +variables: + remote_id: !input remote_name + expected_topic: "{{ remote_id if '/' in remote_id else 'zigbee2mqtt/' ~ remote_id }}" + +# Fallback trigger if the dynamic template topic has constraints in older versions +# Using wildcard MQTT trigger and filtering by topic in conditions for maximum compatibility: +trigger: + - platform: mqtt + topic: "zigbee2mqtt/#" + +condition: + - condition: template + value_template: >- + {{ trigger.topic == expected_topic and trigger.payload_json.action is defined }} + +action: + - choose: + - conditions: + - condition: template + value_template: "{{ trigger.payload_json.action == 'on' }}" + sequence: !input on_click + - conditions: + - condition: template + value_template: "{{ trigger.payload_json.action == 'on_double' }}" + sequence: !input on_double_click + - conditions: + - condition: template + value_template: "{{ trigger.payload_json.action == 'brightness_move_up' }}" + sequence: !input on_hold + - conditions: + - condition: template + value_template: "{{ trigger.payload_json.action == 'off' }}" + sequence: !input off_click + - conditions: + - condition: template + value_template: "{{ trigger.payload_json.action == 'off_double' }}" + sequence: !input off_double_click + - conditions: + - condition: template + value_template: "{{ trigger.payload_json.action == 'brightness_move_down' }}" + sequence: !input off_hold + - conditions: + - condition: template + value_template: "{{ trigger.payload_json.action == 'brightness_stop' }}" + sequence: !input stop_hold +mode: restart \ No newline at end of file diff --git a/blueprints/automation/gist.githubusercontent.com/Bilresa_Scrollwheel_Z2M_en.yaml b/blueprints/automation/gist.githubusercontent.com/Bilresa_Scrollwheel_Z2M_en.yaml new file mode 100644 index 0000000..da98b4d --- /dev/null +++ b/blueprints/automation/gist.githubusercontent.com/Bilresa_Scrollwheel_Z2M_en.yaml @@ -0,0 +1,67 @@ +blueprint: + name: IKEA Bilresa - High-Performance Wheel Control + description: Optimized control for the IKEA Bilresa wheel focusing on speed and + MG24/Ember stability. + domain: automation + input: + controller_name: + name: Remote MQTT Name + description: The name of the device in Zigbee2MQTT (e.g., Bilresa_Wheel) + default: Bilresa Wheel + action_on: + name: 'On' + description: Action to perform when the top button is pressed. + default: [] + selector: + action: {} + action_off: + name: 'Off' + description: Action to perform when the bottom button is pressed. + default: [] + selector: + action: {} + action_right: + name: Turn Right + description: 'Action for clockwise rotation. Recommended: light.turn_on with + brightness_step_pct: 10 and transition: 0.1' + default: [] + selector: + action: {} + action_left: + name: Turn Left + description: 'Action for counter-clockwise rotation. Recommended: light.turn_on + with brightness_step_pct: -10 and transition: 0.1' + default: [] + selector: + action: {} + source_url: https://gist.githubusercontent.com/patt110/4eda20da78422b8989b3b6686264e2ac/raw/a80245848aea86d29668015fc014b13d032569f0/Bilresa_Scrollwheel_Z2M_en.yaml +mode: restart +trigger: +- platform: mqtt + topic: zigbee2mqtt/{{ (controller_name if controller_name else 'Bilresa Wheel') + }} +action: +- variables: + payload: '{{ trigger.payload_json }}' + action_val: '{{ payload.action if ''action'' in payload else '''' }}' +- choose: + - conditions: '{{ action_val == ''on'' }}' + sequence: !input action_on + - conditions: '{{ action_val == ''off'' }}' + sequence: !input action_off + - conditions: '{{ action_val == ''brightness_move_to_level'' }}' + sequence: + - variables: + is_max: '{{ payload.action_brightness_delta is none and payload.action_level + is none }}' + is_min: '{{ payload.action_brightness_delta == 0 and payload.action_level + == 1 }}' + delta: '{{ payload.action_brightness_delta | int(0) if payload.action_brightness_delta + is not none else 0 }}' + - choose: + - conditions: '{{ delta > 0 or is_max }}' + sequence: !input action_right + - conditions: '{{ delta < 0 or is_min }}' + sequence: !input action_left +- delay: + milliseconds: 30 diff --git a/blueprints/automation/jhol-byte/Ikea_bilresa_scroll_wheel.yaml b/blueprints/automation/jhol-byte/Ikea_bilresa_scroll_wheel.yaml new file mode 100644 index 0000000..67c07fc --- /dev/null +++ b/blueprints/automation/jhol-byte/Ikea_bilresa_scroll_wheel.yaml @@ -0,0 +1,908 @@ +blueprint: + name: Ikea_bilresa_scroll_wheel + description: 'Blueprint for Ikea Bilresa Matter (button and scroll wheel for 3 different + channels) + + Version: 2026-04-06 + + Info: https://community.home-assistant.io/t/ikea-bilresa-scroll-wheel-blueprint-matter/965365 + + ' + domain: automation + input: + remote: + name: Remote + description: Ikea Bilresa scroll wheel remote control + selector: + device: + filter: + - manufacturer: IKEA of Sweden + model: BILRESA scroll wheel + integration: matter + multiple: false + channel1_section: + name: Channel 1 + collapsed: true + input: + click_action_ch1: + name: 'Channel 1: action' + default: [] + selector: + action: {} + double_click_action_ch1: + name: 'Channel 1: action' + default: [] + selector: + action: {} + triple_click_action_ch1: + name: 'Channel 1: action' + default: [] + selector: + action: {} + long_click_action_ch1: + name: 'Channel 1: action' + description: '' + default: [] + selector: + action: {} + on_hold_action_ch1: + name: 'Channel 1: action' + description: repeat action until button is released, see also on_hold_delay + default: [] + selector: + action: {} + scroll_wheel_target_ch1: + name: 'Channel 1: Scroll wheel target entity' + description: 'Light or media player to be controlled by scroll wheel for + channel 1 + + - empty for scroll wheel mode + + ' + selector: + entity: + multiple: true + filter: + - domain: + - light + - media_player + reorder: false + default: [] + scroll_wheel_mode_ch1: + selector: + select: + options: + - 'lights: dim' + - 'lights: color temp and color hue' + - 'lights: color temp only' + - 'lights: color hue only' + - 'media: volume control' + - 'dynamic: choose from input_select' + - user defined + custom_value: false + multiple: false + sort: false + default: 'lights: dim' + name: 'Channel 1: Scroll wheel mode' + description: Select attribute of target entity controlled by scroll wheel + scroll_wheel_mode_dynamic_ch1: + selector: + entity: + filter: + - domain: + - input_select + multiple: false + reorder: false + default: + name: 'Channel 1: Scroll wheel mode input_select' + description: 'For scroll wheel mode only, leave empty otherwise. + + Create and choose an ''input_select'' containing the scroll wheel modes + you want to choose from dynamically, e.g. + + - "lights: dim" + + - "lights: color temp only"' + scroll_wheel_mode_ext_ch1: + selector: + select: + options: + - relaxed + - instant + custom_value: false + multiple: false + sort: false + default: relaxed + name: 'Channel 1: Scroll wheel evaluation mode' + description: ': one action call after scrolling + + : many action calls while scrolling + + + Beware: + + for mode the 9 hidden sensor entities of Bilresa device have + to be enabled + + ' + scroll_wheel_user_action_ch1: + name: 'Channel 1: User defined scroll wheel action' + description: 'For scroll wheel mode only + + Exposed variables: + + - : for mode always 1, for mode number + between 1 to 8 + + - : ''left'' or ''right'' + + ' + default: [] + selector: + action: {} + channel2_section: + name: Channel 2 + collapsed: true + input: + click_action_ch2: + name: 'Channel 2: action' + default: [] + selector: + action: {} + double_click_action_ch2: + name: 'Channel 2: action' + default: [] + selector: + action: {} + triple_click_action_ch2: + name: 'Channel 2: action' + default: [] + selector: + action: {} + long_click_action_ch2: + name: 'Channel 2: action' + description: '' + default: [] + selector: + action: {} + on_hold_action_ch2: + name: 'Channel 2: action' + description: repeat action until button is released, see also on_hold_delay + default: [] + selector: + action: {} + scroll_wheel_target_ch2: + name: 'Channel 2: Scroll wheel target entity' + description: 'Light or media player to be controlled by scroll wheel for + channel 2 + + - empty for scroll wheel mode + + ' + selector: + entity: + multiple: true + filter: + - domain: + - light + - media_player + reorder: false + default: [] + scroll_wheel_mode_ch2: + selector: + select: + options: + - 'lights: dim' + - 'lights: color temp and color hue' + - 'lights: color temp only' + - 'lights: color hue only' + - 'media: volume control' + - 'dynamic: choose from input_select' + - user defined + custom_value: false + multiple: false + sort: false + default: 'lights: dim' + name: 'Channel 2: Scroll wheel mode' + description: Select attribute of target entity controlled by scroll wheel + scroll_wheel_mode_dynamic_ch2: + selector: + entity: + filter: + - domain: + - input_select + multiple: false + reorder: false + default: + name: 'Channel 2: Scroll wheel mode input_select' + description: 'For scroll wheel mode only, leave empty otherwise. + + Create and choose an ''input_select'' containing the scroll wheel modes + you want to choose from dynamically, e.g. + + - "lights: dim" + + - "lights: color temp only"' + scroll_wheel_mode_ext_ch2: + selector: + select: + options: + - relaxed + - instant + custom_value: false + multiple: false + sort: false + default: relaxed + name: 'Channel 2: Scroll wheel evaluation mode' + description: '- : one action call after scrolling + + - : many action calls while scrolling + + + Beware: + + - for mode the 9 hidden sensor entities of Bilresa device have + to be enabled + + ' + scroll_wheel_user_action_ch2: + name: 'Channel 2: User defined scroll wheel action' + description: 'For scroll wheel mode only + + Exposed variables: + + - : for mode always 1, for mode number + between 1 to 8 + + - : ''left'' or ''right'' + + ' + default: [] + selector: + action: {} + channel3_section: + name: Channel 3 + collapsed: true + input: + click_action_ch3: + name: 'Channel 3: action' + default: [] + selector: + action: {} + double_click_action_ch3: + name: 'Channel 3: action' + default: [] + selector: + action: {} + triple_click_action_ch3: + name: 'Channel 3: action' + default: [] + selector: + action: {} + long_click_action_ch3: + name: 'Channel 3: action' + description: '' + default: [] + selector: + action: {} + on_hold_action_ch3: + name: 'Channel 3: action' + description: repeat action until button is released, see also on_hold_delay + default: [] + selector: + action: {} + scroll_wheel_target_ch3: + name: 'Channel 3: Scroll wheel target entity' + description: 'Light or media player to be controlled by scroll wheel for + channel 3 + + - empty for scroll wheel mode + + ' + selector: + entity: + multiple: true + filter: + - domain: + - light + - media_player + reorder: false + default: [] + scroll_wheel_mode_ch3: + selector: + select: + options: + - 'lights: dim' + - 'lights: color temp and color hue' + - 'lights: color temp only' + - 'lights: color hue only' + - 'media: volume control' + - 'dynamic: choose from input_select' + - user defined + custom_value: false + multiple: false + sort: false + default: 'lights: dim' + name: 'Channel 3: Scroll wheel mode' + description: Select attribute of target entity controlled by scroll wheel + scroll_wheel_mode_dynamic_ch3: + selector: + entity: + filter: + - domain: + - input_select + multiple: false + reorder: false + default: + name: 'Channel 3: Scroll wheel mode input_select' + description: 'For scroll wheel mode only, leave empty otherwise. + + Create and choose an ''input_select'' containing the scroll wheel modes + you want to choose from dynamically, e.g. + + - "lights: dim" + + - "lights: color temp only"' + scroll_wheel_mode_ext_ch3: + selector: + select: + options: + - relaxed + - instant + custom_value: false + multiple: false + sort: false + default: relaxed + name: 'Channel 3: Scroll wheel evaluation mode' + description: '- : one action call after scrolling + + - : many action calls while scrolling + + + Beware: + + - for mode the 9 hidden sensor entities of Bilresa device have + to be enabled + + ' + scroll_wheel_user_action_ch3: + name: 'Channel 3: User defined scroll wheel action' + description: 'For scroll wheel mode only + + Exposed variables: + + - : for mode always 1, for mode number + between 1 to 8 + + - : ''left'' or ''right'' + + ' + default: [] + selector: + action: {} + global_light_setting: + name: Global light settings + collapsed: true + input: + dim_step_pct: + name: Dimmer step percentage + description: change in brightness in % + default: 10 + selector: + number: + mode: box + step: 1.0 + dim_min_pct: + name: Minimum brightness + description: minimum brightness in % + default: 5 + selector: + number: + mode: box + step: 1.0 + dim_turns_lights_on: + name: Dim turns lights on + description: 'allow scroll wheel (mode: dim) to turn lights on + + ' + default: false + selector: + boolean: {} + min_color_temp: + name: Minimum color temperature + description: minimum color temperature in K (for color_mode = color_temp) + default: 2200 + selector: + number: + mode: box + step: 1.0 + max_color_temp: + name: Maximum color temperature + description: maximum color temperature in K (for color_mode = color_temp) + default: 4000 + selector: + number: + mode: box + step: 1.0 + color_temp_step: + name: Color temperature step + description: change in color temperature in K (for color_mode = color_temp) + default: 100 + selector: + number: + mode: box + step: 1.0 + color_hue_step: + name: Color hue step + description: change in color hue in degree (0..360) (for color_mode = color_hue) + default: 6 + selector: + number: + mode: box + step: 1.0 + color_saturation: + name: Color saturation + description: color saturation in % (for color_mode = color_hue) + default: 100 + selector: + number: + mode: box + step: 1.0 + light_transition_duration: + name: Transition duration + description: Controls the duration of transitions + default: 0.5 + selector: + number: + mode: box + step: 1.0 + global_media_setting: + name: Global media settings + collapsed: true + input: + volume_step_pct: + name: Media player volume step percentage + description: change in volume in % + default: 3.5 + selector: + number: + mode: box + step: 1.0 + volume_use_up_down_for_instant: + name: 'Media player: use actions volume_up/volume_down for mode' + description: " mode uses standard actions volume_up/volume_down + instead of by default \n\nif this is to coarse + disable this option and adjust the value to the + lowest value which still changes the volume while scrolling in both directions\n" + default: true + selector: + boolean: {} + volume_max_pct: + name: Media player max volume percentage + description: max volume in % + default: 70 + selector: + number: + mode: box + step: 1.0 + global_misc_setting: + name: Miscellaneous settings + collapsed: true + input: + auto_reset_time_input_select_helpers: + name: Auto reset time for input_select helpers + description: 'auto reset time for input_select helpers if > 0 + + input_selects are reset to first selection + + ' + default: 0 + selector: + number: + mode: box + step: 60.0 + on_hold_delay: + name: On hold delay + description: delay between separate action calls while on hold button + default: 0.1 + selector: + number: + mode: box + step: 0.1 + max_queued_automation_calls: + name: Max queued automation calls + description: 'Increase if you want to queue more automation calls for special + cases + + ' + default: 10 + selector: + number: + mode: box + step: 1.0 + source_url: https://gist.github.com/jhol-byte/b2731a4d2476f530d76b9ff409f7f3a4 +alias: Ikea_bilresa_scroll_wheel +trigger: +- event_type: state_changed + event_data: + entity_id: '{{ var_button_ch1 }}' + trigger: event + id: button_ch1 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_left_ch1 }}' + trigger: event + id: scroll_left_ch1 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_right_ch1 }}' + trigger: event + id: scroll_right_ch1 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_left_ext_ch1 }}' + trigger: event + id: scroll_left_ext_ch1 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_right_ext_ch1 }}' + trigger: event + id: scroll_right_ext_ch1 +- event_type: state_changed + event_data: + entity_id: '{{ var_button_ch2 }}' + trigger: event + id: button_ch2 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_left_ch2 }}' + trigger: event + id: scroll_left_ch2 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_right_ch2 }}' + trigger: event + id: scroll_right_ch2 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_left_ext_ch2 }}' + trigger: event + id: scroll_left_ext_ch2 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_right_ext_ch2 }}' + trigger: event + id: scroll_right_ext_ch2 +- event_type: state_changed + event_data: + entity_id: '{{ var_button_ch3 }}' + trigger: event + id: button_ch3 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_left_ch3 }}' + trigger: event + id: scroll_left_ch3 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_right_ch3 }}' + trigger: event + id: scroll_right_ch3 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_left_ext_ch3 }}' + trigger: event + id: scroll_left_ext_ch3 +- event_type: state_changed + event_data: + entity_id: '{{ var_scroll_right_ext_ch3 }}' + trigger: event + id: scroll_right_ext_ch3 +condition: +- condition: template + value_template: "{{ trigger.event.data.old_state.state != 'unavailable' \n and + trigger.event.data.new_state.state != 'unavailable'\n}}" +action: +- variables: + channel: "{% if trigger.id in ['button_ch1', 'scroll_left_ch1', 'scroll_right_ch1', + 'scroll_left_ext_ch1', 'scroll_right_ext_ch1'] %}\n ch1\n{% elif trigger.id + in ['button_ch2', 'scroll_left_ch2', 'scroll_right_ch2', 'scroll_left_ext_ch2', + 'scroll_right_ext_ch2'] %}\n ch2\n{% elif trigger.id in ['button_ch3', 'scroll_left_ch3', + 'scroll_right_ch3', 'scroll_left_ext_ch3', 'scroll_right_ext_ch3'] %}\n ch3\n{% + endif %}" +- if: '{{ var_reset_time_input_select_helpers > 0 and last_triggered_seconds > var_reset_time_input_select_helpers + }}' + then: + repeat: + for_each: + - ch1 + - ch2 + - ch3 + sequence: + - action: input_select.select_first + target: + entity_id: '{{ vars[''scroll_wheel_mode_dynamic_'' ~ repeat.item ] }}' +- choose: + - conditions: + - condition: template + value_template: '{{ trigger.id == (''button_'' ~ channel) and trigger.event.data.new_state.attributes.event_type + == ''multi_press_1'' }} + + ' + sequence: + - if: '{{ channel == ''ch1''}}' + then: !input click_action_ch1 + - if: '{{ channel == ''ch2''}}' + then: !input click_action_ch2 + - if: '{{ channel == ''ch3''}}' + then: !input click_action_ch3 + - conditions: + - condition: template + value_template: '{{ trigger.id == (''button_'' ~ channel) and trigger.event.data.new_state.attributes.event_type + == ''multi_press_2'' }} + + ' + sequence: + - if: '{{ channel == ''ch1''}}' + then: !input double_click_action_ch1 + - if: '{{ channel == ''ch2''}}' + then: !input double_click_action_ch2 + - if: '{{ channel == ''ch3''}}' + then: !input double_click_action_ch3 + - conditions: + - condition: template + value_template: '{{ trigger.id == (''button_'' ~ channel) and trigger.event.data.new_state.attributes.event_type + == ''multi_press_3'' }} + + ' + sequence: + - if: '{{ channel == ''ch1''}}' + then: !input triple_click_action_ch1 + - if: '{{ channel == ''ch2''}}' + then: !input triple_click_action_ch2 + - if: '{{ channel == ''ch3''}}' + then: !input triple_click_action_ch3 + - conditions: + - condition: template + value_template: '{{ trigger.id == (''button_'' ~ channel) and trigger.event.data.new_state.attributes.event_type + == ''long_press'' }} + + ' + sequence: + - if: '{{ channel == ''ch1''}}' + then: + sequence: + - sequence: !input long_click_action_ch1 + - repeat: + while: '{{ repeat.index < 100 and state_attr(var_button_ch1, ''event_type'') + == ''long_press'' }}' + sequence: + - sequence: !input on_hold_action_ch1 + - delay: '{{ var_on_hold_delay }}' + - if: '{{ channel == ''ch2''}}' + then: + sequence: + - sequence: !input long_click_action_ch2 + - repeat: + while: '{{ repeat.index < 100 and state_attr(var_button_ch2, ''event_type'') + == ''long_press'' }}' + sequence: + - sequence: !input on_hold_action_ch2 + - delay: '{{ var_on_hold_delay }}' + - if: '{{ channel == ''ch3''}}' + then: + sequence: + - sequence: !input long_click_action_ch3 + - repeat: + while: '{{ repeat.index < 100 and state_attr(var_button_ch3, ''event_type'') + == ''long_press'' }}' + sequence: + - sequence: !input on_hold_action_ch3 + - delay: '{{ var_on_hold_delay }}' + - conditions: + - condition: template + value_template: "{{ ( (vars['scroll_wheel_mode_ext_' ~ channel] == 'relaxed') + and (trigger.id == ('scroll_left_' ~ channel) or trigger.id == ('scroll_right_' + ~ channel))) or\n ( (vars['scroll_wheel_mode_ext_' ~ channel] == 'instant') + and (trigger.id == ('scroll_left_ext_' ~ channel) or trigger.id == ('scroll_right_ext_' + ~ channel)) and trigger.event.data.new_state.state == '1' ) \n}}\n" + sequence: + - variables: + scroll_clicks: "{% if (vars['scroll_wheel_mode_ext_' ~ channel] == 'relaxed') + %}\n {{ trigger.event.data.new_state.attributes.totalNumberOfPressesCounted + }}\n{% else %}\n 1\n{% endif %}\n" + scroll_direction: "{% if (trigger.id == 'scroll_left_' ~ channel) or (trigger.id + == 'scroll_left_ext_' ~ channel)%}\n left\n{% else %}\n right\n{% endif + %}\n" + scroll_wheel_mode: '{{ vars[''scroll_wheel_mode_'' ~ channel] if vars[''scroll_wheel_mode_'' + ~ channel] != ''dynamic: choose from input_select'' else states(vars[''scroll_wheel_mode_dynamic_'' + ~ channel]) }} + + ' + - if: + - condition: template + value_template: '{{ scroll_wheel_mode == ''user defined'' }}' + then: + sequence: + - if: '{{ channel == ''ch1''}}' + then: !input scroll_wheel_user_action_ch1 + - if: '{{ channel == ''ch2''}}' + then: !input scroll_wheel_user_action_ch2 + - if: '{{ channel == ''ch3''}}' + then: !input scroll_wheel_user_action_ch3 + else: + - repeat: + for_each: '{{ vars[''targets_entities_'' ~ channel] }}' + sequence: + - choose: + - conditions: + - condition: template + value_template: '{{ scroll_wheel_mode == ''lights: dim''}}' + sequence: + - if: + - condition: template + value_template: '{{ is_state(repeat.item, ''on'') or (var_dim_turns_lights_on + and scroll_direction == ''right'') }}' + then: + - if: + - condition: template + value_template: '{{ is_state(repeat.item, ''on'') }}' + then: + - action: light.turn_on + target: + entity_id: '{{ repeat.item }}' + data: + brightness_step_pct: "{{ iif(scroll_direction == 'right', scroll_clicks + * var_dim_step_pct, -1*([scroll_clicks * var_dim_step_pct, + state_attr(repeat.item, 'brightness')*100/254\n - var_min_pct]|min)) + \n}}" + transition: '{{ var_light_transition_duration }}' + else: + - action: light.turn_on + target: + entity_id: '{{ repeat.item }}' + data: + transition: '{{ var_light_transition_duration }}' + - conditions: + - condition: template + value_template: '{{ scroll_wheel_mode == ''lights: color temp only'' + or (scroll_wheel_mode == ''lights: color temp and color hue'' and + state_attr(repeat.item, ''color_temp_kelvin'') is not none ) }}' + sequence: + - action: script.light_color_temp_helper + data: + mode: '{{ iif(scroll_direction == ''left'', ''down'', ''up'') }}' + min_color_temp: '{{ var_min_color_temp }}' + max_color_temp: '{{ var_max_color_temp }}' + color_temp_step: '{{ var_color_temp_step * scroll_clicks }}' + target_light: '{{ repeat.item }}' + light_transition_duration: '{{ var_light_transition_duration }}' + - conditions: + - condition: template + value_template: '{{ scroll_wheel_mode == ''lights: color hue only'' + or (scroll_wheel_mode == ''lights: color temp and color hue'' and + state_attr(repeat.item, ''color_temp_kelvin'') is none ) }}' + sequence: + - action: script.light_color_hs_helper + data: + target_light: '{{ repeat.item }}' + light_transition_duration: '{{ var_light_transition_duration }}' + color_hue_step: '{{ var_color_hue_step * scroll_clicks }}' + color_saturation: '{{ var_color_saturation }}' + mode: '{{ iif(scroll_direction == ''left'', ''down'', ''up'') }}' + - conditions: + - condition: template + value_template: '{{ scroll_wheel_mode == ''media: volume control'' + and (is_state(repeat.item, ''playing'') or is_state(repeat.item, + ''on'') )}}' + sequence: + - if: + - condition: template + value_template: '{{ vars[''scroll_wheel_mode_ext_'' ~ channel] == + ''relaxed'' or not var_volume_use_up_down_for_instant}}' + then: + - action: media_player.volume_set + target: + entity_id: '{{ repeat.item }}' + data: + volume_level: "{{ [[state_attr(repeat.item, 'volume_level')*100 + + \n iif(scroll_direction == 'right', 1, -1)* scroll_clicks + * var_volume_step_pct, var_volume_max_pct] | min, 0] | max / + 100\n}}" + else: + - if: + - condition: template + value_template: '{{ scroll_direction == ''left'' }}' + then: + - action: media_player.volume_down + target: + entity_id: '{{ repeat.item }}' + else: + - if: + - condition: template + value_template: '{{ state_attr(repeat.item, ''volume_level'')*100 + < var_volume_max_pct }}' + then: + - action: media_player.volume_up + target: + entity_id: '{{ repeat.item }}' +mode: queued +max: !input max_queued_automation_calls +max_exceeded: silent +variables: + vars: + targets_entities_ch1: !input scroll_wheel_target_ch1 + targets_entities_ch2: !input scroll_wheel_target_ch2 + targets_entities_ch3: !input scroll_wheel_target_ch3 + scroll_wheel_mode_ch1: !input scroll_wheel_mode_ch1 + scroll_wheel_mode_ch2: !input scroll_wheel_mode_ch2 + scroll_wheel_mode_ch3: !input scroll_wheel_mode_ch3 + scroll_wheel_mode_ext_ch1: !input scroll_wheel_mode_ext_ch1 + scroll_wheel_mode_ext_ch2: !input scroll_wheel_mode_ext_ch2 + scroll_wheel_mode_ext_ch3: !input scroll_wheel_mode_ext_ch3 + scroll_wheel_mode_dynamic_ch1: !input scroll_wheel_mode_dynamic_ch1 + scroll_wheel_mode_dynamic_ch2: !input scroll_wheel_mode_dynamic_ch2 + scroll_wheel_mode_dynamic_ch3: !input scroll_wheel_mode_dynamic_ch3 + var_min_pct: !input dim_min_pct + var_dim_step_pct: !input dim_step_pct + var_dim_turns_lights_on: !input dim_turns_lights_on + var_color_hue_step: !input color_hue_step + var_color_saturation: !input color_saturation + var_min_color_temp: !input min_color_temp + var_max_color_temp: !input max_color_temp + var_color_temp_step: !input color_temp_step + var_light_transition_duration: !input light_transition_duration + var_on_hold_delay: !input on_hold_delay + var_volume_step_pct: !input volume_step_pct + var_volume_max_pct: !input volume_max_pct + var_volume_use_up_down_for_instant: !input volume_use_up_down_for_instant + var_reset_time_input_select_helpers: !input auto_reset_time_input_select_helpers + last_triggered_seconds: "{{ as_timestamp(now()) - as_timestamp(this.attributes.last_triggered, + 0) }} \n" +trigger_variables: + var_remote: !input remote + var_button_ch1: "{% for item in device_entities(var_remote) %} {% set match = item + | regex_findall(find='^event\\..+[a-zA-Z]_3.*$') %} {% if (match | length) > 0 + %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_left_ch1: "{% for item in device_entities(var_remote) %} {% set match + = item | regex_findall(find='^event\\..+[a-zA-Z]_2.*$') %} {% if (match | length) + > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_right_ch1: "{% for item in device_entities(var_remote) %} {% set match + = item | regex_findall(find='^event\\..+[a-zA-Z]_1.*$') %} {% if (match | length) + > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_left_ext_ch1: "{% for item in device_entities(var_remote) %} {% set match + = item | regex_findall(find='^sensor\\..+[a-zA-Z]_2.*$') %} {% if (match | length) + > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_right_ext_ch1: "{% for item in device_entities(var_remote) %} {% set + match = item | regex_findall(find='^sensor\\..+[a-zA-Z]_1.*$') %} {% if (match + | length) > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_button_ch2: "{% for item in device_entities(var_remote) %} {% set match = item + | regex_findall(find='^event\\..+[a-zA-Z]_6.*$') %} {% if (match | length) > 0 + %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_left_ch2: "{% for item in device_entities(var_remote) %} {% set match + = item | regex_findall(find='^event\\..+[a-zA-Z]_5.*$') %} {% if (match | length) + > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_right_ch2: "{% for item in device_entities(var_remote) %} {% set match + = item | regex_findall(find='^event\\..+[a-zA-Z]_4.*$') %} {% if (match | length) + > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_left_ext_ch2: "{% for item in device_entities(var_remote) %} {% set match + = item | regex_findall(find='^sensor\\..+[a-zA-Z]_5.*$') %} {% if (match | length) + > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_right_ext_ch2: "{% for item in device_entities(var_remote) %} {% set + match = item | regex_findall(find='^sensor\\..+[a-zA-Z]_4.*$') %} {% if (match + | length) > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_button_ch3: "{% for item in device_entities(var_remote) %} {% set match = item + | regex_findall(find='^event\\..+[a-zA-Z]_9.*$') %} {% if (match | length) > 0 + %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_left_ch3: "{% for item in device_entities(var_remote) %} {% set match + = item | regex_findall(find='^event\\..+[a-zA-Z]_8.*$') %} {% if (match | length) + > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_right_ch3: "{% for item in device_entities(var_remote) %} {% set match + = item | regex_findall(find='^event\\..+[a-zA-Z]_7.*$') %} {% if (match | length) + > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_left_ext_ch3: "{% for item in device_entities(var_remote) %} {% set match + = item | regex_findall(find='^sensor\\..+[a-zA-Z]_8.*$') %} {% if (match | length) + > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" + var_scroll_right_ext_ch3: "{% for item in device_entities(var_remote) %} {% set + match = item | regex_findall(find='^sensor\\..+[a-zA-Z]_7.*$') %} {% if (match + | length) > 0 %}\n {{ match[0] }}\n{% endif %} {% endfor %}" diff --git a/blueprints/automation/mcinnes01/ikea-e2490-bilresa-scroll-wheel.yaml b/blueprints/automation/mcinnes01/ikea-e2490-bilresa-scroll-wheel.yaml new file mode 100644 index 0000000..1c65976 --- /dev/null +++ b/blueprints/automation/mcinnes01/ikea-e2490-bilresa-scroll-wheel.yaml @@ -0,0 +1,500 @@ +blueprint: + name: IKEA E2490 BILRESA Scroll Wheel (Zigbee2MQTT + ZHA) + description: 'Unified controller blueprint for IKEA E2490 BILRESA scroll wheel working + with Zigbee2MQTT and ZHA. + + - Buttons: on, off, on_double, off_double + + - Scroll: brightness_move_to_level with action_level from Zigbee2MQTT main topic, + or ZHA move_to_level args + + Supports light brightness, media_player volume, light color_temp, light hue. + + Version: 2026-01-18 + + ' + domain: automation + input: + controller_device: + name: Controller Device (Zigbee2MQTT or ZHA) + description: Select the E2490 device integrated via Zigbee2MQTT or ZHA. + selector: + device: + filter: + - integration: mqtt + - integration: zha + multiple: false + scroll_target: + name: Scroll wheel target entity (light) + selector: + entity: + filter: + - domain: + - light + multiple: false + reorder: false + scroll_mode: + name: Default scroll mode + description: Default mode when automation starts or after auto-reset timeout + default: brightness + selector: + select: + mode: dropdown + options: + - label: Brightness (light) + value: brightness + - label: Volume (media_player) + value: volume + - label: Color temperature (light) + value: color_temp + - label: Hue (light) + value: hue + sort: false + custom_value: false + multiple: false + volume_target: + name: Volume target (media_player) + default: + selector: + entity: + filter: + - domain: + - media_player + multiple: false + reorder: false + color_temp_target: + name: Color temperature target (light) + default: + selector: + entity: + filter: + - domain: + - light + multiple: false + reorder: false + hue_target: + name: Hue target (light) + default: + selector: + entity: + filter: + - domain: + - light + multiple: false + reorder: false + scroll_mode_helper: + name: (Optional) Scroll mode helper (input_select) + description: 'Provide an input_select to allow cycling scroll modes at runtime + with double ON. Must have options: brightness, volume, color_temp, hue (in + that order)' + default: + selector: + entity: + filter: + - domain: + - input_select + multiple: false + reorder: false + last_activity_helper: + name: (Optional) Last activity helper (input_datetime) + description: Provide an input_datetime to enable auto-reset after inactivity. + Works with scroll mode helper and reset timeout. + default: + selector: + entity: + filter: + - domain: + - input_datetime + multiple: false + reorder: false + reset_timeout: + name: (Optional) Auto-reset timeout (seconds) + description: Time in seconds of inactivity before resetting to default scroll + mode. Set to 0 to disable auto-reset. Requires both scroll mode helper and + last activity helper. + default: 60 + selector: + number: + min: 0.0 + max: 3600.0 + step: 1.0 + unit_of_measurement: seconds + mode: box + volume_max: + name: (Optional) Max volume + description: Upper limit for volume mode (0.0 - 1.0). Values from the wheel + will be clamped to this maximum. + default: 1.0 + selector: + number: + min: 0.0 + max: 1.0 + step: 0.01 + mode: slider + button_on_short: + name: Button ON - short press (optional) + default: [] + selector: + action: {} + button_off_short: + name: Button OFF - short press (optional) + default: [] + selector: + action: {} + button_on_double: + name: Button ON - double press (optional) + default: [] + selector: + action: {} + button_off_double: + name: Button OFF - double press (optional) + default: [] + selector: + action: {} + source_url: https://gist.github.com/mcinnes01/399132520034291b89a82659653b2893 +alias: IKEA E2490 BILRESA Scroll Wheel (Zigbee2MQTT + ZHA) +variables: + controller_device: !input controller_device + friendly_name: '{{ device_attr(controller_device, ''name'') | default('''') }}' + z2m_topic: zigbee2mqtt/{{ friendly_name }} + scroll_mode: !input scroll_mode + scroll_mode_helper: !input scroll_mode_helper + last_activity_helper: !input last_activity_helper + reset_timeout: !input reset_timeout + effective_mode: '{{ states(scroll_mode_helper) if scroll_mode_helper is not none + and states(scroll_mode_helper) != '''' else scroll_mode }}' + brightness_target: !input scroll_target + volume_target: !input volume_target + color_temp_target: !input color_temp_target + hue_target: !input hue_target + volume_max: !input volume_max + available_modes: '{% set modes = [] %} {% if brightness_target is not none %}{% + set modes = modes + [''brightness''] %}{% endif %} {% if volume_target is not + none %}{% set modes = modes + [''volume''] %}{% endif %} {% if color_temp_target + is not none %}{% set modes = modes + [''color_temp''] %}{% endif %} {% if hue_target + is not none %}{% set modes = modes + [''hue''] %}{% endif %} {{ modes }} + + ' + button_on_short_seq: !input button_on_short + button_off_short_seq: !input button_off_short + button_on_double_seq: !input button_on_double + button_off_double_seq: !input button_off_double +trigger: +- platform: device + id: z2m-on + domain: mqtt + device_id: !input controller_device + type: action + subtype: 'on' +- platform: device + id: z2m-off + domain: mqtt + device_id: !input controller_device + type: action + subtype: 'off' +- platform: device + id: z2m-on-double + domain: mqtt + device_id: !input controller_device + type: action + subtype: on_double +- platform: device + id: z2m-off-double + domain: mqtt + device_id: !input controller_device + type: action + subtype: off_double +- platform: mqtt + id: z2m-payload + topic: zigbee2mqtt/# +- platform: event + id: zha-on + event_type: zha_event + event_data: + device_id: !input controller_device + command: 'on' +- platform: event + id: zha-off + event_type: zha_event + event_data: + device_id: !input controller_device + command: 'off' +- platform: event + id: zha-level + event_type: zha_event + event_data: + device_id: !input controller_device + command: move_to_level +condition: [] +action: +- if: + - condition: template + value_template: "{{ scroll_mode_helper is not none \n and last_activity_helper + is not none \n and reset_timeout > 0\n and states(last_activity_helper) + not in ['unknown', 'unavailable', ''] \n and (now() - (states(last_activity_helper) + | as_datetime)).total_seconds() > reset_timeout }}\n" + then: + - service: input_select.select_option + target: + entity_id: '{{ scroll_mode_helper }}' + data: + option: '{{ scroll_mode }}' +- choose: + - conditions: + - condition: template + value_template: "{{ trigger.id == 'z2m-payload' \n and trigger.topic == z2m_topic + \n and trigger.payload_json is defined \n and trigger.payload_json.action + == 'brightness_move_to_level' }}\n" + sequence: + - variables: + lvl: '{{ trigger.payload_json.action_level | default(none) }}' + trans: '{{ trigger.payload_json.action_transition_time | default(0) }}' + - choose: + - conditions: + - condition: template + value_template: '{{ effective_mode == ''brightness'' and brightness_target + is not none }}' + sequence: + - service: light.turn_on + target: + entity_id: '{{ brightness_target }}' + data: + brightness: '{% if lvl is none %}255{% else %}{{ ((lvl|float/241.0)*255.0)|round(0)|int + }}{% endif %} + + ' + transition: '{{ trans }}' + - if: + - condition: template + value_template: '{{ last_activity_helper is not none }}' + then: + - service: input_datetime.set_datetime + target: + entity_id: '{{ last_activity_helper }}' + data: + timestamp: '{{ now().timestamp() }}' + - conditions: + - condition: template + value_template: '{{ effective_mode == ''volume'' and volume_target is not + none }}' + sequence: + - service: media_player.volume_set + target: + entity_id: '{{ volume_target }}' + data: + volume_level: "{% if lvl is none %}\n {{ volume_max }}\n{% else %}\n + \ {{ ((lvl|float/241.0) * volume_max)|round(3) }}\n{% endif %}\n" + - if: + - condition: template + value_template: '{{ last_activity_helper is not none }}' + then: + - service: input_datetime.set_datetime + target: + entity_id: '{{ last_activity_helper }}' + data: + timestamp: '{{ now().timestamp() }}' + - conditions: + - condition: template + value_template: '{{ effective_mode == ''color_temp'' and color_temp_target + is not none }}' + sequence: + - variables: + min_mireds: '{{ state_attr(color_temp_target, ''min_mireds'') | default(153) + }}' + max_mireds: '{{ state_attr(color_temp_target, ''max_mireds'') | default(500) + }}' + - service: light.turn_on + target: + entity_id: '{{ color_temp_target }}' + data: + color_temp: "{% set minm = min_mireds|int %} {% set maxm = max_mireds|int + %} {% if lvl is none %}\n {{ maxm }}\n{% else %}\n {% set ratio = + ((lvl|float - 1.0)/240.0) %}\n {{ (minm + ratio*(maxm-minm))|round(0)|int + }}\n{% endif %}\n" + - if: + - condition: template + value_template: '{{ last_activity_helper is not none }}' + then: + - service: input_datetime.set_datetime + target: + entity_id: '{{ last_activity_helper }}' + data: + timestamp: '{{ now().timestamp() }}' + - conditions: + - condition: template + value_template: '{{ effective_mode == ''hue'' and hue_target is not none + }}' + sequence: + - variables: + current_hs: '{{ state_attr(hue_target, ''hs_color'') | default([0, 100]) + }}' + current_sat: '{{ current_hs[1] if current_hs is sequence and current_hs|length + > 1 else 100 }}' + hue_val: '{% if lvl is none %}360{% else %}{{ ((lvl|float/241.0)*360.0)|round(0)|int + }}{% endif %} + + ' + - service: light.turn_on + target: + entity_id: '{{ hue_target }}' + data: + hs_color: '[ {{ hue_val }}, {{ current_sat }} ]' + - if: + - condition: template + value_template: '{{ last_activity_helper is not none }}' + then: + - service: input_datetime.set_datetime + target: + entity_id: '{{ last_activity_helper }}' + data: + timestamp: '{{ now().timestamp() }}' + - conditions: + - condition: template + value_template: '{{ trigger.id == ''zha-level'' }}' + sequence: + - variables: + lvl: '{% set a = trigger.event.data.args | default([]) %} {% if a is sequence + and a|length > 0 %}{{ a[0] }}{% else %}{{ none }}{% endif %} + + ' + trans: '{% set a = trigger.event.data.args | default([]) %} {% if a is sequence + and a|length > 1 %}{{ a[1] }}{% else %}0{% endif %} + + ' + - choose: + - conditions: + - condition: template + value_template: '{{ effective_mode == ''brightness'' and brightness_target + is not none }}' + sequence: + - service: light.turn_on + target: + entity_id: '{{ brightness_target }}' + data: + brightness: '{% if lvl is none %}255{% else %}{{ (lvl|int)|min(255)|max(1) + }}{% endif %} + + ' + transition: '{{ trans }}' + - if: + - condition: template + value_template: '{{ last_activity_helper is not none }}' + then: + - service: input_datetime.set_datetime + target: + entity_id: '{{ last_activity_helper }}' + data: + timestamp: '{{ now().timestamp() }}' + - conditions: + - condition: template + value_template: '{{ trigger.id in [''z2m-on'',''zha-on''] }}' + sequence: + - choose: + - conditions: + - condition: template + value_template: '{{ button_on_short_seq | length > 0 }}' + sequence: !input button_on_short + default: + - choose: + - conditions: + - condition: template + value_template: '{{ effective_mode == ''volume'' and volume_target is + not none }}' + sequence: + - service: media_player.turn_on + target: + entity_id: '{{ volume_target }}' + - conditions: [] + sequence: + - service: light.turn_on + target: + entity_id: !input scroll_target + - conditions: + - condition: template + value_template: '{{ trigger.id in [''z2m-off'',''zha-off''] }}' + sequence: + - choose: + - conditions: + - condition: template + value_template: '{{ button_off_short_seq | length > 0 }}' + sequence: !input button_off_short + default: + - choose: + - conditions: + - condition: template + value_template: '{{ effective_mode == ''volume'' and volume_target is + not none }}' + sequence: + - service: media_player.turn_off + target: + entity_id: '{{ volume_target }}' + - conditions: [] + sequence: + - service: light.turn_off + target: + entity_id: !input scroll_target + - conditions: + - condition: template + value_template: '{{ trigger.id == ''z2m-on-double'' }}' + sequence: + - choose: + - conditions: + - condition: template + value_template: '{{ scroll_mode_helper is not none }}' + sequence: + - variables: + current_mode: '{{ states(scroll_mode_helper) }}' + modes_list: '{{ available_modes }}' + current_index: '{{ modes_list.index(current_mode) if current_mode in modes_list + else 0 }}' + next_index: '{{ (current_index + 1) % (modes_list | length) }}' + next_mode: '{{ modes_list[next_index] if modes_list | length > 0 else + ''brightness'' }}' + - service: input_select.select_option + target: + entity_id: '{{ scroll_mode_helper }}' + data: + option: '{{ next_mode }}' + - if: + - condition: template + value_template: '{{ last_activity_helper is not none }}' + then: + - service: input_datetime.set_datetime + target: + entity_id: '{{ last_activity_helper }}' + data: + timestamp: '{{ now().timestamp() }}' + - choose: + - conditions: + - condition: template + value_template: '{{ button_on_double_seq | length > 0 }}' + sequence: !input button_on_double + - conditions: [] + sequence: + - choose: + - conditions: + - condition: template + value_template: '{{ button_on_double_seq | length > 0 }}' + sequence: !input button_on_double + - conditions: [] + sequence: + - service: light.turn_on + target: + entity_id: !input scroll_target + data: + brightness: 255 + - conditions: + - condition: template + value_template: '{{ trigger.id == ''z2m-off-double'' }}' + sequence: + - choose: + - conditions: + - condition: template + value_template: '{{ button_off_double_seq | length > 0 }}' + sequence: !input button_off_double + default: + - service: light.turn_off + target: + entity_id: !input scroll_target +mode: restart +max_exceeded: silent diff --git a/blueprints/automation/stone13/ikea_bilresa_atomic_v1.yaml b/blueprints/automation/stone13/ikea_bilresa_atomic_v1.yaml new file mode 100644 index 0000000..50acb32 --- /dev/null +++ b/blueprints/automation/stone13/ikea_bilresa_atomic_v1.yaml @@ -0,0 +1,539 @@ +blueprint: + name: IKEA Bilresa Scrollwheel - Light Control V1.0.5 + description: 'The ultimate LIGHT controller for the IKEA Bilresa Smart Scroll Wheel + via Matter. (NOT for Media/Volume! NOT for the 2-Button Remote!) + + VERSION 1.0.5: Included fix to check both the from_state and the to_state. VERSION + 1.0.4: Code cleanup. Removed ''short_release'' placebo for multi_press events. + VERSION 1.0.3: Bugfix: Fixed "ghost switching" after Home Assistant restarts. + VERSION 1.0.2: Safety update. Added template fallbacks for RGB/Kelvin shifts. + VERSION 1.0.1: Fixed "Bouncing/Jumping" issues (added debounce logic). + + YOU DECIDE EVERYTHING: - 3 Scroll Zones: Choose the function (Brightness, Kelvin, + RGB, Presets). - 3 Buttons x 2 Gestures: Choose a specific action for EACH gesture + (Double/Hold). + + REQUIREMENTS: - ENABLE Sensors: 1,2, 4,5, 7,8 - ENABLE Events: 3,6,9 - DISABLE + Sensors: 3,6,9 (Not needed, keep disabled!) + + HELPER GROUP: If controlling multiple lights, please always use a HELPER GROUP! + Directly controlling multiple entities can cause sync issues. + + ' + domain: automation + input: + light_entity: + name: Light (or Group) + selector: + entity: + domain: + - light + multiple: false + reorder: false + scroll_logic_1_2: + name: Scroll Zone 1 (Sensors 1 & 2) + default: brightness + selector: + select: + options: + - label: Brightness (Bright / Dark) + value: brightness + - label: Kelvin (Cool / Warm) + value: kelvin + - label: RGB Color (Rainbow) + value: rgb + - label: Presets (Work / Cozy) + value: preset + custom_value: false + multiple: false + sort: false + scroll_logic_4_5: + name: Scroll Zone 2 (Sensors 4 & 5) + default: kelvin + selector: + select: + options: + - label: Brightness (Bright / Dark) + value: brightness + - label: Kelvin (Cool / Warm) + value: kelvin + - label: RGB Color (Rainbow) + value: rgb + - label: Presets (Work / Cozy) + value: preset + custom_value: false + multiple: false + sort: false + scroll_logic_7_8: + name: Scroll Zone 3 (Sensors 7 & 8) + default: preset + selector: + select: + options: + - label: Brightness (Bright / Dark) + value: brightness + - label: Kelvin (Cool / Warm) + value: kelvin + - label: RGB Color (Rainbow) + value: rgb + - label: Presets (Work / Cozy) + value: preset + custom_value: false + multiple: false + sort: false + btn1_double: + name: 'Button 1: Double Click Action' + default: set_bright + selector: + select: + options: + - label: -> Bright (80%) + value: set_bright + - label: -> Cool White (4000K) + value: set_cold + - label: -> White (RGB Reset) + value: set_white + - label: -> Red (Signal Color) + value: set_red + - label: -> No Action + value: none + custom_value: false + multiple: false + sort: false + btn1_hold: + name: 'Button 1: Hold Action' + default: set_dim + selector: + select: + options: + - label: -> Dimmed (20%) + value: set_dim + - label: -> Warm White (2200K) + value: set_warm + - label: -> Enable Color (Saturation On) + value: set_color_on + - label: -> No Action + value: none + custom_value: false + multiple: false + sort: false + btn2_double: + name: 'Button 2: Double Click Action' + default: set_cold + selector: + select: + options: + - label: -> Bright (80%) + value: set_bright + - label: -> Cool White (4000K) + value: set_cold + - label: -> White (RGB Reset) + value: set_white + - label: -> Red (Signal Color) + value: set_red + - label: -> No Action + value: none + custom_value: false + multiple: false + sort: false + btn2_hold: + name: 'Button 2: Hold Action' + default: set_warm + selector: + select: + options: + - label: -> Dimmed (20%) + value: set_dim + - label: -> Warm White (2200K) + value: set_warm + - label: -> Enable Color (Saturation On) + value: set_color_on + - label: -> No Action + value: none + custom_value: false + multiple: false + sort: false + btn3_double: + name: 'Button 3: Double Click Action' + default: set_bright + selector: + select: + options: + - label: -> Bright (80%) + value: set_bright + - label: -> Cool White (4000K) + value: set_cold + - label: -> White (RGB Reset) + value: set_white + - label: -> Red (Signal Color) + value: set_red + - label: -> No Action + value: none + custom_value: false + multiple: false + sort: false + btn3_hold: + name: 'Button 3: Hold Action' + default: set_dim + selector: + select: + options: + - label: -> Dimmed (20%) + value: set_dim + - label: -> Warm White (2200K) + value: set_warm + - label: -> Enable Color (Saturation On) + value: set_color_on + - label: -> No Action + value: none + custom_value: false + multiple: false + sort: false + step_pct: + name: 'Step Size: Brightness (%)' + default: 10 + selector: + number: + min: 1.0 + max: 50.0 + unit_of_measurement: '%' + step: 1.0 + mode: slider + step_kelvin: + name: 'Step Size: Kelvin (K)' + default: 400 + selector: + number: + min: 100.0 + max: 1000.0 + unit_of_measurement: K + step: 1.0 + mode: slider + step_hue: + name: 'Step Size: RGB Hue (°)' + default: 15 + selector: + number: + min: 5.0 + max: 90.0 + unit_of_measurement: ° + step: 1.0 + mode: slider + val_bright: + name: 'Value: Bright (%)' + default: 80 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + val_dim: + name: 'Value: Dim (%)' + default: 20 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + val_cold: + name: 'Value: Cool (K)' + default: 4000 + selector: + number: + min: 2000.0 + max: 6500.0 + step: 1.0 + mode: slider + val_warm: + name: 'Value: Warm (K)' + default: 2200 + selector: + number: + min: 2000.0 + max: 6500.0 + step: 1.0 + mode: slider + sensor_1: + name: Sensor 1 (Look for '(1)') + selector: + entity: + domain: + - sensor + multiple: false + reorder: false + sensor_2: + name: Sensor 2 (Look for '(2)') + selector: + entity: + domain: + - sensor + multiple: false + reorder: false + sensor_4: + name: Sensor 4 (Look for '(4)') + selector: + entity: + domain: + - sensor + multiple: false + reorder: false + sensor_5: + name: Sensor 5 (Look for '(5)') + selector: + entity: + domain: + - sensor + multiple: false + reorder: false + sensor_7: + name: Sensor 7 (Look for '(7)') + selector: + entity: + domain: + - sensor + multiple: false + reorder: false + sensor_8: + name: Sensor 8 (Look for '(8)') + selector: + entity: + domain: + - sensor + multiple: false + reorder: false + event_taste_1: + name: Event Button 1 (Look for '(3)') + selector: + entity: + domain: + - event + multiple: false + reorder: false + event_taste_2: + name: Event Button 2 (Look for '(6)') + selector: + entity: + domain: + - event + multiple: false + reorder: false + event_taste_3: + name: Event Button 3 (Look for '(9)') + selector: + entity: + domain: + - event + multiple: false + reorder: false + source_url: https://gist.github.com/stone13/63a778bdbf55efbd712ade1d074e9dbb +mode: single +max_exceeded: silent +variables: + target_light: !input light_entity + m_sc_1: !input scroll_logic_1_2 + m_sc_2: !input scroll_logic_4_5 + m_sc_3: !input scroll_logic_7_8 + b1_d: !input btn1_double + b1_h: !input btn1_hold + b2_d: !input btn2_double + b2_h: !input btn2_hold + b3_d: !input btn3_double + b3_h: !input btn3_hold + s_p: !input step_pct + s_k: !input step_kelvin + s_h: !input step_hue + v_bri: !input val_bright + v_dim: !input val_dim + v_cold: !input val_cold + v_warm: !input val_warm + id_e1: !input event_taste_1 + id_e2: !input event_taste_2 + id_e3: !input event_taste_3 +triggers: +- trigger: state + entity_id: !input sensor_1 + id: sc_1_up +- trigger: state + entity_id: !input sensor_2 + id: sc_1_down +- trigger: state + entity_id: !input sensor_4 + id: sc_2_down +- trigger: state + entity_id: !input sensor_5 + id: sc_2_up +- trigger: state + entity_id: !input sensor_7 + id: sc_3_up +- trigger: state + entity_id: !input sensor_8 + id: sc_3_down +- trigger: state + entity_id: + - !input event_taste_1 + - !input event_taste_2 + - !input event_taste_3 + id: btn_event +conditions: +- condition: template + value_template: "{{ trigger.from_state is defined and trigger.to_state is defined + and\n trigger.from_state.state not in ['unknown', 'unavailable'] and\n trigger.to_state.state + not in ['unknown', 'unavailable'] }}" +actions: +- choose: + - conditions: '{{ ''sc_'' in trigger.id }}' + sequence: + - variables: + direction: '{% if ''_up'' in trigger.id %} 1 {% else %} -1 {% endif %} + + ' + active_func: '{% if ''sc_1'' in trigger.id %} {{ m_sc_1 }} {% elif ''sc_2'' + in trigger.id %} {{ m_sc_2 }} {% elif ''sc_3'' in trigger.id %} {{ m_sc_3 + }} {% endif %} + + ' + - choose: + - conditions: '{{ active_func == ''brightness'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + brightness_step_pct: '{{ (s_p | float(default=10)) * direction }}' + transition: 0 + - conditions: '{{ active_func == ''kelvin'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + color_temp_kelvin: '{% set current = state_attr(target_light, ''color_temp_kelvin'') + | int(default=3000) %} {% set step = s_k | int(default=400) %} {% if + direction == 1 %} {{ ([current + step, 6500] | min) | int }} {% else + %} {{ ([current - step, 2000] | max) | int }} {% endif %} + + ' + transition: 0 + - conditions: '{{ active_func == ''rgb'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + hs_color: '{% set current_hs = state_attr(target_light, ''hs_color'') + %} {% set h = current_hs[0] | float(default=0) if current_hs is not + none else 0 %} {% set s = current_hs[1] | float(default=100) if current_hs + is not none else 100 %} {% set step = s_h | float(default=15) %} {{ + [(h + (step * direction)) % 360, s] }} + + ' + transition: 0 + - conditions: '{{ active_func == ''preset'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + brightness_pct: '{{ v_bri if direction == 1 else v_dim }}' + color_temp_kelvin: '{{ v_cold if direction == 1 else v_warm }}' + transition: 1 +- choose: + - conditions: '{{ trigger.id == ''btn_event'' }}' + sequence: + - choose: + - conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type + == 'multi_press_1' }}\n" + sequence: + - action: light.toggle + target: + entity_id: '{{ target_light }}' + data: + transition: 0.5 + - choose: + - conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type + == 'multi_press_2' }}\n" + sequence: + - variables: + action_code: '{% if trigger.entity_id == id_e1 %} {{ b1_d }} {% elif trigger.entity_id + == id_e2 %} {{ b2_d }} {% elif trigger.entity_id == id_e3 %} {{ b3_d + }} {% endif %} + + ' + - choose: + - conditions: '{{ action_code == ''set_bright'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + brightness_pct: '{{ v_bri }}' + transition: 0.5 + - conditions: '{{ action_code == ''set_cold'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + color_temp_kelvin: '{{ v_cold }}' + transition: 0.5 + - conditions: '{{ action_code == ''set_white'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + color_temp_kelvin: 4000 + transition: 0.5 + - conditions: '{{ action_code == ''set_red'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + hs_color: + - 0 + - 100 + brightness_pct: 80 + transition: 0.5 + - choose: + - conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type + == 'long_press' }}\n" + sequence: + - variables: + action_code: '{% if trigger.entity_id == id_e1 %} {{ b1_h }} {% elif trigger.entity_id + == id_e2 %} {{ b2_h }} {% elif trigger.entity_id == id_e3 %} {{ b3_h + }} {% endif %} + + ' + - choose: + - conditions: '{{ action_code == ''set_dim'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + brightness_pct: '{{ v_dim }}' + transition: 0.5 + - conditions: '{{ action_code == ''set_warm'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + color_temp_kelvin: '{{ v_warm }}' + transition: 0.5 + - conditions: '{{ action_code == ''set_color_on'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + hs_color: + - 0 + - 100 + brightness_pct: 80 + transition: 0.5 +- delay: + milliseconds: 150 diff --git a/blueprints/automation/thetestspecimen/ikea-bilresa-scroll-wheel.yaml b/blueprints/automation/thetestspecimen/ikea-bilresa-scroll-wheel.yaml new file mode 100644 index 0000000..57c84a3 --- /dev/null +++ b/blueprints/automation/thetestspecimen/ikea-bilresa-scroll-wheel.yaml @@ -0,0 +1,920 @@ +blueprint: + name: IKEA Bilresa Scrollwheel - Light Control + author: thetestspecimen + source_url: https://github.com/thetestspecimen/homeassistant/blob/main/blueprints/ikea-bilresa-scroll-wheel.yaml + description: "\n# \U0001F4A1 Light controller \U0001F4A1\n## IKEA BILRESA - Smart + Scroll Wheel utilising Matter over Thread\n\n\n## VARIABLES:\n- 3 Scroll Zones: + Choose the function (Brightness, Kelvin, RGB, Presets).\n- 3 Buttons x 3 Gestures: + Choose a specific action for EACH gesture (double click / triple click / hold).\n\n***Note:*** + single click will always toggle the light on or off.\n\n## REQUIREMENTS:\n\nFor + optimal behavior, the following settings must be applied:\n- ENABLE Events: 3,6,9 + (in section \"Events\")\n- ENABLE Sensors: 1,2,4,5,7,8 (in section \"Diagnostic\")\n- + DISABLE Events: 1,2,4,5,7,8 (in section \"Events\")\n- DISABLE Sensors: 3,6,9 + (in section \"Diagnostic\")\n\n***Note:*** this blueprint cannot be used with + the BILRESA 2-Button Remote.\n" + homeassistant: + min_version: 2024.6.0 + domain: automation + input: + header_entity_mapping: + name: Entity Mapping [REQUIRED] + description: Select the entities for the scroll wheel and buttons + default: '' + selector: + constant: + value: '' + group_buttons: + name: Buttons + icon: mdi:gesture-tap + collapsed: true + input: + event_button_1: + name: 'Channel 1: Button' + description: 'Event: Look for ''Button (3)''' + selector: + entity: + filter: + - domain: + - event + integration: matter + device_class: + - button + multiple: false + reorder: false + event_button_2: + name: 'Channel 2: Button' + description: 'Event: Look for ''Button (6)''' + selector: + entity: + filter: + - domain: + - event + integration: matter + device_class: + - button + multiple: false + reorder: false + event_button_3: + name: 'Channel 3: Button' + description: 'Event: Look for ''Button (9)''' + selector: + entity: + filter: + - domain: + - event + integration: matter + device_class: + - button + multiple: false + reorder: false + group_scroll_wheel: + name: Scroll Wheel + icon: mdi:rotate-right + collapsed: true + input: + sensor_1: + name: 'Channel 1: Scroll forwards' + description: 'Sensor: Look for ''Current switch position (1)''' + selector: + entity: + filter: + - domain: + - sensor + integration: matter + multiple: false + reorder: false + sensor_2: + name: 'Channel 1: Scroll backwards' + description: 'Sensor: Look for ''Current switch position (2)''' + selector: + entity: + filter: + - domain: + - sensor + integration: matter + multiple: false + reorder: false + sensor_4: + name: 'Channel 2: Scroll forwards' + description: 'Sensor: Look for ''Current switch position (4)''' + selector: + entity: + filter: + - domain: + - sensor + integration: matter + multiple: false + reorder: false + sensor_5: + name: 'Channel 2: Scroll backwards' + description: 'Sensor: Look for ''Current switch position (5)''' + selector: + entity: + filter: + - domain: + - sensor + integration: matter + multiple: false + reorder: false + sensor_7: + name: 'Channel 3: Scroll forwards' + description: 'Sensor: Look for ''Current switch position (7)''' + selector: + entity: + filter: + - domain: + - sensor + integration: matter + multiple: false + reorder: false + sensor_8: + name: 'Channel 3: Scroll backwards' + description: 'Sensor: Look for ''Current switch position (8)''' + selector: + entity: + filter: + - domain: + - sensor + integration: matter + multiple: false + reorder: false + header_default_light: + name: Default Light + default: '' + selector: + constant: + value: '' + global_light: + name: Global Light - Default [REQUIRED] + description: This light will be used for any zone or button that does not have + a specific override light set below. + selector: + entity: + domain: + - light + multiple: true + reorder: false + header_1: + name: Channel 1 + default: '' + selector: + constant: + value: '' + group_scroll_1: + name: Scroll Wheel Settings + icon: mdi:rotate-right + collapsed: true + input: + light_z1: + name: Light (Scroll - Channel 1) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + scroll_logic_1_2: + name: Scroll - Channel 1 + description: Sensors 1 & 2 + default: brightness + selector: + select: + options: + - label: Brightness (Bright / Dark) + value: brightness + - label: Kelvin (Cool / Warm) + value: kelvin + - label: RGB Colour (Rainbow) + value: rgb + - label: Presets (Work / Cozy) + value: preset + sort: false + custom_value: false + multiple: false + group_button_1_single: + name: 'Button 1: Single Click (Toggle On/Off)' + icon: mdi:gesture-tap + collapsed: true + input: + light_b1_single: + name: Light (Button 1 Single Click) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + group_button_1_double: + name: 'Button 1: Double Click' + icon: mdi:gesture-double-tap + collapsed: true + input: + light_b1_double: + name: Light (Button 1 Double Click) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + btn1_double_bright: + name: 'Button 1: Double Click Brightness (%)' + default: 80 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + btn1_double_temp: + name: 'Button 1: Double Click Colour Temp (K)' + default: 4000 + selector: + color_temp: + unit: kelvin + min: 2000 + max: 6500 + group_button_1_triple: + name: 'Button 1: Triple Click' + icon: mdi:gesture-tap + collapsed: true + input: + light_b1_triple: + name: Light (Button 1 Triple Click) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + btn1_triple_bright: + name: 'Button 1: Triple Click Brightness (%)' + default: 100 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + btn1_triple_temp: + name: 'Button 1: Triple Click Colour Temp (K)' + default: 6000 + selector: + color_temp: + unit: kelvin + min: 2000 + max: 6500 + group_button_1_hold: + name: 'Button 1: Hold' + icon: mdi:gesture-tap-hold + collapsed: true + input: + light_b1_hold: + name: Light (Button 1 Hold) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + btn1_hold_bright: + name: 'Button 1: Hold Brightness (%)' + default: 20 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + btn1_hold_temp: + name: 'Button 1: Hold Colour Temp' + default: 2200 + selector: + color_temp: + unit: kelvin + min: 2000 + max: 6500 + header_2: + name: Channel 2 + default: '' + selector: + constant: + value: '' + group_scroll_2: + name: Scroll Wheel Settings + icon: mdi:rotate-right + collapsed: true + input: + light_z2: + name: Light (Scroll - Channel 2) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + scroll_logic_4_5: + name: Scroll - Channel 2 + description: Sensors 4 & 5 + default: kelvin + selector: + select: + options: + - label: Brightness (Bright / Dark) + value: brightness + - label: Kelvin (Cool / Warm) + value: kelvin + - label: RGB Colour (Rainbow) + value: rgb + - label: Presets (Work / Cozy) + value: preset + sort: false + custom_value: false + multiple: false + group_button_2_single: + name: 'Button 2: Single Click (Toggle On/Off)' + icon: mdi:gesture-tap + collapsed: true + input: + light_b2_single: + name: Light (Button 2 Single Click) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + group_button_2_double: + name: 'Button 2: Double Click' + icon: mdi:gesture-double-tap + collapsed: true + input: + light_b2_double: + name: Light (Button 2 Double Click) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + btn2_double_bright: + name: 'Button 2: Double Click Brightness (%)' + default: 80 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + btn2_double_temp: + name: 'Button 2: Double Click Colour Temp (K)' + default: 4000 + selector: + color_temp: + unit: kelvin + min: 2000 + max: 6500 + group_button_2_triple: + name: 'Button 2: Triple Click' + icon: mdi:gesture-tap + collapsed: true + input: + light_b2_triple: + name: Light (Button 2 Triple Click) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + btn2_triple_bright: + name: 'Button 2: Triple Click Brightness (%)' + default: 100 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + btn2_triple_temp: + name: 'Button 2: Triple Click Colour Temp (K)' + default: 6000 + selector: + color_temp: + unit: kelvin + min: 2000 + max: 6500 + group_button_2_hold: + name: 'Button 2: Hold' + icon: mdi:gesture-tap-hold + collapsed: true + input: + light_b2_hold: + name: Light (Button 2 Hold) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + btn2_hold_bright: + name: 'Button 2: Hold Brightness (%)' + default: 20 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + btn2_hold_temp: + name: 'Button 2: Hold Colour Temp (K)' + default: 2200 + selector: + color_temp: + unit: kelvin + min: 2000 + max: 6500 + header_3: + name: Channel 3 + default: '' + selector: + constant: + value: '' + group_scroll_3: + name: Scroll Wheel Settings + icon: mdi:rotate-right + collapsed: true + input: + light_z3: + name: Light (Scroll - Channel 3) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + scroll_logic_7_8: + name: Scroll - Channel 3 + description: Sensors 7 & 8 + default: preset + selector: + select: + options: + - label: Brightness (Bright / Dark) + value: brightness + - label: Kelvin (Cool / Warm) + value: kelvin + - label: RGB Colour (Rainbow) + value: rgb + - label: Presets (Work / Cozy) + value: preset + sort: false + custom_value: false + multiple: false + group_button_3_single: + name: 'Button 3: Single Click (Toggle On/Off)' + icon: mdi:gesture-tap + collapsed: true + input: + light_b3_single: + name: Light (Button 3 Single Click) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + group_button_3_double: + name: 'Button 3: Double Click' + icon: mdi:gesture-double-tap + collapsed: true + input: + light_b3_double: + name: Light (Button 3 Double Click) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + btn3_double_bright: + name: 'Button 3: Double Click Brightness (%)' + default: 80 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + btn3_double_temp: + name: 'Button 3: Double Click Colour Temp (K)' + default: 4000 + selector: + color_temp: + unit: kelvin + min: 2000 + max: 6500 + group_button_3_triple: + name: 'Button 3: Triple Click' + icon: mdi:gesture-tap + collapsed: true + input: + light_b3_triple: + name: Light (Button 3 Triple Click) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + btn3_triple_bright: + name: 'Button 3: Triple Click Brightness (%)' + default: 100 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + btn3_triple_temp: + name: 'Button 3: Triple Click Colour Temp (K)' + default: 6000 + selector: + color_temp: + unit: kelvin + min: 2000 + max: 6500 + group_button_3_hold: + name: 'Button 3: Hold' + icon: mdi:gesture-tap-hold + collapsed: true + input: + light_b3_hold: + name: Light (Button 3 Hold) - Optional Override + default: '' + selector: + entity: + domain: + - light + multiple: true + reorder: false + btn3_hold_bright: + name: 'Button 3: Hold Brightness (%)' + default: 20 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + btn3_hold_temp: + name: 'Button 3: Hold Colour Temp (K)' + default: 2200 + selector: + color_temp: + unit: kelvin + min: 2000 + max: 6500 + header_tuning: + name: Fine Tuning + default: '' + selector: + constant: + value: '' + group_step_size: + name: Step Sizes + icon: mdi:tune + collapsed: true + input: + step_pct: + name: 'Step Size: Brightness (%)' + default: 10 + selector: + number: + min: 1.0 + max: 50.0 + unit_of_measurement: '%' + step: 1.0 + mode: slider + step_kelvin: + name: 'Step Size: Kelvin (K)' + default: 400 + selector: + number: + min: 100.0 + max: 1000.0 + unit_of_measurement: K + step: 1.0 + mode: slider + step_hue: + name: 'Step Size: RGB Hue (°)' + default: 15 + selector: + number: + min: 5.0 + max: 90.0 + unit_of_measurement: ° + step: 1.0 + mode: slider + group_values: + name: Preset Values + icon: mdi:brightness-6 + collapsed: true + input: + val_bright: + name: 'Work: Brightness (%)' + description: The brightness value to use for the 'Work' preset + default: 80 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + val_cold: + name: 'Work: Colour Temperature (K)' + description: The colour temperature value to use for the 'Work' preset + default: 4000 + selector: + color_temp: + unit: kelvin + min: 2000 + max: 6500 + val_dim: + name: 'Cozy: Brightness (%)' + description: The brightness value to use for the 'Cozy' preset + default: 20 + selector: + number: + min: 1.0 + max: 100.0 + step: 1.0 + mode: slider + val_warm: + name: 'Cozy: Colour Temperature (K)' + description: The colour temperature value to use for the 'Cozy' preset + default: 2200 + selector: + color_temp: + unit: kelvin + min: 2000 + max: 6500 + group_timings: + name: Timings + icon: mdi:clock + collapsed: true + input: + transition_time: + name: Button Transition Time (Seconds) + default: 0.5 + selector: + number: + min: 0.0 + max: 5.0 + step: 0.1 + mode: slider + delay_time: + name: Delay (Milliseconds) + description: Delay between command actions. This is effectively button debounce. + default: 150 + selector: + number: + min: 0.0 + max: 1000.0 + step: 10.0 + mode: slider +mode: single +max_exceeded: silent +variables: + global_l: !input global_light + l_z1: !input light_z1 + l_z2: !input light_z2 + l_z3: !input light_z3 + l_b1_s: !input light_b1_single + l_b1_d: !input light_b1_double + l_b1_t: !input light_b1_triple + l_b1_h: !input light_b1_hold + l_b2_s: !input light_b2_single + l_b2_d: !input light_b2_double + l_b2_t: !input light_b2_triple + l_b2_h: !input light_b2_hold + l_b3_s: !input light_b3_single + l_b3_d: !input light_b3_double + l_b3_t: !input light_b3_triple + l_b3_h: !input light_b3_hold + m_sc_1: !input scroll_logic_1_2 + m_sc_2: !input scroll_logic_4_5 + m_sc_3: !input scroll_logic_7_8 + b1_d_b: !input btn1_double_bright + b1_d_t: !input btn1_double_temp + b1_t_b: !input btn1_triple_bright + b1_t_t: !input btn1_triple_temp + b1_h_b: !input btn1_hold_bright + b1_h_t: !input btn1_hold_temp + b2_d_b: !input btn2_double_bright + b2_d_t: !input btn2_double_temp + b2_t_b: !input btn2_triple_bright + b2_t_t: !input btn2_triple_temp + b2_h_b: !input btn2_hold_bright + b2_h_t: !input btn2_hold_temp + b3_d_b: !input btn3_double_bright + b3_d_t: !input btn3_double_temp + b3_t_b: !input btn3_triple_bright + b3_t_t: !input btn3_triple_temp + b3_h_b: !input btn3_hold_bright + b3_h_t: !input btn3_hold_temp + s_p: !input step_pct + s_k: !input step_kelvin + s_h: !input step_hue + v_bri: !input val_bright + v_dim: !input val_dim + v_cold: !input val_cold + v_warm: !input val_warm + t_time: !input transition_time + d_time: !input delay_time + id_e1: !input event_button_1 + id_e2: !input event_button_2 + id_e3: !input event_button_3 +triggers: +- trigger: state + entity_id: !input sensor_1 + id: sc_1_up +- trigger: state + entity_id: !input sensor_2 + id: sc_1_down +- trigger: state + entity_id: !input sensor_4 + id: sc_2_down +- trigger: state + entity_id: !input sensor_5 + id: sc_2_up +- trigger: state + entity_id: !input sensor_7 + id: sc_3_up +- trigger: state + entity_id: !input sensor_8 + id: sc_3_down +- trigger: state + entity_id: + - !input event_button_1 + - !input event_button_2 + - !input event_button_3 + id: btn_event +conditions: +- condition: template + value_template: "{{ trigger.from_state is defined and trigger.to_state is defined + and\n trigger.from_state.state not in ['unknown', 'unavailable'] and\n trigger.to_state.state + not in ['unknown', 'unavailable'] and\n trigger.from_state.state != trigger.to_state.state + }}" +actions: +- choose: + - conditions: '{{ ''sc_'' in trigger.id }}' + sequence: + - variables: + target_light: '{{ l_z1 if ''sc_1'' in trigger.id and l_z1 else l_z2 if ''sc_2'' + in trigger.id and l_z2 else l_z3 if ''sc_3'' in trigger.id and l_z3 else + global_l }}' + direction: '{{ 1 if ''_up'' in trigger.id else -1 }}' + active_func: '{{ m_sc_1 if ''sc_1'' in trigger.id else m_sc_2 if ''sc_2'' + in trigger.id else m_sc_3 }}' + - choose: + - conditions: '{{ active_func == ''brightness'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + brightness_step_pct: '{{ (s_p | float(default=10)) * direction }}' + transition: 0 + - conditions: '{{ active_func == ''kelvin'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + color_temp_kelvin: '{% set entities = expand(target_light) | map(attribute=''entity_id'') + | list %} {% set first_light = entities[0] if entities else ''light.none'' + %} {% set current = state_attr(first_light, ''color_temp_kelvin'') | + int(default=3000) %} {% set step = s_k | int(default=400) %} {% set + min_k = state_attr(first_light, ''min_color_temp_kelvin'') | int(default=2000) + %} {% set max_k = state_attr(first_light, ''max_color_temp_kelvin'') + | int(default=6500) %} {% if direction == 1 %} {{ ([current + step, + max_k] | min) | int }} {% else %} {{ ([current - step, min_k] | max) + | int }} {% endif %} + + ' + transition: 0 + - conditions: '{{ active_func == ''rgb'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + hs_color: '{% set entities = expand(target_light) | map(attribute=''entity_id'') + | list %} {% set first_light = entities[0] if entities else ''light.none'' + %} {% set current_hs = state_attr(first_light, ''hs_color'') %} {% set + h = current_hs[0] | float(default=0) if current_hs is not none else + 0 %} {% set s = current_hs[1] | float(default=100) if current_hs is + not none else 100 %} {% set step = s_h | float(default=15) %} {{ [(h + + (step * direction)) % 360, s] }} + + ' + transition: 0 + - conditions: '{{ active_func == ''preset'' }}' + sequence: + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + brightness_pct: '{{ v_bri if direction == 1 else v_dim }}' + color_temp_kelvin: '{{ v_cold if direction == 1 else v_warm }}' + transition: 1 +- choose: + - conditions: '{{ trigger.id == ''btn_event'' }}' + sequence: + - choose: + - conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type + == 'multi_press_1' }}\n" + sequence: + - variables: + target_light: '{{ l_b1_s if trigger.entity_id == id_e1 and l_b1_s else + l_b2_s if trigger.entity_id == id_e2 and l_b2_s else l_b3_s if trigger.entity_id + == id_e3 and l_b3_s else global_l }}' + - action: light.toggle + target: + entity_id: '{{ target_light }}' + data: + transition: '{{ t_time }}' + - choose: + - conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type + == 'multi_press_2' }}\n" + sequence: + - variables: + target_light: '{{ l_b1_d if trigger.entity_id == id_e1 and l_b1_d else + l_b2_d if trigger.entity_id == id_e2 and l_b2_d else l_b3_d if trigger.entity_id + == id_e3 and l_b3_d else global_l }}' + set_bri: '{{ b1_d_b if trigger.entity_id == id_e1 else b2_d_b if trigger.entity_id + == id_e2 else b3_d_b }}' + set_temp: '{{ b1_d_t if trigger.entity_id == id_e1 else b2_d_t if trigger.entity_id + == id_e2 else b3_d_t }}' + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + brightness_pct: '{{ set_bri }}' + color_temp_kelvin: '{{ set_temp }}' + transition: '{{ t_time }}' + - choose: + - conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type + == 'multi_press_3' }}\n" + sequence: + - variables: + target_light: '{{ l_b1_t if trigger.entity_id == id_e1 and l_b1_t else + l_b2_t if trigger.entity_id == id_e2 and l_b2_t else l_b3_t if trigger.entity_id + == id_e3 and l_b3_t else global_l }}' + set_bri: '{{ b1_t_b if trigger.entity_id == id_e1 else b2_t_b if trigger.entity_id + == id_e2 else b3_t_b }}' + set_temp: '{{ b1_t_t if trigger.entity_id == id_e1 else b2_t_t if trigger.entity_id + == id_e2 else b3_t_t }}' + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + brightness_pct: '{{ set_bri }}' + color_temp_kelvin: '{{ set_temp }}' + transition: '{{ t_time }}' + - choose: + - conditions: "{{ trigger.to_state.attributes.event_type is defined and \n trigger.to_state.attributes.event_type + == 'long_press' }}\n" + sequence: + - variables: + target_light: '{{ l_b1_h if trigger.entity_id == id_e1 and l_b1_h else + l_b2_h if trigger.entity_id == id_e2 and l_b2_h else l_b3_h if trigger.entity_id + == id_e3 and l_b3_h else global_l }}' + set_bri: '{{ b1_h_b if trigger.entity_id == id_e1 else b2_h_b if trigger.entity_id + == id_e2 else b3_h_b }}' + set_temp: '{{ b1_h_t if trigger.entity_id == id_e1 else b2_h_t if trigger.entity_id + == id_e2 else b3_h_t }}' + - action: light.turn_on + target: + entity_id: '{{ target_light }}' + data: + brightness_pct: '{{ set_bri }}' + color_temp_kelvin: '{{ set_temp }}' + transition: '{{ t_time }}' +- delay: + milliseconds: '{{ d_time }}' diff --git a/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc index 7005394..87a432b 100644 Binary files a/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc index 1bb4dff..8e9c2fc 100644 Binary files a/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/button.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc index 55445e9..47b5fe2 100644 Binary files a/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/camera.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc index 5956701..fbc093d 100644 Binary files a/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc index 7b04f4f..47fb9a8 100644 Binary files a/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc index 108204b..ef44364 100644 Binary files a/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/coordinator.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc index f823314..2cb500c 100644 Binary files a/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/google_scraper.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc index 84910f6..5fd38ff 100644 Binary files a/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/image_processing.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc index 12bcf14..a1176ca 100644 Binary files a/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/number.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc index d2d02b4..b3d616d 100644 Binary files a/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/playlist.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc index a7a477b..cc58a50 100644 Binary files a/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/select.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc index 1c72a27..a034080 100644 Binary files a/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/sensor.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc index e02cc48..1c84261 100644 Binary files a/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/store.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc index 54ca5db..18ed569 100644 Binary files a/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/switch.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc b/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc index e3db70d..cf17d97 100644 Binary files a/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc and b/custom_components/album_slideshow/__pycache__/text.cpython-314.pyc differ diff --git a/custom_components/album_slideshow/camera.py b/custom_components/album_slideshow/camera.py index 5960647..e6b5bec 100644 --- a/custom_components/album_slideshow/camera.py +++ b/custom_components/album_slideshow/camera.py @@ -148,12 +148,6 @@ class AlbumSlideshowCamera(Camera): self._framebuffer: bytes | None = None - # MJPEG subscribers. Each open stream owns an asyncio.Queue of JPEG - # byte payloads. The render loop pushes the latest still as soon - # as it's encoded; if a subscriber falls behind we drop frames - # for that subscriber rather than block the whole loop. - self._mjpeg_subscribers: set[asyncio.Queue[bytes]] = set() - # Monotonic counter incremented every time a new still is committed. # Exposed as the ``frame_id`` state attribute so the Lovelace card # has an unambiguous "new frame ready" signal even when other @@ -244,6 +238,7 @@ class AlbumSlideshowCamera(Camera): "latitude": getattr(cur, "latitude", None), "longitude": getattr(cur, "longitude", None), "location": getattr(cur, "location", None), + "description": getattr(cur, "description", None), # Structured per-image caption metadata. A single-element list for # normal slides; two elements (top/left first) for paired slides, # so the card can overlay an accurate date/location on each half. @@ -256,6 +251,7 @@ class AlbumSlideshowCamera(Camera): "portrait_mode": self.store.portrait_mode, "order_mode": self.store.order_mode, "date_filter": self.store.date_filter, + "missing_date_mode": self.store.missing_date_mode, "paused": bool(self.store.paused), "refresh_hours": int(self.store.refresh_hours), "aspect_ratio": self.store.aspect_ratio, @@ -282,6 +278,7 @@ class AlbumSlideshowCamera(Camera): "location": getattr(cur, "location", None), "latitude": getattr(cur, "latitude", None), "longitude": getattr(cur, "longitude", None), + "description": getattr(cur, "description", None), } ] @@ -318,6 +315,7 @@ class AlbumSlideshowCamera(Camera): cache_key = ( id(raw), self.store.date_filter, + self.store.missing_date_mode, self.store.order_mode, ) if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key): @@ -326,6 +324,7 @@ class AlbumSlideshowCamera(Camera): filtered = playlist.filter_items( raw, mode=self.store.date_filter, + missing_date=self.store.missing_date_mode, ) ordered = playlist.order_items(filtered, self.store.order_mode) self._effective_cache = (hash(cache_key), ordered) @@ -343,67 +342,32 @@ class AlbumSlideshowCamera(Camera): return self._framebuffer async def handle_async_mjpeg_stream(self, request): - """Stream the slideshow as multipart MJPEG. + """Serve the current slide as MJPEG for Home Assistant core surfaces. - Each open client gets a bounded asyncio.Queue that the render loop - pushes JPEG payloads into when a new still is committed. Visible - transitions are now handled by the Lovelace card on the client - side, so this stream just emits the latest still per slide change. + This is what the more-info dialog and picture-glance live view use + (the camera advertises no live stream, so the frontend falls back to + ``/api/camera_proxy_stream``). We delegate to HA's still-stream + helper, which polls ``async_camera_image`` at ``frame_interval`` and + writes a correct multipart response. + + Crucially it emits frames *continuously* rather than only on slide + change. A browser parsing ``multipart/x-mixed-replace`` holds the + current part until the next boundary arrives, so a stream that sent + one frame and then went quiet until the next slide (potentially many + seconds away, or never while paused) left the more-info view blank. + Polling keeps a boundary coming right away, so the current frame + renders immediately. """ # Imported lazily so the module still loads in test environments - # that stub out homeassistant without installing aiohttp. - from aiohttp import web + # that stub out homeassistant. + from homeassistant.components.camera import async_get_still_stream - boundary = "frame" - response = web.StreamResponse( - status=200, - reason="OK", - headers={ - "Content-Type": f"multipart/x-mixed-replace;boundary={boundary}", - "Cache-Control": "no-cache, private", - "Pragma": "no-cache", - }, + return await async_get_still_stream( + request, + self.async_camera_image, + self.content_type, + self.frame_interval, ) - await response.prepare(request) - - # Bounded queue: a slow client should fall behind on slide commits - # rather than balloon memory. - queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=4) - self._mjpeg_subscribers.add(queue) - - # Push the held still immediately so the client renders something - # before the next slide change. - if self._framebuffer is not None: - try: - queue.put_nowait(self._framebuffer) - except asyncio.QueueFull: - pass - - try: - while True: - payload = await queue.get() - try: - await response.write( - b"--" + boundary.encode() + b"\r\n" - b"Content-Type: image/jpeg\r\n" - b"Content-Length: " + str(len(payload)).encode() + b"\r\n\r\n" - + payload + b"\r\n" - ) - except (ConnectionResetError, asyncio.CancelledError): - raise - except Exception as err: - _LOGGER.debug("Album Slideshow: mjpeg client write failed: %s", err) - break - except (ConnectionResetError, asyncio.CancelledError): - pass - finally: - self._mjpeg_subscribers.discard(queue) - try: - await response.write_eof() - except Exception: - pass - - return response # Older HA cores may dispatch via the alt name; alias for compatibility. async def async_handle_async_mjpeg_stream(self, request): @@ -412,12 +376,17 @@ class AlbumSlideshowCamera(Camera): async def _wait_or_interrupt(self, timeout: float) -> bool: """Wait up to ``timeout`` seconds, returning True if interrupted. - Safe wrapper around clear() + wait_for() - callers don't have to - worry about the ordering of the two operations. The clear() runs - synchronously before the awaitable is created, so no interrupt can - be lost on the single-threaded event loop. + Does NOT clear ``_interrupt_event`` - the render loop clears it once + per cycle, before rendering, so a signal that arrives while we're + rendering (a "next slide" press, a coordinator/store change) survives + until we get here instead of being wiped. + + A force-next that's already pending is honored immediately without + sleeping, so a press that landed during the last render (or right at + the loop boundary) isn't held until the full slide interval elapses. """ - self._interrupt_event.clear() + if self._force_next: + return True try: await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout) return True @@ -433,6 +402,11 @@ class AlbumSlideshowCamera(Camera): raise should_advance = False # Don't advance on the very first render while True: + # Clear the wake signal before rendering. Anything that happens + # from here on (a "next slide" press, a coordinator/store change) + # re-sets it and is picked up after this frame commits, so no + # wake is lost while we're mid-render. + self._interrupt_event.clear() try: await self._render_cycle(advance=should_advance) self._consecutive_failures = 0 @@ -453,9 +427,14 @@ class AlbumSlideshowCamera(Camera): continue interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval))) - if interrupted: - should_advance = self._force_next + if self._force_next: + # Explicit "next slide" request: always advance. + should_advance = True self._force_next = False + elif interrupted: + # A coordinator/store change woke us: re-render the current + # frame (new data or settings) without skipping ahead. + should_advance = False else: # Paused slideshows hold the current frame until the user # un-pauses or hits "next slide" explicitly. @@ -510,7 +489,6 @@ class AlbumSlideshowCamera(Camera): self._last_pair_frames = meta.get("pair_frames") if meta else None self._last_pair_orientation = meta.get("pair_orientation") if meta else None - self._broadcast_frame(encoded) self.async_write_ha_state() @property @@ -528,27 +506,6 @@ class AlbumSlideshowCamera(Camera): domain_data["compose_semaphore"] = sem return sem - def _broadcast_frame(self, payload: bytes) -> None: - """Push a frame to every active MJPEG subscriber. - - Slow subscribers get their frame dropped rather than backing up the - queue; the next still emission will catch them up. - """ - for queue in list(self._mjpeg_subscribers): - try: - queue.put_nowait(payload) - except asyncio.QueueFull: - # Drain one and retry once so a wedged client still sees - # the latest frame eventually instead of forever stale. - try: - queue.get_nowait() - except asyncio.QueueEmpty: - pass - try: - queue.put_nowait(payload) - except asyncio.QueueFull: - pass - def _do_advance(self, count: int, items: list) -> None: """Advance _index to the next slide and commit random-order position.""" if count <= 0: @@ -651,12 +608,14 @@ class AlbumSlideshowCamera(Camera): "location": getattr(cur, "location", None), "latitude": getattr(cur, "latitude", None), "longitude": getattr(cur, "longitude", None), + "description": getattr(cur, "description", None), }, { "captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)), "location": getattr(other_item, "location", None), "latitude": getattr(other_item, "latitude", None), "longitude": getattr(other_item, "longitude", None), + "description": getattr(other_item, "description", None), }, ] pair_meta = [f["captured_at"] for f in pair_frames] @@ -876,11 +835,24 @@ class AlbumSlideshowCamera(Camera): self._download_cache.put(url, data) return data + def _image_request_headers(self, url: str) -> dict[str, str] | None: + """Auth headers required to fetch image bytes for some providers. + + The Immich provider stores an ``x-api-key`` header on the coordinator; + it is sent server-side only, so the key never reaches the browser or + the camera's ``current_url`` attribute. Returns ``None`` when no extra + headers are needed (Google, local folder, media source). + """ + headers = getattr(self.coordinator, "image_request_headers", None) + if headers and isinstance(url, str) and url.startswith("http"): + return dict(headers) + return None + async def _http_get(self, url: str) -> bytes | None: session = async_get_clientsession(self.hass) try: async with async_timeout.timeout(30): - async with session.get(url) as resp: + async with session.get(url, headers=self._image_request_headers(url)) as resp: resp.raise_for_status() content_type = resp.headers.get("Content-Type", "") diff --git a/custom_components/album_slideshow/config_flow.py b/custom_components/album_slideshow/config_flow.py index cbc370e..f667c2e 100644 --- a/custom_components/album_slideshow/config_flow.py +++ b/custom_components/album_slideshow/config_flow.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import re from typing import Any @@ -7,6 +8,11 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector + +# Sentinel values for the "Select all" options in the multi-selects. +_ALL_PEOPLE = "__all_people__" +_ALL_ALBUMS = "__all_albums__" from .const import ( DOMAIN, @@ -14,11 +20,23 @@ from .const import ( CONF_ALBUM_NAME, CONF_ALBUM_URL, CONF_LOCAL_PATH, + CONF_MEDIA_CONTENT_ID, CONF_RECURSIVE, CONF_REVERSE_GEOCODE, + CONF_IMMICH_URL, + CONF_IMMICH_API_KEY, + CONF_IMMICH_SELECTION_TYPE, + CONF_IMMICH_SELECTION_ID, + CONF_IMMICH_IMAGE_SIZE, + CONF_IMMICH_FILTER, + DEFAULT_IMMICH_IMAGE_SIZE, + IMMICH_IMAGE_SIZE_OPTIONS, + IMMICH_SELECTION_COMPOSITE, DEFAULT_REVERSE_GEOCODE, PROVIDER_GOOGLE_SHARED, PROVIDER_LOCAL_FOLDER, + PROVIDER_MEDIA_SOURCE, + PROVIDER_IMMICH, DEFAULT_RECURSIVE, ) @@ -52,6 +70,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: self._provider: str | None = None + # Immich flow state carried between steps. + self._immich_url: str | None = None + self._immich_key: str | None = None + # id -> name maps for the Albums and People multi-selects. + self._immich_albums: dict[str, str] = {} + self._immich_people: dict[str, str] = {} @staticmethod @callback @@ -79,6 +103,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._provider = user_input[CONF_PROVIDER] if self._provider == PROVIDER_LOCAL_FOLDER: return await self.async_step_local_folder() + if self._provider == PROVIDER_MEDIA_SOURCE: + return await self.async_step_media_source() + if self._provider == PROVIDER_IMMICH: + return await self.async_step_immich() return await self.async_step_google_shared() schema = vol.Schema( @@ -86,6 +114,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): vol.Required(CONF_PROVIDER, default=PROVIDER_GOOGLE_SHARED): vol.In({ PROVIDER_GOOGLE_SHARED: "Google Photos", PROVIDER_LOCAL_FOLDER: "Local Folder", + PROVIDER_IMMICH: "Immich (direct API, full metadata)", + PROVIDER_MEDIA_SOURCE: "Media Source (any source, no metadata)", }) } ) @@ -152,6 +182,193 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_show_form(step_id="local_folder", data_schema=schema, errors=errors) + async def async_step_media_source( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + errors: dict[str, str] = {} + + if user_input is not None: + content_id = user_input[CONF_MEDIA_CONTENT_ID].strip() + name = user_input[CONF_ALBUM_NAME].strip() + + if not content_id.startswith("media-source://"): + errors[CONF_MEDIA_CONTENT_ID] = "invalid_media_source" + else: + await self.async_set_unique_id( + f"{DOMAIN}:{PROVIDER_MEDIA_SOURCE}:{content_id}" + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=name, + data={ + CONF_PROVIDER: PROVIDER_MEDIA_SOURCE, + CONF_MEDIA_CONTENT_ID: content_id, + CONF_ALBUM_NAME: name, + }, + ) + + schema = vol.Schema( + { + vol.Required(CONF_ALBUM_NAME): str, + vol.Required(CONF_MEDIA_CONTENT_ID): str, + } + ) + return self.async_show_form( + step_id="media_source", data_schema=schema, errors=errors + ) + + async def async_step_immich( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Collect the Immich URL + API key and validate them.""" + errors: dict[str, str] = {} + + if user_input is not None: + url = user_input[CONF_IMMICH_URL].strip() + key = user_input[CONF_IMMICH_API_KEY].strip() + from . import immich as immich_api + + client = immich_api.ImmichClient(self.hass, url, key) + try: + await client.async_validate() + albums = await client.async_list_albums() + people = await client.async_list_people() + except Exception: # noqa: BLE001 - any failure means bad URL/key + errors["base"] = "immich_cannot_connect" + else: + self._immich_url = client.base_url + self._immich_key = key + # id -> name maps for the two multi-select pickers. + self._immich_albums = { + a["id"]: (a.get("albumName") or a["id"]) + for a in albums + if a.get("id") + } + self._immich_people = { + p["id"]: p["name"] + for p in people + if p.get("id") and (p.get("name") or "").strip() + } + return await self.async_step_immich_select() + + schema = vol.Schema( + { + vol.Required(CONF_IMMICH_URL): str, + vol.Required(CONF_IMMICH_API_KEY): str, + } + ) + return self.async_show_form( + step_id="immich", data_schema=schema, errors=errors + ) + + async def async_step_immich_select( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Build a composite Immich selection and finish the entry. + + The user ticks any mix of albums, people and favorites (and may add a + custom JSON filter); the coordinator unions them. Leaving everything + empty means "all photos". + """ + errors: dict[str, str] = {} + + if user_input is not None: + name = user_input[CONF_ALBUM_NAME].strip() + size = user_input.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE) + raw_filter = (user_input.get(CONF_IMMICH_FILTER) or "").strip() + favorites = bool(user_input.get("favorites")) + + chosen_albums = [a for a in user_input.get("albums", []) if a] + if _ALL_ALBUMS in chosen_albums: + chosen_albums = list(self._immich_albums.keys()) + else: + chosen_albums = [a for a in chosen_albums if a in self._immich_albums] + + chosen_people = [p for p in user_input.get("people", []) if p] + if _ALL_PEOPLE in chosen_people: + chosen_people = list(self._immich_people.keys()) + else: + chosen_people = [p for p in chosen_people if p in self._immich_people] + + if raw_filter: + try: + parsed = json.loads(raw_filter) + if not isinstance(parsed, dict): + raise ValueError + except ValueError: + errors[CONF_IMMICH_FILTER] = "immich_filter_invalid" + + if not errors: + selection = { + "albums": chosen_albums, + "people": chosen_people, + "favorites": favorites, + } + sel_id = json.dumps(selection, sort_keys=True) + unique = ( + f"{DOMAIN}:{PROVIDER_IMMICH}:{self._immich_url}:" + f"composite:{sel_id}:{raw_filter}" + ) + await self.async_set_unique_id(unique) + self._abort_if_unique_id_configured() + data = { + CONF_PROVIDER: PROVIDER_IMMICH, + CONF_IMMICH_URL: self._immich_url, + CONF_IMMICH_API_KEY: self._immich_key, + CONF_IMMICH_SELECTION_TYPE: IMMICH_SELECTION_COMPOSITE, + CONF_IMMICH_SELECTION_ID: sel_id, + CONF_IMMICH_IMAGE_SIZE: size, + CONF_ALBUM_NAME: name, + } + if raw_filter: + data[CONF_IMMICH_FILTER] = raw_filter + return self.async_create_entry(title=name, data=data) + + fields: dict[Any, Any] = {vol.Required(CONF_ALBUM_NAME): str} + if self._immich_albums: + album_options = [ + selector.SelectOptionDict( + value=_ALL_ALBUMS, label="Select all albums" + ) + ] + [ + selector.SelectOptionDict(value=aid, label=name) + for aid, name in self._immich_albums.items() + ] + fields[vol.Optional("albums")] = selector.SelectSelector( + selector.SelectSelectorConfig( + options=album_options, + multiple=True, + mode=selector.SelectSelectorMode.DROPDOWN, + custom_value=False, + ) + ) + if self._immich_people: + people_options = [ + selector.SelectOptionDict( + value=_ALL_PEOPLE, label="Select all people" + ) + ] + [ + selector.SelectOptionDict(value=pid, label=name) + for pid, name in self._immich_people.items() + ] + fields[vol.Optional("people")] = selector.SelectSelector( + selector.SelectSelectorConfig( + options=people_options, + multiple=True, + mode=selector.SelectSelectorMode.DROPDOWN, + custom_value=False, + ) + ) + fields[vol.Optional("favorites", default=False)] = selector.BooleanSelector() + fields[vol.Optional(CONF_IMMICH_FILTER)] = str + fields[ + vol.Optional(CONF_IMMICH_IMAGE_SIZE, default=DEFAULT_IMMICH_IMAGE_SIZE) + ] = vol.In(IMMICH_IMAGE_SIZE_OPTIONS) + schema = vol.Schema(fields) + return self.async_show_form( + step_id="immich_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 ed16808..b150b12 100644 --- a/custom_components/album_slideshow/const.py +++ b/custom_components/album_slideshow/const.py @@ -6,6 +6,43 @@ CONF_ALBUM_NAME = "album_name" CONF_LOCAL_PATH = "local_path" CONF_RECURSIVE = "recursive" CONF_IMAGE_CACHE_MB = "image_cache_mb" +# Media Source provider: a ``media-source://...`` content id pointing at a +# folder-like node (e.g. an Immich people/album view, or local media). The +# coordinator browses it, collects the image children, and resolves each to +# a playable URL. +CONF_MEDIA_CONTENT_ID = "media_content_id" +# Immich (direct API) provider. +CONF_IMMICH_URL = "immich_url" +CONF_IMMICH_API_KEY = "immich_api_key" +CONF_IMMICH_SELECTION_TYPE = "immich_selection_type" +CONF_IMMICH_SELECTION_ID = "immich_selection_id" +CONF_IMMICH_IMAGE_SIZE = "immich_image_size" +CONF_IMMICH_FILTER = "immich_filter" + +IMMICH_SELECTION_ALBUM = "album" +IMMICH_SELECTION_ALBUMS = "albums" +IMMICH_SELECTION_PERSON = "person" +IMMICH_SELECTION_PEOPLE = "people" +IMMICH_SELECTION_FAVORITES = "favorites" +IMMICH_SELECTION_ALL = "all" +IMMICH_SELECTION_RANDOM = "random" +IMMICH_SELECTION_SEARCH = "search" +# Composite: a client-side union of any mix of albums, people, favorites and a +# custom filter. Immich has no OR operator, so each member is queried on its +# own and the results are merged (see #19). The selection id is a JSON object +# ``{"albums": [...], "people": [...], "favorites": bool}``; an empty composite +# means "all photos". +IMMICH_SELECTION_COMPOSITE = "composite" + +IMMICH_IMAGE_PREVIEW = "preview" +IMMICH_IMAGE_FULLSIZE = "fullsize" +IMMICH_IMAGE_ORIGINAL = "original" +IMMICH_IMAGE_SIZE_OPTIONS = [ + IMMICH_IMAGE_PREVIEW, + IMMICH_IMAGE_FULLSIZE, + IMMICH_IMAGE_ORIGINAL, +] +DEFAULT_IMMICH_IMAGE_SIZE = IMMICH_IMAGE_PREVIEW # Local-folder option: when True (default) the coordinator does best-effort # reverse geocoding of EXIF GPS coordinates via the public Nominatim # (OpenStreetMap) endpoint and exposes a human-readable ``location`` @@ -16,6 +53,8 @@ DEFAULT_REVERSE_GEOCODE = True PROVIDER_GOOGLE_SHARED = "google_shared" PROVIDER_LOCAL_FOLDER = "local_folder" +PROVIDER_MEDIA_SOURCE = "media_source" +PROVIDER_IMMICH = "immich" FILL_COVER = "cover" FILL_CONTAIN = "contain" @@ -60,6 +99,21 @@ DATE_FILTER_OPTIONS = [ ] DEFAULT_DATE_FILTER = DATE_FILTER_OFF +# How the date filter treats photos that have no EXIF capture date. +# use_uploaded_at - fall back to the upload date (keeps filters meaningful) +# include - keep undated photos (legacy behaviour for windows) +# exclude - drop undated photos entirely +MISSING_DATE_USE_UPLOADED = "use_uploaded_at" +MISSING_DATE_INCLUDE = "include" +MISSING_DATE_EXCLUDE = "exclude" + +MISSING_DATE_OPTIONS = [ + MISSING_DATE_USE_UPLOADED, + MISSING_DATE_INCLUDE, + MISSING_DATE_EXCLUDE, +] +DEFAULT_MISSING_DATE_MODE = MISSING_DATE_USE_UPLOADED + DEFAULT_SLIDE_INTERVAL = 60 DEFAULT_REFRESH_HOURS = 24 DEFAULT_FILL_MODE = FILL_BLUR diff --git a/custom_components/album_slideshow/coordinator.py b/custom_components/album_slideshow/coordinator.py index c0b287e..cbf17c2 100644 --- a/custom_components/album_slideshow/coordinator.py +++ b/custom_components/album_slideshow/coordinator.py @@ -6,6 +6,7 @@ from datetime import timedelta import json import logging from pathlib import Path +import re from typing import Any import async_timeout @@ -20,12 +21,22 @@ from .const import ( CONF_PROVIDER, CONF_ALBUM_URL, CONF_LOCAL_PATH, + CONF_MEDIA_CONTENT_ID, CONF_RECURSIVE, CONF_REVERSE_GEOCODE, + CONF_IMMICH_URL, + CONF_IMMICH_API_KEY, + CONF_IMMICH_SELECTION_TYPE, + CONF_IMMICH_SELECTION_ID, + CONF_IMMICH_IMAGE_SIZE, + CONF_IMMICH_FILTER, + DEFAULT_IMMICH_IMAGE_SIZE, DEFAULT_REVERSE_GEOCODE, DOMAIN, PROVIDER_GOOGLE_SHARED, PROVIDER_LOCAL_FOLDER, + PROVIDER_MEDIA_SOURCE, + PROVIDER_IMMICH, ) from .store import SlideshowStore @@ -53,6 +64,13 @@ class MediaItem: latitude: float | None = None longitude: float | None = None location: str | None = None + # Free-text photo description / caption (local-folder provider only), + # read from EXIF ImageDescription, IPTC Caption-Abstract, or XMP + # dc:description. Used by the card's caption overlay when enabled. + description: str | None = None + # Provider-specific source identifier (e.g. the Immich asset id) used by + # background enrichment to fetch per-item metadata. + source_id: str | None = None # True once the local-folder EXIF reader has visited this file. # Prevents re-reading EXIF on every coordinator refresh and lets the # background enrichment task skip already-processed files even after @@ -63,6 +81,73 @@ class MediaItem: _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"} _VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm", ".3gp", ".mts", ".m2ts"} +# Media Source browsing limits: cap total collected images and recursion +# depth so a huge or self-referential tree can't hang the coordinator or +# exhaust memory. +_MEDIA_SOURCE_MAX_ITEMS = 5000 +_MEDIA_SOURCE_MAX_DEPTH = 8 + +# System/metadata folders and files that some sources (notably Synology) +# expose but which are never user photos. +_SKIP_MEDIA_TITLES = {"@eadir", ".ds_store", "thumbs.db", "@syno", "#recycle"} +# Image formats a browser cannot render inline; skip so we don't queue +# guaranteed fetch failures. +_NON_WEB_IMAGE_EXTS = { + ".psd", ".tif", ".tiff", ".heic", ".heif", + ".cr2", ".nef", ".arw", ".dng", ".raw", ".orf", ".rw2", +} + + +def _is_junk_media_title(title: Any) -> bool: + """Return True for system folders / non-renderable files to skip.""" + if not isinstance(title, str): + return False + t = title.strip().lower() + if not t: + return False + if t in _SKIP_MEDIA_TITLES: + return True + if t.startswith("@") or t.startswith("."): + return True + dot = t.rfind(".") + if dot != -1 and t[dot:] in _NON_WEB_IMAGE_EXTS: + return True + return False + + +def _media_node_is_image(media_class: Any, media_content_type: Any) -> bool: + """Return True if a browsed media node looks like a still image. + + ``media_class`` is Home Assistant's coarse category (e.g. ``image``, + ``video``, ``directory``); ``media_content_type`` is the MIME type when + known. We accept a node when either signals an image, and explicitly + reject anything that declares a video type. + """ + mc = str(media_class or "").lower() + mime = str(media_content_type or "").lower() + if mc == "video" or mime.startswith("video/"): + return False + if mc == "image" or mime.startswith("image/"): + return True + return False + + +def _normalize_resolved_url(url: str, base_url: str) -> str: + """Make a resolved media URL absolute so the fetch layer can load it. + + ``async_resolve_media`` returns either an absolute ``http(s)`` URL or a + site-relative path such as ``/media/local/...`` (often already signed via + an ``authSig`` query param). Relative paths are prefixed with the + instance's internal base URL; absolute URLs pass through unchanged. + """ + if not isinstance(url, str) or not url: + return url + if url.startswith("http://") or url.startswith("https://"): + return url + if url.startswith("/") and base_url: + return f"{base_url.rstrip('/')}{url}" + return url + _SKIP_DIR_PREFIXES = (".", "@", "#") @@ -74,6 +159,66 @@ def _pick_url(item: dict[str, Any]) -> str | None: return None +# Matches Google's ``=w1920-h1080`` (and variants) size suffix so two URLs for +# the same photo at different sizes collapse to one stable key. +_PHOTO_SIZE_SUFFIX_RE = re.compile(r"=[a-z0-9-]+$", re.IGNORECASE) + + +def _photo_base_key(url: str | None) -> str | None: + """Return a stable per-photo key from a Google CDN URL. + + Both album sources (``batchexecute`` and publicalbum.org) hand back + ``lh3.googleusercontent.com/=w...-h...`` URLs for the same photo, just + at different sizes and sometimes with query params. Dropping the query + string and the size suffix leaves the shared ```` portion, which lets + us match a publicalbum item to its dated batchexecute twin. + """ + if not isinstance(url, str) or not url: + return None + base = url.split("?", 1)[0] + base = _PHOTO_SIZE_SUFFIX_RE.sub("", base) + return base or None + + +def _enrich_missing_dates( + api_items: list["MediaItem"], scraped_items: list["MediaItem"] +) -> int: + """Backfill missing dates on ``api_items`` from dated ``scraped_items``. + + Matches photos across the two Google album sources by their stable + per-photo URL key and fills in any ``captured_at`` / ``uploaded_at`` that + the publicalbum item is missing. Mutates ``api_items`` in place and returns + how many items were touched. See issue #18. + """ + if not api_items or not scraped_items: + return 0 + dates_by_key: dict[str, tuple[int | None, int | None]] = {} + for it in scraped_items: + key = _photo_base_key(it.url) + if key and (it.captured_at is not None or it.uploaded_at is not None): + dates_by_key.setdefault(key, (it.captured_at, it.uploaded_at)) + if not dates_by_key: + return 0 + enriched = 0 + for it in api_items: + if it.captured_at is not None and it.uploaded_at is not None: + continue + twin = dates_by_key.get(_photo_base_key(it.url)) + if not twin: + continue + cap, up = twin + touched = False + if it.captured_at is None and cap is not None: + it.captured_at = cap + touched = True + if it.uploaded_at is None and up is not None: + it.uploaded_at = up + touched = True + if touched: + enriched += 1 + return enriched + + def _pick_int(d: dict[str, Any], *path: str) -> int | None: cur: Any = d for p in path: @@ -231,6 +376,7 @@ def _looks_like_video(raw: dict[str, Any]) -> bool: _EXIF_TAG_DATETIME_ORIGINAL = 36867 # DateTimeOriginal _EXIF_TAG_OFFSET_TIME_ORIGINAL = 36881 # OffsetTimeOriginal (e.g. "+02:00") _EXIF_TAG_DATETIME = 306 # DateTime (modification time) +_EXIF_TAG_IMAGE_DESCRIPTION = 270 # ImageDescription (free text) _EXIF_TAG_GPS_IFD = 34853 # Pointer to the GPS IFD _EXIF_GPS_LAT_REF = 1 # "N" / "S" _EXIF_GPS_LAT = 2 # rational tuple @@ -351,6 +497,128 @@ def _parse_exif_datetime(raw: Any, offset_raw: Any) -> int | None: return None +def _clean_description(value: Any) -> str | None: + """Normalise a raw description value to trimmed text or ``None``.""" + if isinstance(value, bytes): + try: + value = value.decode("utf-8", errors="ignore") + except Exception: + return None + if not isinstance(value, str): + return None + text = value.strip().replace("\x00", "") + return text or None + + +def _read_photo_description(img: Any, exif: Any) -> str | None: + """Extract a photo description from EXIF, IPTC, or XMP metadata. + + Tries, in order: EXIF ``ImageDescription``, IPTC ``Caption-Abstract`` + (record 2, dataset 120), and XMP ``dc:description``. Returns the first + non-empty value, or ``None``. All lookups are defensive because these + metadata blocks are frequently absent or malformed. + """ + try: + desc = _clean_description(exif.get(_EXIF_TAG_IMAGE_DESCRIPTION)) + if desc: + return desc + except Exception: + pass + + try: + from PIL import IptcImagePlugin + + iptc = IptcImagePlugin.getiptcinfo(img) + if iptc: + desc = _clean_description(iptc.get((2, 120))) + if desc: + return desc + except Exception: + pass + + try: + xmp = img.getxmp() + except Exception: + xmp = None + if isinstance(xmp, dict): + found = _find_xmp_description(xmp) + if found: + return found + + return None + + +def _find_xmp_description(node: Any) -> str | None: + """Recursively search a parsed XMP tree for the ``dc:description`` text. + + Pillow's ``getxmp()`` returns nested dicts. The relevant path is + ``xmpmeta -> RDF -> Description -> description -> Alt -> li``. We match + the ``dc:description`` *field* by its exact lowercase localname + ``description`` (case-sensitive) so we don't accidentally match the + ``rdf:Description`` *container* (capital ``D``) that wraps it. + """ + if isinstance(node, dict): + for key, value in node.items(): + # Case-sensitive: dc:description is lowercase; the rdf:Description + # container is capitalised and must be skipped here. + if isinstance(key, str) and key.split(":")[-1] == "description": + text = _extract_xmp_text(value) + if text: + return text + for value in node.values(): + found = _find_xmp_description(value) + if found: + return found + elif isinstance(node, list): + for value in node: + found = _find_xmp_description(value) + if found: + return found + return None + + +def _extract_xmp_text(value: Any) -> str | None: + """Pull plain text out of an XMP language-alternative structure. + + Handles the shapes Pillow produces for ``dc:description``: + - a plain string + - ``{"Alt"/"Bag"/"Seq": {"li": ...}}`` containers + - an ``li`` that is a string, a ``{"lang", "text"}`` dict, or a list of + such dicts (multiple languages) + Prefers the ``x-default`` language entry when several are present. + """ + if isinstance(value, str): + return _clean_description(value) + if isinstance(value, dict): + # A language-alternative leaf: ``{"lang": ..., "text": ...}``. + if "text" in value: + return _clean_description(value.get("text")) + # Container wrappers: descend only through the known XMP keys so we + # never grab a sibling attribute value (e.g. a ``lang`` code). + for container in ("Alt", "Bag", "Seq"): + if container in value: + text = _extract_xmp_text(value[container]) + if text: + return text + if "li" in value: + return _extract_xmp_text(value["li"]) + return None + if isinstance(value, list): + # Multiple ``rdf:li`` entries: prefer x-default, else the first + # non-empty one. + default = None + first = None + for v in value: + if isinstance(v, dict) and v.get("lang") == "x-default": + default = _clean_description(v.get("text")) + if default: + return default + if first is None: + first = _extract_xmp_text(v) + return first + return None + + def _read_local_exif(path: Path) -> dict[str, Any]: """Read EXIF metadata for a local file. @@ -382,17 +650,25 @@ def _read_local_exif(path: Path) -> dict[str, Any]: try: with Image.open(path) as img: exif = img.getexif() + + if exif: + dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get( + _EXIF_TAG_DATETIME + ) + offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL) + parsed = _parse_exif_datetime(dt_raw, offset_raw) + if parsed is not None: + out["captured_at"] = parsed + + # Description can come from IPTC / XMP even when the file has no + # EXIF IFD, so this runs regardless of ``exif`` being present. + description = _read_photo_description(img, exif) + if description: + out["description"] = description + if not exif: return out - dt_raw = exif.get(_EXIF_TAG_DATETIME_ORIGINAL) or exif.get( - _EXIF_TAG_DATETIME - ) - offset_raw = exif.get(_EXIF_TAG_OFFSET_TIME_ORIGINAL) - parsed = _parse_exif_datetime(dt_raw, offset_raw) - if parsed is not None: - out["captured_at"] = parsed - gps = None try: gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None @@ -550,13 +826,18 @@ def _merge_prior_enrichment( item.longitude = prev.longitude if prev.location and not item.location: item.location = prev.location + if prev.description and not item.description: + item.description = prev.description if prev.exif_scanned: item.exif_scanned = True class AlbumCoordinator(DataUpdateCoordinator): # Bump when the persisted item shape changes incompatibly. - _ITEM_CACHE_VERSION = 2 + # v3: added ``description``; forces a re-scan so already-cached items + # (exif_scanned=True) get their description read instead of being + # skipped forever. + _ITEM_CACHE_VERSION = 3 # Bump independently of the items cache - the geocode cache is # keyed by coordinate and is safe to keep across item-shape changes. _GEOCODE_CACHE_VERSION = 1 @@ -570,6 +851,10 @@ class AlbumCoordinator(DataUpdateCoordinator): self.album_url: str | None = entry.data.get(CONF_ALBUM_URL) self.local_path: str | None = entry.data.get(CONF_LOCAL_PATH) self.recursive: bool = bool(entry.data.get(CONF_RECURSIVE, True)) + self.media_content_id: str | None = entry.data.get(CONF_MEDIA_CONTENT_ID) + # Extra headers the camera must send when fetching image bytes + # (Immich API key). Empty for providers that need no auth. + self.image_request_headers: dict[str, str] = {} # Persist the most recent successful album fetch so that a transient # network/Google failure doesn't blank the slideshow on restart. @@ -631,6 +916,10 @@ class AlbumCoordinator(DataUpdateCoordinator): data = await self._update_local_folder() elif self.provider == PROVIDER_GOOGLE_SHARED: data = await self._update_google_shared() + elif self.provider == PROVIDER_MEDIA_SOURCE: + data = await self._update_media_source() + elif self.provider == PROVIDER_IMMICH: + data = await self._update_immich() else: raise UpdateFailed(f"Unsupported provider: {self.provider}") except UpdateFailed: @@ -644,9 +933,9 @@ class AlbumCoordinator(DataUpdateCoordinator): raise items = data.get("items") or [] - if self.provider == PROVIDER_LOCAL_FOLDER and items: - # Carry forward EXIF/geocode metadata for files we've already - # scanned this session; new files get filled in by the + if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH) and items: + # Carry forward EXIF/geocode metadata for items we've already + # scanned this session; new items get filled in by the # background worker below. prior_items = (self.data or {}).get("items") if isinstance(self.data, dict) else None if prior_items: @@ -740,6 +1029,8 @@ class AlbumCoordinator(DataUpdateCoordinator): latitude=raw.get("latitude"), longitude=raw.get("longitude"), location=raw.get("location"), + description=raw.get("description"), + source_id=raw.get("source_id"), exif_scanned=bool(raw.get("exif_scanned", False)), )) except Exception: @@ -770,6 +1061,8 @@ class AlbumCoordinator(DataUpdateCoordinator): "latitude": it.latitude, "longitude": it.longitude, "location": it.location, + "description": it.description, + "source_id": it.source_id, "exif_scanned": it.exif_scanned, } for it in items @@ -839,6 +1132,261 @@ class AlbumCoordinator(DataUpdateCoordinator): "items": items, } + async def _update_media_source(self) -> dict[str, Any]: + """Build the item list from a Home Assistant Media Source node. + + Browses the configured ``media-source://`` content id recursively, + collecting image children, then resolves each to a playable URL. + Media Source exposes no per-photo EXIF, so date/GPS/description + features do not apply here (same as the Google provider). + """ + content_id = self.media_content_id + if not content_id: + raise UpdateFailed("Missing media source content id") + + try: + from homeassistant.components import media_source + except Exception as err: # pragma: no cover - core ships media_source + raise UpdateFailed("media_source integration is not available") from err + + collected: list[tuple[str, str | None]] = [] + try: + await self._browse_media_source(media_source, content_id, collected, 0) + except UpdateFailed: + raise + except Exception as err: + raise UpdateFailed(f"Error browsing media source: {err}") from err + + if not collected: + raise UpdateFailed("No images found in the selected media source") + + base_url = self._internal_base_url() + items: list[MediaItem] = [] + for cid, title in collected: + resolved = await self._resolve_media(media_source, cid) + if resolved is None: + continue + raw = resolved[0] + # ``async_resolve_media`` returns an UNSIGNED ``/media/...`` path + # when called directly (the frontend's websocket handler is what + # normally signs it). Fetching an unsigned local-media path + # server-side gets a 401, so sign it ourselves before use. + if isinstance(raw, str) and raw.startswith("/"): + raw = self._sign_media_path(raw) + url = _normalize_resolved_url(raw, base_url) + if not url: + continue + items.append( + MediaItem( + url=url, + width=None, + height=None, + mime_type=resolved[1], + filename=title, + ) + ) + + if not items: + raise UpdateFailed("Could not resolve any media source images") + + return { + "title": self.entry.title, + "items": items, + } + + def _sign_media_path(self, path: str) -> str: + """Sign a relative ``/media/...`` path so it can be fetched server-side. + + Mirrors what Home Assistant's ``media_source/resolve_media`` websocket + handler does: quote the path and append an ``authSig`` signature. Uses + the content user so signing works from a background task with no + request context (the same mechanism that lets Cast devices fetch local + media). Signs for a window comfortably longer than the album refresh + interval; every refresh re-signs, so URLs stay fresh. + """ + from urllib.parse import quote + + try: + from homeassistant.components.http.auth import async_sign_path + except Exception: # pragma: no cover - http always present + return path + + quoted = quote(path) + expiration = timedelta(hours=max(48, int(self.store.refresh_hours) * 2 + 1)) + for kwargs in ({"use_content_user": True}, {}): + try: + return async_sign_path(self.hass, quoted, expiration, **kwargs) + except TypeError: + # Older/newer signature without ``use_content_user``. + continue + except Exception as err: + _LOGGER.debug("media_source: failed to sign %s: %s", path, err) + return path + return path + + async def _browse_media_source( + self, + media_source, + content_id: str, + collected: list[tuple[str, str | None]], + depth: int, + ) -> None: + """Recursively walk a media source tree collecting image leaves.""" + if len(collected) >= _MEDIA_SOURCE_MAX_ITEMS or depth > _MEDIA_SOURCE_MAX_DEPTH: + return + browsed = await media_source.async_browse_media(self.hass, content_id) + children = getattr(browsed, "children", None) or [] + for child in children: + if len(collected) >= _MEDIA_SOURCE_MAX_ITEMS: + break + child_id = getattr(child, "media_content_id", None) + if not child_id: + continue + title = getattr(child, "title", None) + if _is_junk_media_title(title): + continue + if _media_node_is_image( + getattr(child, "media_class", None), + getattr(child, "media_content_type", None), + ): + collected.append((child_id, title)) + elif getattr(child, "can_expand", False): + await self._browse_media_source( + media_source, child_id, collected, depth + 1 + ) + + async def _resolve_media( + self, media_source, content_id: str + ) -> tuple[str, str | None] | None: + """Resolve a media content id to ``(url, mime_type)`` or ``None``.""" + try: + try: + play = await media_source.async_resolve_media( + self.hass, content_id, None + ) + except TypeError: + # Older cores: async_resolve_media(hass, content_id). + play = await media_source.async_resolve_media(self.hass, content_id) + except Exception as err: + _LOGGER.debug("media_source: failed to resolve %s: %s", content_id, err) + return None + url = getattr(play, "url", None) + if not url: + return None + return url, getattr(play, "mime_type", None) + + def _internal_base_url(self) -> str: + """Best-effort internal base URL for site-relative media URLs.""" + try: + from homeassistant.helpers.network import get_url + + return get_url(self.hass, prefer_external=False, allow_ip=True) + except Exception: + return "" + + async def _update_immich(self) -> dict[str, Any]: + """Build the item list from an Immich album or person via its API. + + Unlike Media Source, the Immich API exposes per-photo metadata, so + capture date, GPS/location, and description all work. Dates come from + the asset list up front; location and description are filled in by the + background enrichment worker (one asset-detail call each, cached). + """ + from . import immich as immich_api + + url = self.entry.data.get(CONF_IMMICH_URL) + api_key = self.entry.data.get(CONF_IMMICH_API_KEY) + sel_type = self.entry.data.get(CONF_IMMICH_SELECTION_TYPE) + sel_id = self.entry.data.get(CONF_IMMICH_SELECTION_ID) + size = self.entry.data.get(CONF_IMMICH_IMAGE_SIZE, DEFAULT_IMMICH_IMAGE_SIZE) + if not url or not api_key or not sel_type: + raise UpdateFailed("Immich provider is missing URL, API key, or selection") + + # ``album``/``albums`` and ``person``/``people`` need a target id; + # composite/favorites/all/random/search do not (an empty composite + # means "all photos"). + if sel_type in ("album", "albums", "person", "people") and not sel_id: + raise UpdateFailed("Immich provider is missing the album/person id") + + filter_body = None + raw_filter = self.entry.data.get(CONF_IMMICH_FILTER) + if sel_type in ("search", "composite") and raw_filter: + try: + parsed = json.loads(raw_filter) + if isinstance(parsed, dict): + filter_body = parsed + except (ValueError, TypeError): + raise UpdateFailed("Immich search filter is not valid JSON") + + client = immich_api.ImmichClient(self.hass, url, api_key) + # Auth header the camera must send when fetching image bytes. Sent + # server-side only, so the key never reaches the browser or the + # ``current_url`` attribute. + self.image_request_headers = dict(client.image_headers) + + try: + assets = await client.async_collect_assets(sel_type, sel_id, filter_body) + except Exception as err: + raise UpdateFailed(f"Error querying Immich: {err}") from err + + if not assets: + raise UpdateFailed("No images found for the selected Immich source") + + items: list[MediaItem] = [] + for a in assets: + aid = a.get("id") + if not aid: + continue + captured = immich_api._to_epoch_ms( + a.get("localDateTime") + ) or immich_api._to_epoch_ms(a.get("fileCreatedAt")) + w = a.get("width") + h = a.get("height") + items.append( + MediaItem( + url=immich_api.build_image_url(client.base_url, aid, size), + width=w if isinstance(w, int) else None, + height=h if isinstance(h, int) else None, + mime_type=None, + filename=a.get("originalFileName"), + captured_at=captured, + source_id=aid, + ) + ) + + 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 + + url = self.entry.data.get(CONF_IMMICH_URL) + api_key = self.entry.data.get(CONF_IMMICH_API_KEY) + if not item.source_id or not url or not api_key: + item.exif_scanned = True + return + client = immich_api.ImmichClient(self.hass, url, api_key) + try: + asset = await client.async_get_asset(item.source_id) + except Exception as err: + _LOGGER.debug("Immich: failed to fetch asset %s: %s", item.source_id, err) + item.exif_scanned = True + return + info = immich_api.parse_asset_exif(asset) + if "captured_at" in info: + item.captured_at = info["captured_at"] + if "latitude" in info and "longitude" in info: + item.latitude = info["latitude"] + item.longitude = info["longitude"] + if "location" in info: + item.location = info["location"] + if "description" in info: + item.description = info["description"] + item.exif_scanned = True + async def _enrich_items_background(self, data: dict[str, Any]) -> None: """Read EXIF for unscanned local files, then reverse-geocode. @@ -865,6 +1413,24 @@ class AlbumCoordinator(DataUpdateCoordinator): await asyncio.sleep(0) continue + if self.provider == PROVIDER_IMMICH: + try: + await self._enrich_immich_item(item) + except asyncio.CancelledError: + raise + except Exception as err: # noqa: BLE001 + _LOGGER.debug("Immich enrich error: %s", err) + item.exif_scanned = True + scanned_since_save += 1 + self._enrich_progress["exif_done"] = ( + self._enrich_progress.get("exif_done", 0) + 1 + ) + if scanned_since_save >= _EXIF_BATCH_SAVE: + scanned_since_save = 0 + await self._save_cached_items(data) + self.async_set_updated_data(data) + continue + url = item.url if not url.startswith("file://"): item.exif_scanned = True @@ -883,6 +1449,8 @@ class AlbumCoordinator(DataUpdateCoordinator): if "captured_at" in info: item.captured_at = info["captured_at"] + if "description" in info: + item.description = info["description"] if "latitude" in info and "longitude" in info: item.latitude = info["latitude"] item.longitude = info["longitude"] @@ -1202,6 +1770,21 @@ class AlbumCoordinator(DataUpdateCoordinator): byte_size=byte_size, )) + # Cross-source date enrichment: publicalbum.org often returns a fuller + # item list but with no (or partial) date metadata, while batchexecute + # returns dated items. Where the same photo appears in both, backfill + # the publicalbum item's captured_at / uploaded_at from its dated + # batchexecute twin (matched by the stable per-photo URL key). This + # keeps the larger item count while restoring the dates the date + # filter needs. See issue #18. + enriched = _enrich_missing_dates(api_items, scraped_items) + if enriched: + _LOGGER.info( + "Album scraper: enriched %d publicalbum item(s) with " + "batchexecute dates", + enriched, + ) + # Pick the source with more items; prefer publicalbum.org on a tie # because its URLs come pre-decorated with size hints. if len(scraped_items) > len(api_items): diff --git a/custom_components/album_slideshow/immich.py b/custom_components/album_slideshow/immich.py new file mode 100644 index 0000000..64ab3b0 --- /dev/null +++ b/custom_components/album_slideshow/immich.py @@ -0,0 +1,375 @@ +"""Immich (direct API) client and pure parsing helpers. + +Talks to an Immich server using an API key. HTTP lives in ``ImmichClient``; +the parsing/URL helpers are pure functions so they can be unit-tested without +a live server or aiohttp. + +API shape (Immich v1.13x / v3, ``/api`` prefix, ``x-api-key`` header): +- ``GET /api/server/about`` -> ``{version, ...}`` (used to validate URL + key) +- ``GET /api/albums`` -> ``[{id, albumName, assetCount}]`` +- ``GET /api/people`` -> ``{people: [{id, name}]}`` +- ``POST /api/search/metadata`` ``{albumIds|personIds, type, size, page}`` + -> ``{assets: {items: [...], total, nextPage}}``. List items carry + ``id``/``type``/``localDateTime``/``fileCreatedAt``/``width``/``height``/ + ``originalFileName`` but NOT ``exifInfo``. +- ``GET /api/assets/{id}`` -> full asset incl ``exifInfo`` (lat/long, city, + country, description) - used to enrich location/description per asset. +- Image bytes: ``/api/assets/{id}/thumbnail?size=preview|fullsize`` or + ``/api/assets/{id}/original`` (all require the ``x-api-key`` header). +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any + +import async_timeout +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +_TIMEOUT = 30 +_PAGE_SIZE = 1000 +_MAX_ASSETS = 20_000 + + +def normalize_base_url(url: str) -> str: + """Strip trailing slashes and a trailing ``/api`` from a base URL.""" + u = (url or "").strip().rstrip("/") + if u.endswith("/api"): + u = u[: -len("/api")] + return u + + +def build_image_url(base_url: str, asset_id: str, size: str) -> str: + """Build the image URL for an asset at the requested size. + + ``preview`` / ``fullsize`` map to the thumbnail endpoint; ``original`` + fetches the untouched original file. The API key is NOT included here - it + is sent as a request header so it never leaks into logs or the camera's + ``current_url`` attribute. + """ + base = normalize_base_url(base_url) + if size == "original": + return f"{base}/api/assets/{asset_id}/original" + thumb_size = "fullsize" if size == "fullsize" else "preview" + return f"{base}/api/assets/{asset_id}/thumbnail?size={thumb_size}" + + +def _to_epoch_ms(value: Any) -> int | None: + """Parse an ISO-8601 timestamp to epoch milliseconds, or ``None``.""" + if not isinstance(value, str) or not value: + return None + try: + iso = value.replace("Z", "+00:00") + dt = datetime.fromisoformat(iso) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + try: + return int(dt.timestamp() * 1000) + except (OverflowError, OSError, ValueError): + return None + + +def location_label(city: Any, state: Any, country: Any) -> str | None: + """Build a short ``"City, Country"`` style label from EXIF place fields. + + Prefers ``city`` for the locality, falling back to ``state``. Appends the + country when present. Returns ``None`` when nothing usable is available. + """ + parts: list[str] = [] + locality = None + for candidate in (city, state): + if isinstance(candidate, str) and candidate.strip(): + locality = candidate.strip() + break + if locality: + parts.append(locality) + if isinstance(country, str) and country.strip(): + parts.append(country.strip()) + return ", ".join(parts) if parts else None + + +def parse_search_page(payload: Any) -> tuple[list[dict[str, Any]], int | None]: + """Return ``(image_items, next_page)`` from a search/metadata response. + + Filters out non-image assets and anything trashed/archived. ``next_page`` + is the page number to request next, or ``None`` when done. + """ + assets = (payload or {}).get("assets") if isinstance(payload, dict) else None + if not isinstance(assets, dict): + return [], None + items = assets.get("items") + out = _filter_image_items(items) + next_page = assets.get("nextPage") + if isinstance(next_page, str) and next_page.isdigit(): + next_page = int(next_page) + if not isinstance(next_page, int): + next_page = None + return out, next_page + + +def parse_random(payload: Any) -> list[dict[str, Any]]: + """Return image items from a ``/api/search/random`` response. + + ``search/random`` returns a plain list of assets (no pagination wrapper). + """ + if isinstance(payload, list): + return _filter_image_items(payload) + # Some cores wrap it like search/metadata; handle that too. + if isinstance(payload, dict): + assets = payload.get("assets") + if isinstance(assets, dict): + return _filter_image_items(assets.get("items")) + return [] + + +def _filter_image_items(items: Any) -> list[dict[str, Any]]: + """Keep only non-trashed, non-archived image assets with an id.""" + out: list[dict[str, Any]] = [] + if isinstance(items, list): + for it in items: + if not isinstance(it, dict): + continue + if str(it.get("type", "")).upper() != "IMAGE": + continue + if it.get("isTrashed") or it.get("isArchived"): + continue + if not it.get("id"): + continue + out.append(it) + return out + + +def build_search_body( + selection_type: str, selection_id: str | None, filter_body: dict | None +) -> dict[str, Any]: + """Build the ``search/metadata`` request body for a selection. + + Always constrains to images. For ``search`` the user-supplied filter is + used as a base (with ``type`` forced to IMAGE). ``album``/``person`` add + the id filter; ``favorites`` sets ``isFavorite``; ``all`` adds nothing. + """ + body: dict[str, Any] = {"type": "IMAGE"} + if selection_type == "search" and isinstance(filter_body, dict): + body = dict(filter_body) + body["type"] = "IMAGE" + elif selection_type == "album" and selection_id: + body["albumIds"] = [selection_id] + elif selection_type == "person" and selection_id: + body["personIds"] = [selection_id] + elif selection_type == "favorites": + body["isFavorite"] = True + # ``all`` -> no extra filter (whole library). + return body + + +def parse_composite_selection(selection_id: str | None) -> dict[str, Any]: + """Parse a composite selection id into ``{albums, people, favorites}``. + + The id is a JSON object; anything malformed degrades to an empty + composite (which means "all photos"). + """ + albums: list[str] = [] + people: list[str] = [] + favorites = False + if selection_id: + try: + data = json.loads(selection_id) + except (ValueError, TypeError): + data = None + if isinstance(data, dict): + albums = [a for a in data.get("albums", []) if isinstance(a, str) and a] + people = [p for p in data.get("people", []) if isinstance(p, str) and p] + favorites = bool(data.get("favorites")) + return {"albums": albums, "people": people, "favorites": favorites} + + +def build_composite_bodies( + selection_id: str | None, filter_body: dict | None = None +) -> list[dict[str, Any]]: + """Build one ``search/metadata`` body per composite union member. + + Immich has no OR, so each album, person, the favorites flag and any + custom filter becomes its own image query; the caller unions the + results. An empty composite yields a single unfiltered query -> the + whole library ("all photos"). + """ + sel = parse_composite_selection(selection_id) + bodies: list[dict[str, Any]] = [] + for aid in sel["albums"]: + bodies.append({"type": "IMAGE", "albumIds": [aid]}) + for pid in sel["people"]: + bodies.append({"type": "IMAGE", "personIds": [pid]}) + if sel["favorites"]: + bodies.append({"type": "IMAGE", "isFavorite": True}) + if isinstance(filter_body, dict) and filter_body: + member = dict(filter_body) + member["type"] = "IMAGE" + bodies.append(member) + if not bodies: + bodies.append({"type": "IMAGE"}) + return bodies + + +def parse_asset_exif(asset: Any) -> dict[str, Any]: + """Extract the metadata we surface from a full asset detail response.""" + out: dict[str, Any] = {} + if not isinstance(asset, dict): + return out + exif = asset.get("exifInfo") + if not isinstance(exif, dict): + return out + captured = _to_epoch_ms(exif.get("dateTimeOriginal")) or _to_epoch_ms( + asset.get("localDateTime") + ) + if captured is not None: + out["captured_at"] = captured + lat = exif.get("latitude") + lon = exif.get("longitude") + if isinstance(lat, (int, float)) and isinstance(lon, (int, float)): + # Immich returns 0/0 or null when there is no fix; treat 0,0 as none. + if not (abs(lat) < 1e-6 and abs(lon) < 1e-6): + out["latitude"] = float(lat) + out["longitude"] = float(lon) + label = location_label(exif.get("city"), exif.get("state"), exif.get("country")) + if label: + out["location"] = label + desc = exif.get("description") + if isinstance(desc, str) and desc.strip(): + out["description"] = desc.strip() + return out + + +class ImmichClient: + """Thin async wrapper over the Immich REST API.""" + + def __init__(self, hass, base_url: str, api_key: str) -> None: + self.hass = hass + self.base_url = normalize_base_url(base_url) + self.api_key = api_key + + @property + def headers(self) -> dict[str, str]: + return {"x-api-key": self.api_key, "Accept": "application/json"} + + @property + def image_headers(self) -> dict[str, str]: + return {"x-api-key": self.api_key} + + async def _get(self, path: str) -> Any: + session = async_get_clientsession(self.hass) + async with async_timeout.timeout(_TIMEOUT): + async with session.get(self.base_url + path, headers=self.headers) as resp: + resp.raise_for_status() + return await resp.json() + + async def _post(self, path: str, body: dict[str, Any]) -> Any: + session = async_get_clientsession(self.hass) + async with async_timeout.timeout(_TIMEOUT): + async with session.post( + self.base_url + path, headers=self.headers, json=body + ) as resp: + resp.raise_for_status() + return await resp.json() + + async def async_validate(self) -> str | None: + """Return the server version if the URL + key work, else raise.""" + data = await self._get("/api/server/about") + return data.get("version") if isinstance(data, dict) else None + + async def async_list_albums(self) -> list[dict[str, Any]]: + data = await self._get("/api/albums") + return data if isinstance(data, list) else [] + + async def async_list_people(self) -> list[dict[str, Any]]: + data = await self._get("/api/people") + if isinstance(data, dict): + people = data.get("people") + return people if isinstance(people, list) else [] + return data if isinstance(data, list) else [] + + async def async_collect_assets( + self, + selection_type: str, + selection_id: str | None = None, + filter_body: dict | None = None, + ) -> list[dict[str, Any]]: + """Collect image assets for a selection. + + ``random`` uses ``/api/search/random`` (a single, unpaginated batch). + Everything else pages through ``/api/search/metadata`` with a body + built from the selection. + """ + if selection_type == "random": + body = {"size": min(_PAGE_SIZE, 250), "type": "IMAGE"} + if isinstance(filter_body, dict): + merged = dict(filter_body) + merged.update(body) + body = merged + payload = await self._post("/api/search/random", body) + return parse_random(payload) + + if selection_type == "people": + # Immich treats multiple personIds in one query as AND (only photos + # where everyone appears together). To get OR (any of the people), + # query each person separately and union by asset id. See #19. + ids = [p for p in (selection_id or "").split(",") if p] + bodies = [{"type": "IMAGE", "personIds": [p]} for p in ids] + return await self._collect_union(bodies) + + if selection_type == "albums": + # Same OR behavior for a set of albums: query each album on its own + # and union the results, deduped by asset id. + ids = [a for a in (selection_id or "").split(",") if a] + bodies = [{"type": "IMAGE", "albumIds": [a]} for a in ids] + return await self._collect_union(bodies) + + if selection_type == "composite": + # A mix of albums, people, favorites and/or a custom filter. Each + # is queried on its own and unioned; an empty composite means the + # whole library. See #19. + bodies = build_composite_bodies(selection_id, filter_body) + return await self._collect_union(bodies) + + base = build_search_body(selection_type, selection_id, filter_body) + return await self._collect_metadata(base) + + async def _collect_metadata(self, base: dict[str, Any]) -> list[dict[str, Any]]: + """Page through ``search/metadata`` for a prebuilt body.""" + collected: list[dict[str, Any]] = [] + page: int | None = 1 + while page is not None and len(collected) < _MAX_ASSETS: + body = dict(base) + body["size"] = _PAGE_SIZE + body["page"] = page + payload = await self._post("/api/search/metadata", body) + items, next_page = parse_search_page(payload) + collected.extend(items) + page = next_page + return collected + + async def _collect_union( + self, bodies: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Union several ``search/metadata`` queries (OR), deduped by asset id. + + Each body is queried on its own so the results are a union (any of), + not Immich's default AND (only assets that match every filter at + once). See #19. + """ + seen: set[str] = set() + out: list[dict[str, Any]] = [] + for body in bodies: + if len(out) >= _MAX_ASSETS: + break + items = await self._collect_metadata(body) + for it in items: + aid = it.get("id") + if aid and aid not in seen: + seen.add(aid) + out.append(it) + return out + + async def async_get_asset(self, asset_id: str) -> dict[str, Any]: + return await self._get(f"/api/assets/{asset_id}") diff --git a/custom_components/album_slideshow/manifest.json b/custom_components/album_slideshow/manifest.json index eccdbfb..1981911 100644 --- a/custom_components/album_slideshow/manifest.json +++ b/custom_components/album_slideshow/manifest.json @@ -3,10 +3,10 @@ "name": "Album Slideshow Camera", "codeowners": ["@eyalgal"], "config_flow": true, - "dependencies": ["http", "frontend"], + "dependencies": ["http", "frontend", "media_source"], "documentation": "https://github.com/eyalgal/album_slideshow", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eyalgal/album_slideshow/issues", "requirements": ["Pillow"], - "version": "1.0.0" + "version": "1.2.2" } diff --git a/custom_components/album_slideshow/playlist.py b/custom_components/album_slideshow/playlist.py index f119d4d..1eca176 100644 --- a/custom_components/album_slideshow/playlist.py +++ b/custom_components/album_slideshow/playlist.py @@ -17,6 +17,9 @@ from .const import ( DATE_FILTER_ON_THIS_DAY, DATE_FILTER_THIS_MONTH, DATE_FILTER_THIS_YEAR, + DEFAULT_MISSING_DATE_MODE, + MISSING_DATE_EXCLUDE, + MISSING_DATE_USE_UPLOADED, ORDER_ALBUM, ORDER_NEWEST_ADDED, ORDER_NEWEST_TAKEN, @@ -72,12 +75,20 @@ def filter_items( items: Iterable[T], *, mode: str, + missing_date: str = DEFAULT_MISSING_DATE_MODE, now: datetime | None = None, ) -> list[T]: - """Filter items by ``captured_at`` according to ``mode``. + """Filter items by date according to ``mode``. - Items with no ``captured_at`` are kept by default unless the mode is - ``on_this_day`` (treated as a strict filter). + ``missing_date`` decides what happens to photos that have no EXIF + ``captured_at``: + + - ``use_uploaded_at`` - fall back to ``uploaded_at`` for the comparison + so date windows stay meaningful. Photos with neither timestamp are + kept for window filters and dropped for the strict ``on_this_day``. + - ``include`` - keep undated photos for window filters (the legacy + behaviour); the strict ``on_this_day`` still drops them. + - ``exclude`` - drop undated photos entirely. ``now`` is overridable for deterministic tests. """ @@ -93,11 +104,25 @@ def filter_items( out: list[T] = [] for it in items: ts = getattr(it, "captured_at", None) - if not isinstance(ts, int): - if not strict: + if isinstance(ts, int): + if pred(ts): out.append(it) continue - if pred(ts): + + # No capture date: behaviour depends on ``missing_date``. + if missing_date == MISSING_DATE_EXCLUDE: + continue + if missing_date == MISSING_DATE_USE_UPLOADED: + up = getattr(it, "uploaded_at", None) + if isinstance(up, int): + if pred(up): + out.append(it) + continue + # No upload date either: fall through to the lenient default. + + # ``include`` (or ``use_uploaded_at`` with no usable date): keep for + # window filters, drop for strict modes like ``on_this_day``. + if not strict: out.append(it) return out diff --git a/custom_components/album_slideshow/select.py b/custom_components/album_slideshow/select.py index aed6532..8fa3cd4 100644 --- a/custom_components/album_slideshow/select.py +++ b/custom_components/album_slideshow/select.py @@ -19,6 +19,8 @@ from .const import ( ORDER_RANDOM, DATE_FILTER_OPTIONS, DATE_FILTER_OFF, + MISSING_DATE_OPTIONS, + MISSING_DATE_USE_UPLOADED, ) from .store import SlideshowStore @@ -39,6 +41,7 @@ async def async_setup_entry( AspectRatioSelect(entry, store), MaxResolutionSelect(entry, store), DateFilterSelect(entry, store), + MissingDateModeSelect(entry, store), ] ) @@ -239,3 +242,31 @@ class DateFilterSelect(_BaseSelect): if old and old.state in self.options: self.store.date_filter = old.state self.store.notify() + + +class MissingDateModeSelect(_BaseSelect): + _attr_icon = "mdi:calendar-question" + + def __init__(self, entry: ConfigEntry, store: SlideshowStore) -> None: + super().__init__(entry, store) + self._attr_unique_id = f"{entry.entry_id}_missing_date_mode" + self._attr_name = "Missing capture date" + self._attr_options = list(MISSING_DATE_OPTIONS) + + @property + def current_option(self): + value = self.store.missing_date_mode + return value if value in self.options else MISSING_DATE_USE_UPLOADED + + async def async_select_option(self, option: str) -> None: + if option not in self.options: + return + self.store.missing_date_mode = option + self.store.notify() + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + old = await self.async_get_last_state() + if old and old.state in self.options: + self.store.missing_date_mode = old.state + self.store.notify() diff --git a/custom_components/album_slideshow/store.py b/custom_components/album_slideshow/store.py index dcbaf58..fa8b5c0 100644 --- a/custom_components/album_slideshow/store.py +++ b/custom_components/album_slideshow/store.py @@ -15,6 +15,7 @@ from .const import ( DEFAULT_IMAGE_CACHE_MB, DEFAULT_MAX_RESOLUTION, DEFAULT_DATE_FILTER, + DEFAULT_MISSING_DATE_MODE, ) @@ -37,6 +38,9 @@ class SlideshowStore: # Date filter mode (preset windows like this_year / on_this_day). date_filter: str = DEFAULT_DATE_FILTER + # How the date filter treats photos with no EXIF capture date. + missing_date_mode: str = DEFAULT_MISSING_DATE_MODE + # Pause toggle - when True, the slideshow holds on the current frame. paused: bool = False diff --git a/custom_components/album_slideshow/strings.json b/custom_components/album_slideshow/strings.json index b7e2e0c..60af11b 100644 --- a/custom_components/album_slideshow/strings.json +++ b/custom_components/album_slideshow/strings.json @@ -24,11 +24,46 @@ "local_path": "Folder path", "recursive": "Include subfolders" } + }, + "media_source": { + "title": "Media Source", + "description": "Paste a Media Source content id pointing at a folder-like node, for example media-source://immich/SERVER_ID|people or media-source://media_source/local/Albums. Browse to the folder in the HA media browser and copy its media-source:// id. Note: Media Source photos have no EXIF, so date, GPS and description features do not apply.", + "data": { + "album_name": "Album name", + "media_content_id": "Media Source id (media-source://...)" + } + }, + "immich": { + "title": "Immich", + "description": "Connect directly to your Immich server for full photo metadata (date, location, description). Create an API key in Immich under Account Settings > API Keys.", + "data": { + "immich_url": "Immich URL (e.g. http://192.168.1.10:2283)", + "immich_api_key": "API key" + } + }, + "immich_select": { + "title": "Immich source", + "description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add an Immich search filter (JSON) to include those results too - see the README for examples.", + "data": { + "album_name": "Name", + "albums": "Albums", + "people": "People", + "favorites": "Include favorites", + "immich_filter": "Extra search filter (JSON, optional)", + "immich_image_size": "Image quality" + } } }, "error": { "invalid_album_url": "That does not look like a Google Photos shared album link.", - "invalid_path": "Path is empty or invalid." + "invalid_path": "Path is empty or invalid.", + "invalid_media_source": "That does not look like a Media Source id (should start with media-source://).", + "immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.", + "immich_no_content": "No albums or named people were found on this Immich server.", + "immich_filter_required": "Custom search needs a JSON filter.", + "immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.", + "immich_people_required": "Pick at least one person for the People source.", + "immich_albums_required": "Pick at least one album for the Albums source." } }, "options": { diff --git a/custom_components/album_slideshow/translations/en.json b/custom_components/album_slideshow/translations/en.json index b7e2e0c..60af11b 100644 --- a/custom_components/album_slideshow/translations/en.json +++ b/custom_components/album_slideshow/translations/en.json @@ -24,11 +24,46 @@ "local_path": "Folder path", "recursive": "Include subfolders" } + }, + "media_source": { + "title": "Media Source", + "description": "Paste a Media Source content id pointing at a folder-like node, for example media-source://immich/SERVER_ID|people or media-source://media_source/local/Albums. Browse to the folder in the HA media browser and copy its media-source:// id. Note: Media Source photos have no EXIF, so date, GPS and description features do not apply.", + "data": { + "album_name": "Album name", + "media_content_id": "Media Source id (media-source://...)" + } + }, + "immich": { + "title": "Immich", + "description": "Connect directly to your Immich server for full photo metadata (date, location, description). Create an API key in Immich under Account Settings > API Keys.", + "data": { + "immich_url": "Immich URL (e.g. http://192.168.1.10:2283)", + "immich_api_key": "API key" + } + }, + "immich_select": { + "title": "Immich source", + "description": "Tick any mix of albums, people and favorites - they are combined into one slideshow. Each list is searchable and has a Select all option. Leave everything empty for all photos. Advanced: add an Immich search filter (JSON) to include those results too - see the README for examples.", + "data": { + "album_name": "Name", + "albums": "Albums", + "people": "People", + "favorites": "Include favorites", + "immich_filter": "Extra search filter (JSON, optional)", + "immich_image_size": "Image quality" + } } }, "error": { "invalid_album_url": "That does not look like a Google Photos shared album link.", - "invalid_path": "Path is empty or invalid." + "invalid_path": "Path is empty or invalid.", + "invalid_media_source": "That does not look like a Media Source id (should start with media-source://).", + "immich_cannot_connect": "Could not connect to Immich. Check the URL and API key.", + "immich_no_content": "No albums or named people were found on this Immich server.", + "immich_filter_required": "Custom search needs a JSON filter.", + "immich_filter_invalid": "The filter must be a valid JSON object. See the README for examples.", + "immich_people_required": "Pick at least one person for the People source.", + "immich_albums_required": "Pick at least one album for the Albums source." } }, "options": { diff --git a/custom_components/album_slideshow/www/album-slideshow-card.js b/custom_components/album_slideshow/www/album-slideshow-card.js index 47b856f..309af12 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.0.0"; +const VERSION = "1.2.2"; const ANIMATED_TRANSITIONS = [ "fade", @@ -46,10 +46,10 @@ const TRANSITIONS = new Set(["random", "none", ...ANIMATED_TRANSITIONS]); const FIT_MODES = new Set(["auto", "cover", "contain"]); -// Caption overlay (date / location). ``show`` is an ordered subset of -// these fields; ``position`` is one of a 3x3 anchor grid; ``date_format`` -// is one of the named presets below or a custom token string. -const CAPTION_FIELDS = ["date", "location"]; +// Caption overlay (date / location / description). ``show`` is an ordered +// subset of these fields; ``position`` is one of a 3x3 anchor grid; +// ``date_format`` is one of the named presets below or a custom token string. +const CAPTION_FIELDS = ["date", "location", "description"]; const CAPTION_POSITIONS = new Set([ "top-left", "top-center", @@ -492,6 +492,7 @@ function createAlbumSlideshowCardClass(Base) { location: attrs.location, latitude: attrs.latitude, longitude: attrs.longitude, + description: attrs.description, } : null; this._loadAndSwap(url, fit, blurBackdrop, captionData); @@ -644,6 +645,7 @@ function createAlbumSlideshowCardClass(Base) { location: data.location ?? null, latitude: data.latitude ?? null, longitude: data.longitude ?? null, + description: data.description ?? null, }, ]; } @@ -656,6 +658,8 @@ function createAlbumSlideshowCardClass(Base) { if (txt) lines.push(txt); } else if (field === "location") { if (frame.location) lines.push(String(frame.location)); + } else if (field === "description") { + if (frame.description) lines.push(String(frame.description)); } } return lines; @@ -890,6 +894,7 @@ const TAP_OPTIONS = [ const CAPTION_SHOW_OPTIONS = [ { value: "date", label: "Date" }, { value: "location", label: "Location" }, + { value: "description", label: "Description" }, ]; const CAPTION_POSITION_OPTIONS = [ @@ -952,6 +957,7 @@ const CAPTION_DEFAULTS = { const LIVE_FIELDS = [ "paused", "date_filter", + "missing_date_mode", "portrait_mode", "order_mode", "slide_interval", @@ -962,6 +968,7 @@ const LIVE_FIELDS = [ const LIVE_SUFFIX = { paused: "_paused", date_filter: "_date_filter", + missing_date_mode: "_missing_date_mode", portrait_mode: "_portrait_mode", order_mode: "_order_mode", slide_interval: "_interval", @@ -974,6 +981,7 @@ const LIVE_SUFFIX = { const LIVE_LABELS = { live_paused: "Pause slideshow", live_date_filter: "Date filter", + live_missing_date_mode: "Missing capture date", live_portrait_mode: "Orientation mismatch mode", live_order_mode: "Order mode", live_slide_interval: "Slide interval (seconds)", @@ -1130,6 +1138,7 @@ function createAlbumSlideshowCardEditorClass(Base) { } for (const [field, id] of [ ["date_filter", s.date_filter], + ["missing_date_mode", s.missing_date_mode], ["portrait_mode", s.portrait_mode], ["order_mode", s.order_mode], ]) { @@ -1263,7 +1272,7 @@ function createAlbumSlideshowCardEditorClass(Base) { }, { type: "expandable", - title: "Caption (date & location)", + title: "Caption (date, location & description)", icon: "mdi:format-text", schema: [ { name: "caption_enabled", selector: { boolean: {} } }, @@ -1377,7 +1386,7 @@ function createAlbumSlideshowCardEditorClass(Base) { const e = st(s.paused); out.live_paused = !!e && e.state === "on"; } - for (const f of ["date_filter", "portrait_mode", "order_mode"]) { + for (const f of ["date_filter", "missing_date_mode", "portrait_mode", "order_mode"]) { if (s[f]) { const e = st(s[f]); out[`live_${f}`] = e ? e.state : ""; @@ -1429,12 +1438,16 @@ function createAlbumSlideshowCardEditorClass(Base) { "How long the card freezes its slide after a tap. 0 disables it.", caption_date_format: "Pick a preset or type a custom format (YYYY, MMMM, MMM, MM, DD, D).", + caption_show: + "Description comes from the photo's EXIF/IPTC/XMP caption and is only available with the local-folder provider.", caption_per_image: - "When a portrait pair is shown, caption each photo with its own date and location.", + "When a portrait pair is shown, caption each photo with its own date, location and description.", caption_color: "CSS color, e.g. #ffffff or white.", caption_font_size: "CSS size, e.g. 14px, 1.1em.", live_paused: "These control the Album Slideshow integration directly and apply everywhere this album is shown, not only this card.", + live_missing_date_mode: + "What a date filter does with photos that have no capture date: use the upload date, keep them, or drop them.", }; return helpers[s.name] || ""; }; @@ -1558,6 +1571,7 @@ function createAlbumSlideshowCardEditorClass(Base) { }); } else if ( field === "date_filter" || + field === "missing_date_mode" || field === "portrait_mode" || field === "order_mode" ) { @@ -1636,7 +1650,9 @@ function createAlbumSlideshowCardEditorClass(Base) { if (data.caption_enabled) { let show = data.caption_show; if (!Array.isArray(show)) show = show ? [show] : []; - show = show.filter((v) => v === "date" || v === "location"); + show = show.filter( + (v) => v === "date" || v === "location" || v === "description", + ); if (show.length > 0) { const cap = { show }; const pos = data.caption_position || CAPTION_DEFAULTS.position; diff --git a/custom_components/mail_and_packages/__init__.py b/custom_components/mail_and_packages/__init__.py index 9f99302..06a5a8b 100644 --- a/custom_components/mail_and_packages/__init__.py +++ b/custom_components/mail_and_packages/__init__.py @@ -6,8 +6,12 @@ import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_RESOURCES from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, +) +from homeassistant.helpers import ( + device_registry as dr, +) from . import const from .const import ( @@ -103,6 +107,9 @@ __all__ = [ _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + async def async_setup(hass: HomeAssistant, config_entry: MailAndPackagesConfigEntry): # pylint: disable=unused-argument """Disallow configuration via YAML.""" return True @@ -133,21 +140,13 @@ async def async_setup_entry( # Setup the data coordinator coordinator = MailDataUpdateCoordinator(hass, config, config_entry) - # Fetch initial data so we have data when entities subscribe - await coordinator.async_refresh() - - # Raise ConfigEntryNotReady if coordinator didn't update - if not coordinator.last_update_success: - if isinstance(coordinator.last_exception, ConfigEntryAuthFailed): - raise coordinator.last_exception - exc = coordinator.last_exception - detail = (str(exc) or type(exc).__name__) if exc else "unknown error" - _LOGGER.error("Error updating sensor data: %s", detail) - raise ConfigEntryNotReady - config_entry.runtime_data = MailAndPackagesData(coordinator=coordinator, cameras=[]) + # Fetch initial data in the background so setup doesn't block + hass.async_create_task(coordinator.async_refresh()) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True diff --git a/custom_components/mail_and_packages/__pycache__/__init__.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index a5dbd18..0000000 Binary files a/custom_components/mail_and_packages/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/__pycache__/application_credentials.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/application_credentials.cpython-314.pyc deleted file mode 100644 index 28f40af..0000000 Binary files a/custom_components/mail_and_packages/__pycache__/application_credentials.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/__pycache__/config_flow.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/config_flow.cpython-314.pyc deleted file mode 100644 index 14cd8d0..0000000 Binary files a/custom_components/mail_and_packages/__pycache__/config_flow.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/__pycache__/const.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/const.cpython-314.pyc deleted file mode 100644 index 3b8947c..0000000 Binary files a/custom_components/mail_and_packages/__pycache__/const.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/__pycache__/coordinator.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/coordinator.cpython-314.pyc deleted file mode 100644 index 9678665..0000000 Binary files a/custom_components/mail_and_packages/__pycache__/coordinator.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/__pycache__/entity.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/entity.cpython-314.pyc deleted file mode 100644 index e612396..0000000 Binary files a/custom_components/mail_and_packages/__pycache__/entity.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/__pycache__/helpers.cpython-314.pyc b/custom_components/mail_and_packages/__pycache__/helpers.cpython-314.pyc deleted file mode 100644 index 119d22b..0000000 Binary files a/custom_components/mail_and_packages/__pycache__/helpers.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/binary_sensor.py b/custom_components/mail_and_packages/binary_sensor.py index faba768..2446c6e 100644 --- a/custom_components/mail_and_packages/binary_sensor.py +++ b/custom_components/mail_and_packages/binary_sensor.py @@ -78,6 +78,8 @@ class PackagesBinarySensor(CoordinatorEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return True if the image is updated.""" + if self.coordinator.data is None: + return False if self._type in self.coordinator.data: _LOGGER.debug( "binary_sensor: %s value: %s", diff --git a/custom_components/mail_and_packages/camera.py b/custom_components/mail_and_packages/camera.py index 8931fb4..c0a2a40 100644 --- a/custom_components/mail_and_packages/camera.py +++ b/custom_components/mail_and_packages/camera.py @@ -182,7 +182,13 @@ class MailCam(CoordinatorEntity, Camera): def _read_file(path: str) -> bytes: with Path(path).open("rb") as f: - return f.read() + data = f.read() + if not data: + # A 0-byte image (e.g. a failed extraction that wrote an empty + # file) is as unservable as a missing one — raise so it routes + # through the same placeholder fallback below. + raise FileNotFoundError(f"empty image file: {path}") + return data try: image_bytes = await self.hass.async_add_executor_job( @@ -273,7 +279,7 @@ class MailCam(CoordinatorEntity, Camera): if required_keys.issubset(self.coordinator.data): image = self.coordinator.data[ATTR_USPS_IMAGE] path = self.coordinator.data[ATTR_IMAGE_PATH] - self._file_path = f"{self.hass.config.path()}/{path}{image}" + self._file_path = self.hass.config.path(path, image) self._is_generic = not self.coordinator.data.get("usps_update", False) _LOGGER.debug( "usps_camera camera - file path set to: %s", @@ -313,7 +319,7 @@ class MailCam(CoordinatorEntity, Camera): return image_path = self.coordinator.data.get(ATTR_IMAGE_PATH, "") - full_storage_path = Path(f"{self.hass.config.path()}/{image_path}") + full_storage_path = Path(self.hass.config.path(image_path)) gif_path = str(full_storage_path / "generic_deliveries.gif") resized_images = await self.hass.async_add_executor_job( @@ -383,7 +389,7 @@ class MailCam(CoordinatorEntity, Camera): image = self.coordinator.data[image_attr] path = f"{self.coordinator.data[ATTR_IMAGE_PATH]}{path_suffix}" - delivery_file_path = f"{self.hass.config.path()}/{path}{image}" + delivery_file_path = self.hass.config.path(path, image) is_no_mail = image.startswith( no_mail_check, @@ -448,7 +454,7 @@ class MailCam(CoordinatorEntity, Camera): image = self.coordinator.data[image_attr] image_path = self.coordinator.data[ATTR_IMAGE_PATH].rstrip("/") + "/" path = f"{image_path}{base_name}/" - coordinator_file_path = f"{self.hass.config.path()}/{path}{image}" + coordinator_file_path = self.hass.config.path(path, image) _LOGGER.debug( "=== %s CAMERA UPDATE === coordinator %s = '%s'", diff --git a/custom_components/mail_and_packages/config_flow.py b/custom_components/mail_and_packages/config_flow.py index c331ed6..0266ab6 100644 --- a/custom_components/mail_and_packages/config_flow.py +++ b/custom_components/mail_and_packages/config_flow.py @@ -57,6 +57,7 @@ from .const import ( CONF_STORAGE, CONF_UPS_CUSTOM_IMG, CONF_UPS_CUSTOM_IMG_FILE, + CONF_USPS_PLACEHOLDER, CONF_VERIFY_SSL, CONF_WALMART_CUSTOM_IMG, CONF_WALMART_CUSTOM_IMG_FILE, @@ -89,6 +90,7 @@ from .const import ( DEFAULT_STORAGE, DEFAULT_UPS_CUSTOM_IMG, DEFAULT_UPS_CUSTOM_IMG_FILE, + DEFAULT_USPS_PLACEHOLDER, DEFAULT_WALMART_CUSTOM_IMG, DEFAULT_WALMART_CUSTOM_IMG_FILE, DOMAIN, @@ -198,7 +200,9 @@ async def _check_forwarded_emails(user_input: dict[str, Any]) -> list[str]: return errors -def _validate_path_input(user_input: dict, errors: dict) -> None: +def _validate_path_input( + user_input: dict, errors: dict, hass: HomeAssistant | None = None +) -> None: """Validate path and file inputs.""" # List of (Toggle Key, File Key, Error Key) file_checks = [ @@ -233,11 +237,17 @@ def _validate_path_input(user_input: dict, errors: dict) -> None: for toggle, file_key, error_key in file_checks: if user_input.get(toggle) and file_key in user_input: - if not Path(user_input[file_key]).is_file(): + path = user_input[file_key] + if hass: + path = hass.config.path(path) + if not Path(path).is_file(): errors[error_key] = "file_not_found" if CONF_STORAGE in user_input: - if not Path(user_input[CONF_STORAGE]).exists(): + path = user_input[CONF_STORAGE] + if hass: + path = hass.config.path(path) + if not Path(path).exists(): errors[CONF_STORAGE] = "path_not_found" @@ -275,7 +285,9 @@ async def _validate_forwarded_emails(user_input: dict, errors: dict) -> None: errors[CONF_FORWARDED_EMAILS] = status[0] -async def _validate_user_input(user_input: dict) -> tuple: +async def _validate_user_input( + user_input: dict, hass: HomeAssistant | None = None +) -> tuple: """Validate user input from config flow. Returns tuple with error messages and modified user_input @@ -303,7 +315,7 @@ async def _validate_user_input(user_input: dict) -> tuple: errors[CONF_GENERATE_MP4] = "ffmpeg_not_found" # Validate file paths - _validate_path_input(user_input, errors) + _validate_path_input(user_input, errors, hass) # Normalize CONF_FOLDER: if it has exactly 1 folder, store as string if CONF_FOLDER in user_input: @@ -555,6 +567,10 @@ async def _get_schema_step_2( CONF_GENERATE_MP4, default=_get_default(CONF_GENERATE_MP4, False), ): cv.boolean, + vol.Optional( + CONF_USPS_PLACEHOLDER, + default=_get_default(CONF_USPS_PLACEHOLDER, DEFAULT_USPS_PLACEHOLDER), + ): cv.boolean, vol.Optional( CONF_ALLOW_EXTERNAL, default=_get_default(CONF_ALLOW_EXTERNAL, False), @@ -975,7 +991,7 @@ class MailAndPackagesFlowHandler( """Configure form step 2.""" self._errors = {} if user_input is not None: - self._errors, user_input = await _validate_user_input(user_input) + self._errors, user_input = await _validate_user_input(user_input, self.hass) self._data.update(user_input) _LOGGER.debug("RESOURCES: %s", self._data[CONF_RESOURCES]) if len(self._errors) == 0: @@ -1012,12 +1028,13 @@ class MailAndPackagesFlowHandler( CONF_FOLDER: DEFAULT_FOLDER, CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, CONF_CUSTOM_DAYS: DEFAULT_CUSTOM_DAYS, - CONF_PATH: self.hass.config.path() + DEFAULT_PATH, + CONF_PATH: self.hass.config.path(DEFAULT_PATH), CONF_DURATION: DEFAULT_GIF_DURATION, CONF_IMAGE_SECURITY: DEFAULT_IMAGE_SECURITY, CONF_IMAP_TIMEOUT: DEFAULT_IMAP_TIMEOUT, CONF_GENERATE_GRID: False, CONF_GENERATE_MP4: False, + CONF_USPS_PLACEHOLDER: DEFAULT_USPS_PLACEHOLDER, CONF_ALLOW_EXTERNAL: DEFAULT_ALLOW_EXTERNAL, CONF_CUSTOM_IMG: DEFAULT_CUSTOM_IMG, CONF_AMAZON_CUSTOM_IMG: DEFAULT_AMAZON_CUSTOM_IMG, @@ -1045,7 +1062,7 @@ class MailAndPackagesFlowHandler( self._errors = {} if user_input is not None: self._data.update(user_input) - self._errors, user_input = await _validate_user_input(self._data) + self._errors, user_input = await _validate_user_input(self._data, self.hass) if len(self._errors) == 0: return await self.async_step_config_storage() return await self._show_config_3(user_input) @@ -1076,7 +1093,7 @@ class MailAndPackagesFlowHandler( self._errors = {} if user_input is not None: self._data.update(user_input) - self._errors, user_input = await _validate_user_input(self._data) + self._errors, user_input = await _validate_user_input(self._data, self.hass) if len(self._errors) == 0: if ( self._data.get(CONF_CUSTOM_IMG) @@ -1117,7 +1134,7 @@ class MailAndPackagesFlowHandler( self._errors = {} if user_input is not None: self._data.update(user_input) - self._errors, user_input = await _validate_user_input(self._data) + self._errors, user_input = await _validate_user_input(self._data, self.hass) if len(self._errors) == 0: if any( sensor in self._data[CONF_RESOURCES] for sensor in AMAZON_SENSORS @@ -1150,7 +1167,7 @@ class MailAndPackagesFlowHandler( self._errors = {} if user_input is not None: self._data.update(user_input) - self._errors, user_input = await _validate_user_input(self._data) + self._errors, user_input = await _validate_user_input(self._data, self.hass) if len(self._errors) == 0: return self.async_create_entry( title=f"Mail and Packages ({self._data[CONF_HOST]})", @@ -1254,7 +1271,7 @@ class MailAndPackagesFlowHandler( _LOGGER.debug("Loading step 2...") if user_input is not None: self._data.update(user_input) - self._errors, user_input = await _validate_user_input(user_input) + self._errors, user_input = await _validate_user_input(user_input, self.hass) if len(self._errors) == 0: if self._data.get(CONF_ALLOW_FORWARDED_EMAILS, False): return await self.async_step_reconfig_forwarded_emails() @@ -1300,7 +1317,7 @@ class MailAndPackagesFlowHandler( self._errors = {} if user_input is not None: self._data.update(user_input) - self._errors, user_input = await _validate_user_input(self._data) + self._errors, user_input = await _validate_user_input(self._data, self.hass) if len(self._errors) == 0: return await self.async_step_reconfig_storage() @@ -1332,7 +1349,7 @@ class MailAndPackagesFlowHandler( self._errors = {} if user_input is not None: self._data.update(user_input) - self._errors, user_input = await _validate_user_input(self._data) + self._errors, user_input = await _validate_user_input(self._data, self.hass) if len(self._errors) == 0: has_custom_image = ( self._data.get(CONF_CUSTOM_IMG) @@ -1375,7 +1392,7 @@ class MailAndPackagesFlowHandler( self._errors = {} if user_input is not None: self._data.update(user_input) - self._errors, user_input = await _validate_user_input(self._data) + self._errors, user_input = await _validate_user_input(self._data, self.hass) if len(self._errors) == 0: if any( sensor in self._data.get(CONF_RESOURCES, []) @@ -1404,7 +1421,7 @@ class MailAndPackagesFlowHandler( self._errors = {} if user_input is not None: self._data.update(user_input) - self._errors, user_input = await _validate_user_input(self._data) + self._errors, user_input = await _validate_user_input(self._data, self.hass) if len(self._errors) == 0: self.hass.config_entries.async_update_entry( self._entry, diff --git a/custom_components/mail_and_packages/const.py b/custom_components/mail_and_packages/const.py index 26e07bd..1c5261b 100644 --- a/custom_components/mail_and_packages/const.py +++ b/custom_components/mail_and_packages/const.py @@ -12,7 +12,7 @@ from .entity import MailandPackagesBinarySensorEntityDescription DOMAIN = "mail_and_packages" DOMAIN_DATA = f"{DOMAIN}_data" -VERSION = "0.5.9" +VERSION = "0.5.15" ISSUE_URL = "http://github.com/moralmunky/Home-Assistant-Mail-And-Packages" PLATFORM = "sensor" PLATFORMS = ["binary_sensor", "camera", "sensor"] @@ -83,6 +83,7 @@ CONF_ALLOW_FORWARDED_EMAILS = "allow_forwarded_emails" CONF_FORWARDED_EMAILS = "forwarded_emails" CONF_FORWARDING_HEADER = "forwarding_header" CONF_CUSTOM_DAYS = "custom_days" +CONF_USPS_PLACEHOLDER = "usps_placeholder" # Defaults DEFAULT_CAMERA_NAME = "Mail USPS Camera" @@ -130,6 +131,7 @@ DEFAULT_STORAGE = "custom_components/mail_and_packages/images/" DEFAULT_ALLOW_FORWARDED_EMAILS = False DEFAULT_FORWARDED_EMAILS = "(none)" DEFAULT_FORWARDING_HEADER = "(none)" +DEFAULT_USPS_PLACEHOLDER = True # Amazon AMAZON_DOMAINS = [ @@ -173,6 +175,7 @@ AMAZON_SHIPMENT_SUBJECT = [ "Shipped:", "Enviado:", "Out for delivery:", + "Spedito:", ] AMAZON_ORDERED_SUBJECT = ["Ordered:", "Pedido efetuado:"] AMAZON_EMAIL = [ @@ -218,6 +221,7 @@ AMAZON_TIME_PATTERN = [ "Chega ", "Verwachte bezorgdatum:", "Votre date de livraison prévue est :", + "In arrivo", ] AMAZON_TIME_PATTERN_END = [ "Previously expected:", @@ -252,6 +256,10 @@ AMAZON_TIME_PATTERN_REGEX = [ "Wordt bezorgd op (\\w+ \\d+ \\w+)", "Wordt bezorgd op (\\w+ \\d+)", "Wordt (\\w+) bezorgd", + "In arrivo (\\w+ \\d+) - (\\w+ \\d+)", + "In arrivo (\\w+ \\d+)", + "In arrivo (\\w+ \\d*)", + "In arrivo (\\w+)", ] AMAZON_EXCEPTION_SUBJECT = "Delivery update:" AMAZON_EXCEPTION_BODY = "running late" @@ -435,6 +443,7 @@ SENSOR_DATA = { "donotreply_odd@dhl.com", "NoReply.ODD@dhl.com", "noreply@dhl.de", + "no-reply@dhl.de", "pl.no.reply@dhl.com", "support@dhl.com", "noreply@dhlecommerce.nl", @@ -448,6 +457,7 @@ SENSOR_DATA = { "liegt am gewünschten Ablageort", "Ihre Sendung liegt im Briefkasten", "Zustellung an Ablageort", + "Ablageort", "Sendung zugestellt", "Paket wurde zugestellt", "Ihre AliExpress Sendung liegt im Briefkasten", @@ -474,6 +484,7 @@ SENSOR_DATA = { "donotreply_odd@dhl.com", "NoReply.ODD@dhl.com", "noreply@dhl.de", + "no-reply@dhl.de", "pl.no.reply@dhl.com", "support@dhl.com", "noreply@dhlecommerce.nl", @@ -500,6 +511,7 @@ SENSOR_DATA = { "scheduled for delivery TODAY", "zostanie dziś do Państwa doręczona", "wird Ihnen heute", + "wird Ihnen voraussichtlich", "heute zwischen", " - Shipment is out with courier for delivery - ", "Shipment is scheduled for delivery", diff --git a/custom_components/mail_and_packages/coordinator.py b/custom_components/mail_and_packages/coordinator.py index 409f313..33dbb74 100644 --- a/custom_components/mail_and_packages/coordinator.py +++ b/custom_components/mail_and_packages/coordinator.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import ( ConfigEntryAuthFailed, DataUpdateCoordinator, @@ -249,21 +250,35 @@ class MailDataUpdateCoordinator(DataUpdateCoordinator): ) except InvalidAuth as err: _LOGGER.error("Authentication failed: %s", err) + # Create a repairs issue for authentication failure + ir.async_create_issue( + self.hass, + DOMAIN, + "auth_failed", + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="auth_failed", + data={"entry_id": self.config_entry.entry_id} + if self.config_entry + else None, + ) raise ConfigEntryAuthFailed from err except Exception as err: _LOGGER.error("Error logging into IMAP: %s", err) raise UpdateFailed(f"Login failed: {err}") from err + # Login succeeded, delete the issue if it exists + issue_registry = ir.async_get(self.hass) + if (DOMAIN, "auth_failed") in issue_registry.issues: + ir.async_delete_issue(self.hass, DOMAIN, "auth_failed") folders = config.get(CONF_FOLDER) - if not folders: - folders = ["INBOX"] - elif isinstance(folders, str): + if isinstance(folders, str): folders = [folders] elif isinstance(folders, (list, tuple, set)): folders = [f for f in folders if isinstance(f, str) and f] - if not folders: - folders = ["INBOX"] else: + folders = [] + if not folders: folders = ["INBOX"] account._folders = folders # noqa: SLF001 account._current_folder = None # noqa: SLF001 @@ -365,9 +380,34 @@ class MailDataUpdateCoordinator(DataUpdateCoordinator): ) in_transit = self._in_transit_tracking.get(prefix, {}) - if in_transit: + # A carrier that reported DELIVERING/EXCEPTION tracking details + # this scan must have its count overridden even when the + # in-transit map ends up EMPTY: when every tracked package has a + # delivered notification, the raw IMAP count (which cannot dedup + # prior-day deliveries) would otherwise leak through as the + # sensor value. Batch-level dedup already zeroes the count for + # shippers that emit tracking details, so this is defense in + # depth at the state-machine layer. Delivered-only details must + # NOT trigger the override: a carrier whose delivering emails + # yielded no extractable tracking numbers has a legitimate + # email-based count that tracking-level dedup cannot verify — + # and carriers with no tracking details at all keep their + # email-count value untouched. + has_details = any( + f"{prefix}_{suffix}" in tracking_details + for suffix in ("delivering", "exception") + ) + if in_transit or has_details: + if not in_transit and data.get(f"{prefix}_delivering"): + _LOGGER.debug( + "Prefix '%s': no tracked packages remain in transit — " + "overriding delivering count %s -> 0", + prefix, + data.get(f"{prefix}_delivering"), + ) data[f"{prefix}_tracking"] = list(in_transit.keys()) data[f"{prefix}_delivering"] = len(in_transit) + if in_transit: delivered_count = data.get(f"{prefix}_delivered", 0) data[f"{prefix}_packages"] = len(in_transit) + ( delivered_count if isinstance(delivered_count, int) else 0 @@ -523,7 +563,7 @@ class MailDataUpdateCoordinator(DataUpdateCoordinator): path = f"{image_path}{base_name}/" # Use absolute path for file existence check delivery_image_relative = f"{path}{image}" - delivery_image = f"{self.hass.config.path()}/{delivery_image_relative}" + delivery_image = self.hass.config.path(delivery_image_relative) _LOGGER.debug( "Full %s image path: %s", base_name.title(), diff --git a/custom_components/mail_and_packages/helpers.py b/custom_components/mail_and_packages/helpers.py index cef33cf..55317a3 100644 --- a/custom_components/mail_and_packages/helpers.py +++ b/custom_components/mail_and_packages/helpers.py @@ -114,7 +114,7 @@ def get_resources(hass: HomeAssistant | None = None) -> dict: def copy_images(hass: HomeAssistant, config: ConfigEntry) -> None: """Copy processed images to www directory.""" - image_path = Path(hass.config.path()) / default_image_path(hass, config) + image_path = Path(hass.config.path(default_image_path(hass, config))) www_path = Path(hass.config.path()) / "www" / "mail_and_packages" if not www_path.is_dir(): diff --git a/custom_components/mail_and_packages/manifest.json b/custom_components/mail_and_packages/manifest.json index 2935039..2fab275 100644 --- a/custom_components/mail_and_packages/manifest.json +++ b/custom_components/mail_and_packages/manifest.json @@ -18,5 +18,5 @@ "dateparser", "aioimaplib" ], - "version": "0.5.9" + "version": "0.5.15" } diff --git a/custom_components/mail_and_packages/repairs.py b/custom_components/mail_and_packages/repairs.py new file mode 100644 index 0000000..b224e53 --- /dev/null +++ b/custom_components/mail_and_packages/repairs.py @@ -0,0 +1,58 @@ +"""Repairs platform for Mail and Packages.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class AuthRepairFlow(RepairsFlow): + """Handler for repairs flow.""" + + def __init__(self, entry_id: str | None) -> None: + """Initialize.""" + self.entry_id = entry_id + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the first step of a repair flow.""" + return await self.async_step_confirm(user_input) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle confirm step.""" + if user_input is not None: + if self.entry_id: + entry = self.hass.config_entries.async_get_entry(self.entry_id) + else: + entries = self.hass.config_entries.async_entries(DOMAIN) + entry = entries[0] if entries else None + + if entry: + entry.async_start_reauth(self.hass) + return self.async_create_entry(title="", data={}) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, Any] | None, +) -> RepairsFlow: + """Create a flow to fix a specific issue.""" + if issue_id == "auth_failed": + entry_id = data.get("entry_id") if data else None + return AuthRepairFlow(entry_id) + raise ValueError(f"Unknown issue {issue_id}") diff --git a/custom_components/mail_and_packages/sensor.py b/custom_components/mail_and_packages/sensor.py index 3a3672e..e51b764 100644 --- a/custom_components/mail_and_packages/sensor.py +++ b/custom_components/mail_and_packages/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_RESOURCES from homeassistant.core import HomeAssistant +from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import MailAndPackagesConfigEntry @@ -118,6 +119,8 @@ class PackagesSensor(CoordinatorEntity, SensorEntity): @property def native_value(self) -> Any: """Return the state of the sensor.""" + if self.coordinator.data is None: + return None value = self.coordinator.data.get(self.type) if self.type == "mail_updated": @@ -146,6 +149,8 @@ class PackagesSensor(CoordinatorEntity, SensorEntity): """Return device specific state attributes.""" attr = {} data = self.coordinator.data + if data is None: + return attr if any( sensor in self.type @@ -154,10 +159,6 @@ class PackagesSensor(CoordinatorEntity, SensorEntity): if tracking := data.get(self._tracking_key): attr[ATTR_TRACKING_NUM] = tracking - # Catch no data entries - if self.data is None: - return attr - if "Amazon" in self._name: self._add_amazon_attributes(attr, data) elif self._name == "Mail USPS Mail": @@ -225,6 +226,9 @@ class ImagePathSensors(CoordinatorEntity, SensorEntity): @property def native_value(self) -> str | None: """Return the state of the sensor.""" + if self.coordinator.data is None: + return None + image = "" the_path = None @@ -239,10 +243,10 @@ class ImagePathSensors(CoordinatorEntity, SensorEntity): if self.type == "usps_mail_image_system_path" and image: _LOGGER.debug("Updating system image path to: %s", path) - the_path = f"{self.hass.config.path()}/{path}{image}" + the_path = self.hass.config.path(path, image) elif self.type == "usps_mail_grid_image_path" and grid_image: _LOGGER.debug("Updating grid image path to: %s", path) - the_path = f"{self.hass.config.path()}/{path}{grid_image}" + the_path = self.hass.config.path(path, grid_image) elif self.type == "usps_mail_image_url" and image: url = self._get_base_url() if url: @@ -250,33 +254,12 @@ class ImagePathSensors(CoordinatorEntity, SensorEntity): return the_path def _get_base_url(self) -> str | None: - """Return the best available base URL for building image links. - - Priority: explicit external URL → HA Cloud remote URL → internal URL. - """ - if self.hass.config.external_url: - return self.hass.config.external_url - - # Try Home Assistant Cloud (Nabu Casa) — its remote URL is not exposed via - # hass.config.external_url when "Use Home Assistant Cloud" is selected. + """Return the best available base URL for building image links.""" try: - from homeassistant.components.cloud import ( # noqa: PLC0415 - CloudNotAvailable, - async_remote_ui_url, - ) - except ImportError: - pass - else: - try: - return async_remote_ui_url(self.hass) - except (CloudNotAvailable, KeyError): - _LOGGER.debug("HA Cloud remote URL not available.") - - if self.hass.config.internal_url: - _LOGGER.debug("Falling back to internal URL for image link.") - return self.hass.config.internal_url - - return None + return get_url(self.hass, prefer_external=True) + except NoURLAvailableError: + _LOGGER.debug("No URL available for image link.") + return None @property def should_poll(self) -> bool: diff --git a/custom_components/mail_and_packages/shippers/__pycache__/__init__.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 3d34363..0000000 Binary files a/custom_components/mail_and_packages/shippers/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/shippers/__pycache__/amazon.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/amazon.cpython-314.pyc deleted file mode 100644 index 8284586..0000000 Binary files a/custom_components/mail_and_packages/shippers/__pycache__/amazon.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/shippers/__pycache__/base.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/base.cpython-314.pyc deleted file mode 100644 index 298dff1..0000000 Binary files a/custom_components/mail_and_packages/shippers/__pycache__/base.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/shippers/__pycache__/generic.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/generic.cpython-314.pyc deleted file mode 100644 index 4630250..0000000 Binary files a/custom_components/mail_and_packages/shippers/__pycache__/generic.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/shippers/__pycache__/post_de.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/post_de.cpython-314.pyc deleted file mode 100644 index 9cac748..0000000 Binary files a/custom_components/mail_and_packages/shippers/__pycache__/post_de.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/shippers/__pycache__/usps.cpython-314.pyc b/custom_components/mail_and_packages/shippers/__pycache__/usps.cpython-314.pyc deleted file mode 100644 index 53e3727..0000000 Binary files a/custom_components/mail_and_packages/shippers/__pycache__/usps.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/shippers/amazon.py b/custom_components/mail_and_packages/shippers/amazon.py index 1d8bc7d..28c954a 100644 --- a/custom_components/mail_and_packages/shippers/amazon.py +++ b/custom_components/mail_and_packages/shippers/amazon.py @@ -104,12 +104,23 @@ class AmazonShipper(Shipper): days = self.config.get(CONF_AMAZON_DAYS, DEFAULT_AMAZON_DAYS) domain = self.config.get(CONF_AMAZON_DOMAIN) - if sensor_type in [AMAZON_PACKAGES, AMAZON_ORDER]: - param = "count" if sensor_type == AMAZON_PACKAGES else "order" - result = await self._parse_amazon_emails( - account, param, fwds, days, domain, cache, forwarding_header + if sensor_type == AMAZON_PACKAGES: + count = await self._parse_amazon_emails( + account, "count", fwds, days, domain, cache, forwarding_header ) - return {sensor_type: result} + orders = await self._parse_amazon_emails( + account, "order", fwds, days, domain, cache, forwarding_header + ) + return { + AMAZON_PACKAGES: count, + AMAZON_ORDER: orders, + } + + if sensor_type == AMAZON_ORDER: + result = await self._parse_amazon_emails( + account, "order", fwds, days, domain, cache, forwarding_header + ) + return {AMAZON_ORDER: result} if sensor_type == AMAZON_HUB: return await self._amazon_hub(account, fwds, cache, forwarding_header) @@ -199,7 +210,17 @@ class AmazonShipper(Shipper): if param == "count": return final_count - return list(context["all_shipped_orders"]) + + return [ + order_id + for order_id in context["all_shipped_orders"] + if context["packages_arriving_today"].get(order_id, 0) + > context["delivered_packages"].get(order_id, 0) + or ( + context["packages_arriving_today"].get(order_id, 0) == 0 + and context["delivered_packages"].get(order_id, 0) == 0 + ) + ] async def _process_amazon_email( self, diff --git a/custom_components/mail_and_packages/shippers/generic.py b/custom_components/mail_and_packages/shippers/generic.py index c0f7c2e..05ebe03 100644 --- a/custom_components/mail_and_packages/shippers/generic.py +++ b/custom_components/mail_and_packages/shippers/generic.py @@ -297,7 +297,14 @@ class GenericShipper(Shipper): tracking = set(sensor_res.get(ATTR_TRACKING, [])) if sensor.endswith("_delivered"): - shippers[prefix]["delivered"].update(tracking) + # ATTR_TRACKING on _delivered sensors holds only TODAY's + # deliveries (so the sensor resets at midnight); dedup must + # use the extended-window list or packages delivered on a + # previous day are never subtracted from _delivering. + extended = sensor_res.get("pre_filtered_tracking") + shippers[prefix]["delivered"].update( + tracking if extended is None else set(extended) + ) elif sensor.endswith(("_delivering", "_exception")): shippers[prefix]["delivering"].update(tracking) shippers[prefix]["update_targets"].append((sensor, sensor_res)) @@ -662,7 +669,11 @@ class GenericShipper(Shipper): msg_parts = (await email_fetch(account, eid, "(RFC822)"))[1] for response_part in msg_parts: if isinstance(response_part, (bytes, bytearray)): - if generic_delivery_image_extraction( + # The extraction does blocking file I/O (the image + # write) and CPU-heavy email parsing — run the whole + # sync function off the event loop. + if await self.hass.async_add_executor_job( + generic_delivery_image_extraction, response_part, s_config["image_path"], s_config["image_name"], diff --git a/custom_components/mail_and_packages/shippers/usps.py b/custom_components/mail_and_packages/shippers/usps.py index b88c1d3..7373b5e 100644 --- a/custom_components/mail_and_packages/shippers/usps.py +++ b/custom_components/mail_and_packages/shippers/usps.py @@ -27,6 +27,7 @@ from custom_components.mail_and_packages.const import ( CONF_FORWARDING_HEADER, CONF_GENERATE_GRID, CONF_GENERATE_MP4, + CONF_USPS_PLACEHOLDER, DEFAULT_CUSTOM_IMG_FILE, SENSOR_DATA, ) @@ -103,32 +104,17 @@ class USPSShipper(Shipper): images = await self._process_usps_images(all_msg_content, images) image_count = len(images) - if image_count > 0: - await self._generate_mail_image( - images, - config["image_output_path"], - config["image_name"], - config["gif_duration"], - images_delete, - ) - elif image_count == 0: - await self._copy_nomail_image( - config["image_output_path"], - config["image_name"], - config["custom_img"], + # Generate filtered list for GIF/MP4/Grid + gif_images = images.copy() + if not config.get("usps_placeholder", True): + placeholder_str = str( + Path(__file__).parent.parent / "image-no-mailpieces700.jpg" ) + if placeholder_str in gif_images: + gif_images.remove(placeholder_str) - if config["gen_mp4"]: - await self._generate_mp4_video( - config["image_output_path"], - config["image_name"], - ) - if config["gen_grid"]: - await self._generate_grid_image( - config["image_output_path"], - config["image_name"], - image_count, - ) + # Generate camera media + await self._create_camera_media(gif_images, config, images_delete) return { ATTR_COUNT: image_count, @@ -159,6 +145,40 @@ class USPSShipper(Shipper): return res + async def _create_camera_media( + self, + gif_images: list, + config: dict, + images_delete: list, + ): + """Create camera media (GIF, MP4, and Grid).""" + if len(gif_images) > 0: + await self._generate_mail_image( + gif_images, + config["image_output_path"], + config["image_name"], + config["gif_duration"], + images_delete, + ) + else: + await self._copy_nomail_image( + config["image_output_path"], + config["image_name"], + config["custom_img"], + ) + + if config["gen_mp4"]: + await self._generate_mp4_video( + config["image_output_path"], + config["image_name"], + ) + if config["gen_grid"]: + await self._generate_grid_image( + config["image_output_path"], + config["image_name"], + len(gif_images), + ) + async def _generate_mp4_video(self, path: str, name: str): """Generate MP4 video from images.""" await self.hass.async_add_executor_job(_generate_mp4, path, name) @@ -260,6 +280,7 @@ class USPSShipper(Shipper): "custom_img": self.config.get(CONF_CUSTOM_IMG_FILE) or DEFAULT_CUSTOM_IMG_FILE, "gen_grid": self.config.get(CONF_GENERATE_GRID), + "usps_placeholder": self.config.get(CONF_USPS_PLACEHOLDER, True), } async def _search_informed_delivery(self, account: IMAP4_SSL) -> tuple: diff --git a/custom_components/mail_and_packages/strings.json b/custom_components/mail_and_packages/strings.json index 9928637..9565ca9 100644 --- a/custom_components/mail_and_packages/strings.json +++ b/custom_components/mail_and_packages/strings.json @@ -48,6 +48,7 @@ "generate_grid": "Create image grid for LLM vision models", "generate_mp4": "Create mp4 from images", "allow_external": "Create image for notification apps", + "usps_placeholder": "Include USPS no-image placeholder in GIF?", "custom_img": "Use custom USPS 'no mail' image?", "amazon_custom_img": "Use custom 'no Amazon delivery' image?", "ups_custom_img": "Use custom 'no UPS delivery' image?", @@ -121,6 +122,7 @@ "resources": "Sensors List", "imap_timeout": "Mailbox scan time limit (seconds, minimum 10)", "allow_external": "Create image for notification apps", + "usps_placeholder": "Include USPS no-image placeholder in GIF?", "custom_img": "Use custom USPS 'no mail' image?", "amazon_custom_img": "Use custom 'no Amazon delivery' image?", "ups_custom_img": "Use custom 'no UPS delivery' image?", @@ -195,5 +197,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Mail and Packages authentication failed", + "fix_flow": { + "step": { + "confirm": { + "title": "Re-authenticate Mail Server", + "description": "Authentication to your IMAP mail server has failed. Click submit to start the re-authentication flow." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/ca.json b/custom_components/mail_and_packages/translations/ca.json index a22a5d8..a065fd0 100644 --- a/custom_components/mail_and_packages/translations/ca.json +++ b/custom_components/mail_and_packages/translations/ca.json @@ -36,7 +36,8 @@ "generic_custom_img": "Utilitza la imatge personalitzada genèrica 'no hi ha lliurament'?", "generate_grid": "Crea una quadrícula d'imatges per a models de visió LLM", "allow_forwarded_emails": "Permet els correus electrònics reenviats a més del valor predeterminat d'un servei (p. ex., no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Incloure la imatge de substitució de l'USPS sense imatge al GIF?" }, "description": "Finalitzeu la configuració personalitzant la següent en funció de la vostra instal·lació de correu electrònic i la instal·lació d'assistència a casa. \n\n Per obtenir més informació sobre les opcions [Integració de paquets i correus] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), reviseu les opcions [configuració, plantilles , secció i automatitzacions] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) a GitHub.", "title": "Correu i paquets (pas 2 de 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Utilitza la imatge personalitzada genèrica 'no hi ha lliurament'?", "generate_grid": "Crea una quadrícula d'imatges per a models de visió LLM", "allow_forwarded_emails": "Permet els correus electrònics reenviats a més del valor predeterminat d'un servei (p. ex., no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Incloure la imatge de substitució de l'USPS sense imatge al GIF?" }, "description": "Acabeu la configuració personalitzant el següent en funció de l'estructura del vostre correu electrònic i de la instal·lació de Home Assistant.\n\nPer obtenir més detalls sobre les opcions d'[integració de correu i paquets](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), consulteu la [secció de configuració, plantilles i automatitzacions](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) a GitHub.\n\nSi utilitzeu correus electrònics reenviats d'Amazon, separeu cada adreça amb una coma o introduïu (none) per esborrar aquesta configuració.", "title": "Correu i paquets (pas 2 de 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "L'autenticació de Mail and Packages ha fallat", + "fix_flow": { + "step": { + "confirm": { + "title": "Tornar a autenticar el servidor de correu", + "description": "L'autenticació amb el vostre servidor de correu IMAP ha fallat. Feu clic a envia per iniciar el flux de reautenticació." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/cs.json b/custom_components/mail_and_packages/translations/cs.json index 383d4cd..9719571 100644 --- a/custom_components/mail_and_packages/translations/cs.json +++ b/custom_components/mail_and_packages/translations/cs.json @@ -40,7 +40,8 @@ "generic_custom_img": "Použít vlastní obrázek obecný 'bez dodání'?", "allow_forwarded_emails": "Povolit přeposílané e-maily kromě výchozí hodnoty služby (např. no-reply@usps.com)", "generate_grid": "Create image grid for LLM vision models", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Zahrnout zástupný symbol USPS bez obrázku do GIF?" }, "description": "Dokončete konfiguraci přizpůsobením následujících položek na základě struktury e-mailu a instalace Home Assistant.\n\nPodrobnosti o [Mail and Packages integration](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) můžete zkontrolovat na [configurace, styly a automatizace](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHubu.", "title": "Mail and Packages (Krok 2 ze 3)" @@ -152,7 +153,8 @@ "walmart_custom_img": "Use custom 'no Walmart delivery' image?", "fedex_custom_img": "Use custom 'no FedEx delivery' image?", "allow_forwarded_emails": "Allow forwarded emails in addition to a service's default (e.g. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Zahrnout zástupný symbol USPS bez obrázku do GIF?" }, "description": "Finish the configuration by customizing the following based on your email structure and Home Assistant installation.\n\nIf using Amazon forwarded emails please separate each address with a comma or enter (none) to clear this setting.", "title": "Mail and Packages (Step 2 of 2)" @@ -265,5 +267,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Ověření Mail and Packages selhalo", + "fix_flow": { + "step": { + "confirm": { + "title": "Znovu ověřit poštovní server", + "description": "Ověření k vašemu poštovnímu serveru IMAP selhalo. Kliknutím na odeslat spustíte proces opětovného ověření." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/de.json b/custom_components/mail_and_packages/translations/de.json index 697a1e9..fc35c6c 100644 --- a/custom_components/mail_and_packages/translations/de.json +++ b/custom_components/mail_and_packages/translations/de.json @@ -37,7 +37,8 @@ "post_de_custom_img": "Benutzerdefiniertes Post DE 'keine Post' Bild verwenden?", "generate_grid": "Erstellen Sie ein Bildraster für LLM-Vision-Modelle", "allow_forwarded_emails": "Weitergeleitete E-Mails zusätzlich zu den Standardwerten eines Dienstes zulassen (z. B. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "USPS-Platzhalter ohne Bild im GIF einschließen?" }, "description": "Beenden Sie die Konfiguration, indem Sie Folgendes basierend auf Ihrer E-Mail-Struktur und der Installation von Home Assistant anpassen. \n\n Weitere Informationen zu den Optionen [Mail- und Paketintegration] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) finden Sie in den [Konfiguration, Vorlagen und Abschnitt Automatisierungen] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) auf GitHub.", "title": "Briefe und Pakete (Schritt 2 von 2)" @@ -83,7 +84,8 @@ "post_de_custom_img": "Benutzerdefiniertes Post DE 'keine Post' Bild verwenden?", "generate_grid": "Erstellen Sie ein Bildraster für LLM-Vision-Modelle", "allow_forwarded_emails": "Weitergeleitete E-Mails zusätzlich zu den Standardwerten eines Dienstes zulassen (z. B. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "USPS-Platzhalter ohne Bild im GIF einschließen?" }, "description": "Schließen Sie die Konfiguration ab, indem Sie das Folgende an Ihre E-Mail-Struktur und Home Assistant-Installation anpassen.\n\nWeitere Informationen zu den [Mail und Packages Integration](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) Optionen finden Sie im [Konfigurations-, Vorlagen- und Automatisierungsabschnitt](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) auf GitHub.\n\nWenn Sie weitergeleitete E-Mails von Amazon verwenden, trennen Sie bitte jede Adresse durch ein Komma oder geben Sie (keine) ein, um diese Einstellung zu löschen.", "title": "Briefe und Pakete (Schritt 2 von 2)" @@ -203,5 +205,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Mail and Packages Authentifizierung fehlgeschlagen", + "fix_flow": { + "step": { + "confirm": { + "title": "Mailserver neu authentifizieren", + "description": "Die Authentifizierung bei Ihrem IMAP-Mailserver ist fehlgeschlagen. Klicken Sie auf Senden, um den Reauthentifizierungs-Flow zu starten." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/en.json b/custom_components/mail_and_packages/translations/en.json index 41ea4a0..e4d9056 100644 --- a/custom_components/mail_and_packages/translations/en.json +++ b/custom_components/mail_and_packages/translations/en.json @@ -48,6 +48,7 @@ "imap_timeout": "Mailbox scan time limit (seconds, minimum 10)", "generate_mp4": "Create mp4 from images", "allow_external": "Create image for notification apps", + "usps_placeholder": "Include USPS no-image placeholder in GIF?", "custom_img": "Use custom USPS 'no mail' image?", "amazon_custom_img": "Use custom 'no Amazon delivery' image?", "ups_custom_img": "Use custom 'no UPS delivery' image?", @@ -116,6 +117,7 @@ "resources": "Sensors List", "imap_timeout": "Mailbox scan time limit (seconds, minimum 10)", "allow_external": "Create image for notification apps", + "usps_placeholder": "Include USPS no-image placeholder in GIF?", "custom_img": "Use custom USPS 'no mail' image?", "amazon_custom_img": "Use custom 'no Amazon delivery' image?", "ups_custom_img": "Use custom 'no UPS delivery' image?", @@ -209,5 +211,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Mail and Packages authentication failed", + "fix_flow": { + "step": { + "confirm": { + "title": "Re-authenticate Mail Server", + "description": "Authentication to your IMAP mail server has failed. Click submit to start the re-authentication flow." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/es.json b/custom_components/mail_and_packages/translations/es.json index f0fae70..466f16a 100644 --- a/custom_components/mail_and_packages/translations/es.json +++ b/custom_components/mail_and_packages/translations/es.json @@ -36,7 +36,8 @@ "generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?", "generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM", "allow_forwarded_emails": "Permitir correos electrónicos reenviados además del valor predeterminado de un servicio (p. ej., no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "¿Incluir marcador de posición sin imagen de USPS en el GIF?" }, "description": "Termine la configuración personalizando lo siguiente según su estructura de correo electrónico y la instalación de Home Assistant. \n\n Para obtener detalles sobre las opciones [Integración de correo y paquetes] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) revise las [configuración, plantillas , y la sección de automatizaciones] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) en GitHub.", "title": "Correo y paquetes (Paso 2 de 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?", "generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM", "allow_forwarded_emails": "Permitir correos electrónicos reenviados además del valor predeterminado de un servicio (p. ej., no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "¿Incluir marcador de posición sin imagen de USPS en el GIF?" }, "description": "Finalice la configuración personalizando lo siguiente en función de la estructura de su correo electrónico y la instalación de Home Assistant.\n\nPara obtener detalles sobre las opciones de [integración de correo y paquetes](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), revise la [sección de configuración, plantillas y automatizaciones](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) en GitHub.\n\nSi utiliza correos electrónicos reenviados de Amazon, separe cada dirección con una coma o ingrese (ninguno) para borrar esta configuración.", "title": "Correo y paquetes (Paso 2 de 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Error de autenticación de Mail and Packages", + "fix_flow": { + "step": { + "confirm": { + "title": "Volver a autenticar el servidor de correo", + "description": "Error al autenticar en su servidor de correo IMAP. Haga clic en enviar para iniciar el flujo de reautenticación." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/es_419.json b/custom_components/mail_and_packages/translations/es_419.json index b0e7251..58590bf 100644 --- a/custom_components/mail_and_packages/translations/es_419.json +++ b/custom_components/mail_and_packages/translations/es_419.json @@ -36,7 +36,8 @@ "generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?", "generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM", "allow_forwarded_emails": "Permitir correos electrónicos reenviados además del valor predeterminado de un servicio (p. ej., no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "¿Incluir marcador de posición sin imagen de USPS en el GIF?" }, "description": "Termine la configuración personalizando lo siguiente según su estructura de correo electrónico y la instalación de Home Assistant. \n\n Para obtener detalles sobre las opciones [Integración de correo y paquetes] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) revise las [configuración, plantillas , y la sección de automatizaciones] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) en GitHub.", "title": "Correo y paquetes (Paso 2 de 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?", "generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM", "allow_forwarded_emails": "Permitir correos electrónicos reenviados además del valor predeterminado de un servicio (p. ej., no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "¿Incluir marcador de posición sin imagen de USPS en el GIF?" }, "description": "Finalice la configuración personalizando lo siguiente en función de la estructura de su correo electrónico y la instalación de Home Assistant.\n\nPara obtener detalles sobre las opciones de [integración de correo y paquetes](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), revise la [sección de configuración, plantillas y automatizaciones](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) en GitHub.\n\nSi utiliza correos electrónicos reenviados de Amazon, separe cada dirección con una coma o ingrese (ninguno) para borrar esta configuración.", "title": "Correo y paquetes (Paso 2 de 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Error de autenticación de Mail and Packages", + "fix_flow": { + "step": { + "confirm": { + "title": "Volver a autenticar el servidor de correo", + "description": "Error al autenticar en su servidor de correo IMAP. Haga clic en enviar para iniciar el flujo de reautenticación." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/fi.json b/custom_components/mail_and_packages/translations/fi.json index 0205650..2aa8baa 100644 --- a/custom_components/mail_and_packages/translations/fi.json +++ b/custom_components/mail_and_packages/translations/fi.json @@ -36,7 +36,8 @@ "generic_custom_img": "Käytä mukautettua yleistä 'ei toimituksia' kuvaa?", "generate_grid": "Luo kuvaverkko LLM-näkömalleille", "allow_forwarded_emails": "Salli edelleenlähetetyt sähköpostit palvelun oletusarvon lisäksi (esim. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Sisällytä USPS:n ei-kuvaa -paikkamerkki GIF-tiedostoon?" }, "description": "Viimeistele kokoonpano mukauttamalla seuraava sähköpostirakenteen ja Home Assistant -asennuksen perusteella. \n\n Lisätietoja [Posti ja paketit-integroinnista] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) -asetuksista on [kokoonpano, mallit , ja automaatiot-osio] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) GitHubissa.", "title": "Posti ja paketit (vaihe 2/2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Käytä mukautettua yleistä 'ei toimituksia' kuvaa?", "generate_grid": "Luo kuvaverkko LLM-näkömalleille", "allow_forwarded_emails": "Salli edelleenlähetetyt sähköpostit palvelun oletusarvon lisäksi (esim. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Sisällytä USPS:n ei-kuvaa -paikkamerkki GIF-tiedostoon?" }, "description": "Suorita määritys loppuun mukauttamalla seuraavat sähköpostirakenteeseesi ja Home Assistant -asennukseesi perustuen.\n\nLisätietoja [Mail and Packages -integraation](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) vaihtoehdoista löydät GitHubista [määritys-, mallit- ja automaatiot-osiossa](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration).\n\nJos käytät Amazonin välittämiä sähköposteja, erottele jokainen osoite pilkulla tai syötä (none) tyhjentääksesi tämän asetuksen.", "title": "Posti ja paketit (vaihe 2/2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Mail and Packages -autentikointi epäonnistui", + "fix_flow": { + "step": { + "confirm": { + "title": "Tunnistaudu uudelleen sähköpostipalvelimeen", + "description": "Autentikointi IMAP-sähköpostipalvelimeesi epäonnistui. Napsauta Lähetä aloittaaksesi uudelleentunnistautumisen." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/fr.json b/custom_components/mail_and_packages/translations/fr.json index c18b76d..fe6ab1e 100644 --- a/custom_components/mail_and_packages/translations/fr.json +++ b/custom_components/mail_and_packages/translations/fr.json @@ -36,7 +36,8 @@ "generic_custom_img": "Utiliser une image personnalisée générique 'pas de livraison' ?", "generate_grid": "Créer une grille d'images pour les modèles de vision LLM", "allow_forwarded_emails": "Autoriser les e-mails transférés en plus de la valeur par défaut d'un service (par ex. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Inclure l'image d'emplacement sans image USPS dans le GIF ?" }, "description": "Terminez la configuration en personnalisant les éléments suivants en fonction de votre structure de messagerie et de l'installation de Home Assistant. \n\n Pour plus de détails sur les [Intégration de messagerie et de packages] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), passez en revue les [configuration, modèles et section automatisations] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) sur GitHub.", "title": "Courrier et colis (étape 2 sur 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Utiliser une image personnalisée générique 'pas de livraison' ?", "generate_grid": "Créer une grille d'images pour les modèles de vision LLM", "allow_forwarded_emails": "Autoriser les e-mails transférés en plus de la valeur par défaut d'un service (par ex. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Inclure l'image d'emplacement sans image USPS dans le GIF ?" }, "description": "Terminez la configuration en personnalisant ce qui suit en fonction de la structure de votre courrier électronique et de l'installation de Home Assistant.\n\nPour plus de détails sur les options d'[intégration de courrier et de colis](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), consultez la [section configuration, modèles et automatisations](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) sur GitHub.\n\nSi vous utilisez des courriels transférés par Amazon, veuillez séparer chaque adresse par une virgule ou entrer (aucun) pour effacer ce paramètre.", "title": "Courrier et colis (étape 2 sur 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Échec de l'authentification de Mail and Packages", + "fix_flow": { + "step": { + "confirm": { + "title": "Ré-authentifier le serveur de messagerie", + "description": "L'authentification sur votre serveur de messagerie IMAP a échoué. Cliquez sur soumettre pour démarrer le flux de ré-authentification." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/hu.json b/custom_components/mail_and_packages/translations/hu.json index 94ed0dc..0f2afea 100644 --- a/custom_components/mail_and_packages/translations/hu.json +++ b/custom_components/mail_and_packages/translations/hu.json @@ -36,7 +36,8 @@ "generic_custom_img": "Használjon egyéni általános 'nincs szállítás' képet?", "generate_grid": "Kép rács létrehozása LLM látásmodellekhez", "allow_forwarded_emails": "Engedélyezze a továbbított e-maileket a szolgáltatás alapértelmezett értékén kívül (pl. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Tartalmazza az USPS kép nélküli helyőrzőjét a GIF-ben?" }, "description": "Végezze el a konfigurációt az alábbiak testreszabásával az e-mail struktúrája és a Home Assistant telepítése alapján. \n\n A [Levelek és csomagok integrációja] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) opciókkal kapcsolatban tekintse meg a [konfiguráció, sablonok , és az automatizálás szakasz] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) a GitHubon.", "title": "Levél és csomagok (2. lépés a 2-ből)" @@ -80,7 +81,8 @@ "generic_custom_img": "Használjon egyéni általános 'nincs szállítás' képet?", "generate_grid": "Kép rács létrehozása LLM látásmodellekhez", "allow_forwarded_emails": "Engedélyezze a továbbított e-maileket a szolgáltatás alapértelmezett értékén kívül (pl. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Tartalmazza az USPS kép nélküli helyőrzőjét a GIF-ben?" }, "description": "Fejezze be a konfigurációt a következők testreszabásával az e-mail struktúrája és a Home Assistant telepítése alapján.\n\nA [Mail and Packages integráció](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) lehetőségeinek részleteiről a GitHub-on található [konfiguráció, sablonok és automatizálások szekcióban](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) található információkat.\n\nHa Amazon továbbított e-maileket használ, kérjük, válassza el minden címet vesszővel, vagy írja be a (none) opciót, hogy törölje ezt a beállítást.", "title": "Levél és csomagok (2. lépés a 2-ből)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Mail and Packages hitelesítés sikertelen", + "fix_flow": { + "step": { + "confirm": { + "title": "Levelezőszerver újrahitelesítése", + "description": "Nem sikerült a hitelesítés az IMAP levelezőszerveren. Kattintson a küldésre az újrahitelesítési folyamat elindításához." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/it.json b/custom_components/mail_and_packages/translations/it.json index 6651e26..ebf4a7d 100644 --- a/custom_components/mail_and_packages/translations/it.json +++ b/custom_components/mail_and_packages/translations/it.json @@ -36,7 +36,8 @@ "generic_custom_img": "Usare l'immagine personalizzata generica 'nessuna consegna'?", "generate_grid": "Crea una griglia di immagini per i modelli di visione LLM", "allow_forwarded_emails": "Consenti email inoltrate oltre al valore predefinito di un servizio (ad es. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Includere il segnaposto USPS senza immagine nella GIF?" }, "description": "Termina la configurazione personalizzando quanto segue in base alla struttura della tua e-mail e all'installazione di Home Assistant. \n\n Per i dettagli sulle opzioni [Integrazione posta e pacchetti] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) rivedere le [configurazione, modelli e sezione automazioni] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) su GitHub.", "title": "Posta e pacchi (passaggio 2 di 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Usare l'immagine personalizzata generica 'nessuna consegna'?", "generate_grid": "Crea una griglia di immagini per i modelli di visione LLM", "allow_forwarded_emails": "Consenti email inoltrate oltre al valore predefinito di un servizio (ad es. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Includere il segnaposto USPS senza immagine nella GIF?" }, "description": "Termina la configurazione personalizzando quanto segue in base alla struttura della tua email e all'installazione di Home Assistant.\n\nPer i dettagli sulle opzioni di [integrazione Mail e Pacchetti](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) consulta la [sezione configurazione, modelli e automazioni](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) su GitHub.\n\nSe utilizzi email inoltrate da Amazon, separa ogni indirizzo con una virgola o inserisci (nessuno) per cancellare questa impostazione.", "title": "Posta e pacchi (passaggio 2 di 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Autenticazione Mail and Packages fallita", + "fix_flow": { + "step": { + "confirm": { + "title": "Riautentica il server di posta", + "description": "L'autenticazione al server di posta IMAP è fallita. Clicca su invia per avviare la procedura di riautenticazione." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/ko.json b/custom_components/mail_and_packages/translations/ko.json index 59f2e93..b54f8d2 100644 --- a/custom_components/mail_and_packages/translations/ko.json +++ b/custom_components/mail_and_packages/translations/ko.json @@ -36,7 +36,8 @@ "generic_custom_img": "사용자 정의 일반 '배송 없음' 이미지를 사용하시겠습니까?", "generate_grid": "LLM 비전 모델을 위한 이미지 그리드 생성", "allow_forwarded_emails": "서비스 기본값 외에 전달된 이메일 허용 (예: no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "GIF에 USPS 이미지 없음 자리 표시자를 포함하시겠습니까?" }, "description": "이메일 구조 및 Home Assistant 설치에 따라 다음을 사용자 정의하여 구성을 완료하십시오. \n\n [메일 및 패키지 통합] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) 옵션에 대한 자세한 내용은 [구성, 템플릿 및 자동화 섹션] (https://github.com/moralmunky/Home-Assistant-Mail-and-Packages/wiki/Configuration-and-Email-Settings#configuration)", "title": "메일 및 패키지 (2/2 단계)" @@ -80,7 +81,8 @@ "generic_custom_img": "사용자 정의 일반 '배송 없음' 이미지를 사용하시겠습니까?", "generate_grid": "LLM 비전 모델을 위한 이미지 그리드 생성", "allow_forwarded_emails": "서비스 기본값 외에 전달된 이메일 허용 (예: no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "GIF에 USPS 이미지 없음 자리 표시자를 포함하시겠습니까?" }, "description": "이메일 구조와 Home Assistant 설치에 따라 다음을 사용자 정의하여 구성을 완료하십시오.\n\n[Mail and Packages 통합](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) 옵션에 대한 자세한 내용은 GitHub의 [구성, 템플릿, 자동화 섹션](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration)을 참조하십시오.\n\nAmazon에서 전달된 이메일을 사용하는 경우 각 주소를 쉼표로 구분하거나 이 설정을 지우려면 (none)을 입력하십시오.", "title": "메일 및 패키지 (2/2 단계)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Mail and Packages 인증 실패", + "fix_flow": { + "step": { + "confirm": { + "title": "메일 서버 재인증", + "description": "IMAP 메일 서버 인증에 실패했습니다. 제출을 클릭하여 재인증 흐름을 시작하십시오." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/nl.json b/custom_components/mail_and_packages/translations/nl.json index 768ab51..c7128df 100644 --- a/custom_components/mail_and_packages/translations/nl.json +++ b/custom_components/mail_and_packages/translations/nl.json @@ -36,7 +36,8 @@ "generic_custom_img": "Gebruik aangepaste generieke 'geen levering' afbeelding?", "generate_grid": "Maak een afbeeldingsraster voor LLM-visiemodellen", "allow_forwarded_emails": "Doorgestuurde e-mails toestaan naast de standaardwaarde van een service (bijv. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "USPS tijdelijke aanduiding zonder afbeelding opnemen in GIF?" }, "description": "Voltooi de configuratie door het volgende aan te passen op basis van uw e-mailstructuur en Home Assistant-installatie. \n\n Voor meer informatie over de [E-mail en pakketten integratie] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) opties bekijk de [configuratie, sjablonen , en automatisering sectie] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) op GitHub.", "title": "Mail en pakketten (stap 2 van 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Gebruik aangepaste generieke 'geen levering' afbeelding?", "generate_grid": "Maak een afbeeldingsraster voor LLM-visiemodellen", "allow_forwarded_emails": "Doorgestuurde e-mails toestaan naast de standaardwaarde van een service (bijv. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "USPS tijdelijke aanduiding zonder afbeelding opnemen in GIF?" }, "description": "Rond de configuratie af door het volgende aan te passen op basis van uw e-mailstructuur en Home Assistant-installatie.\n\nVoor details over de [Mail en Packages integratie](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) opties, bekijk de [configuratie, sjablonen en automatiseringen sectie](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) op GitHub.\n\nAls u doorgestuurde e-mails van Amazon gebruikt, scheid dan elk adres met een komma of voer (geen) in om deze instelling te wissen.", "title": "Mail en pakketten (stap 2 van 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Mail and Packages authenticatie mislukt", + "fix_flow": { + "step": { + "confirm": { + "title": "E-mailserver opnieuw verifiëren", + "description": "Authenticatie bij uw IMAP-mailserver is mislukt. Klik op verzenden om de herauthenticatiestroom te starten." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/no.json b/custom_components/mail_and_packages/translations/no.json index bc704cc..3f67033 100644 --- a/custom_components/mail_and_packages/translations/no.json +++ b/custom_components/mail_and_packages/translations/no.json @@ -36,7 +36,8 @@ "generic_custom_img": "Bruk tilpasset generisk 'ingen levering' bilde?", "generate_grid": "Opprett bildegitter for LLM-visjonsmodeller", "allow_forwarded_emails": "Tillat videresendte e-poster i tillegg til en tjenestes standardverdi (f.eks. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Inkluder USPS plassholder uten bilde i GIF?" }, "description": "Fullfør konfigurasjonen ved å tilpasse følgende basert på e-poststrukturen og Home Assistant-installasjonen. \n\n For detaljer om alternativene [Mail and Packages] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), les gjennom [konfigurasjon, maler , og automatiseringsdel] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) på GitHub.", "title": "E-post og pakker (trinn 2 av 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Bruk tilpasset generisk 'ingen levering' bilde?", "generate_grid": "Opprett bildegitter for LLM-visjonsmodeller", "allow_forwarded_emails": "Tillat videresendte e-poster i tillegg til en tjenestes standardverdi (f.eks. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Inkluder USPS plassholder uten bilde i GIF?" }, "description": "Fullfør konfigurasjonen ved å tilpasse følgende basert på din e-poststruktur og Home Assistant-installasjon.\n\nFor detaljer om [Mail and Packages-integrasjonen](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) alternativene, se gjennom [konfigurasjon, maler og automatiseringsseksjonen](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) på GitHub.\n\nHvis du bruker Amazon videresendte e-poster, vennligst skill hver adresse med et komma eller skriv inn (ingen) for å tømme denne innstillingen.", "title": "E-post og pakker (trinn 2 av 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Autentisering for Mail and Packages mislyktes", + "fix_flow": { + "step": { + "confirm": { + "title": "Autentiser e-postserver på nytt", + "description": "Autentisering mot din IMAP-e-postserver mislyktes. Klikk på send for å starte reautentiseringsflyten." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/pl.json b/custom_components/mail_and_packages/translations/pl.json index efcfe9d..6d4d319 100644 --- a/custom_components/mail_and_packages/translations/pl.json +++ b/custom_components/mail_and_packages/translations/pl.json @@ -36,7 +36,8 @@ "generic_custom_img": "Użyć niestandardowego obrazu ogólnego 'brak dostawy'?", "generate_grid": "Utwórz siatkę obrazów dla modeli wizji LLM", "allow_forwarded_emails": "Zezwalaj na przekierowane e-maile oprócz wartości domyślnej usługi (np. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Dołączyć zarezerwowane miejsce dla USPS bez obrazu do pliku GIF?" }, "description": "Zakończ konfigurację, dostosowując następujące elementy w oparciu o strukturę poczty e-mail i instalację Home Assistant. \n\n Aby uzyskać szczegółowe informacje na temat opcji [Integracja poczty i pakietów] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) sprawdź opcje [konfiguracja, szablony i sekcja automatyzacji] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHub.", "title": "Poczta i paczki (krok 2 z 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Użyć niestandardowego obrazu ogólnego 'brak dostawy'?", "generate_grid": "Utwórz siatkę obrazów dla modeli wizji LLM", "allow_forwarded_emails": "Zezwalaj na przekierowane e-maile oprócz wartości domyślnej usługi (np. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Dołączyć zarezerwowane miejsce dla USPS bez obrazu do pliku GIF?" }, "description": "Zakończ konfigurację, dostosowując następujące elementy do struktury swojego e-maila i instalacji Home Assistant.\n\nAby uzyskać szczegółowe informacje na temat opcji [integracji Mail and Packages](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), zapoznaj się z [sekcją konfiguracji, szablonów i automatyzacji](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHubie.\n\nJeśli korzystasz z przekierowanych e-maili Amazon, oddziel każdy adres przecinkiem lub wprowadź (brak), aby wyczyścić to ustawienie.", "title": "Poczta i paczki (krok 2 z 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Błąd uwierzytelniania Mail and Packages", + "fix_flow": { + "step": { + "confirm": { + "title": "Ponownie uwierzytelnij serwer pocztowy", + "description": "Uwierzytelnienie na serwerze pocztowym IMAP nie powiodło się. Kliknij prześlij, aby rozpocząć proces ponownego uwierzytelniania." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/pt.json b/custom_components/mail_and_packages/translations/pt.json index d21820b..32b0af0 100644 --- a/custom_components/mail_and_packages/translations/pt.json +++ b/custom_components/mail_and_packages/translations/pt.json @@ -36,7 +36,8 @@ "generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?", "generate_grid": "Criar grade de imagens para modelos de visão LLM", "allow_forwarded_emails": "Permitir emails encaminhados além do padrão de um serviço (ex: no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Incluir marcador de posição sem imagem USPS no GIF?" }, "description": "Conclua a configuração, personalizando o seguinte com base na sua estrutura de email e instalação do Home Assistant. \n\n Para obter detalhes sobre as opções [integração de Mail e Pacotes] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), revise as opções de [configuração, modelos e seção de automações] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) no GitHub.", "title": "Correio e pacotes (Etapa 2 de 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?", "generate_grid": "Criar grade de imagens para modelos de visão LLM", "allow_forwarded_emails": "Permitir emails encaminhados além do padrão de um serviço (ex: no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Incluir marcador de posição sem imagem USPS no GIF?" }, "description": "Conclua a configuração personalizando o seguinte com base na estrutura do seu email e na instalação do Home Assistant.\n\nPara detalhes sobre as opções de [integração de Correio e Pacotes](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), consulte a [seção de configuração, modelos e automações](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) no GitHub.\n\nSe estiver usando emails encaminhados da Amazon, separe cada endereço com uma vírgula ou insira (nenhum) para limpar essa configuração.", "title": "Correio e pacotes (Etapa 2 de 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Falha na autenticação de Mail and Packages", + "fix_flow": { + "step": { + "confirm": { + "title": "Reautenticar o Servidor de E-mail", + "description": "A autenticação no seu servidor de e-mail IMAP falhou. Clique em enviar para iniciar o fluxo de reautenticação." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/pt_BR.json b/custom_components/mail_and_packages/translations/pt_BR.json index ad19de1..687efb1 100644 --- a/custom_components/mail_and_packages/translations/pt_BR.json +++ b/custom_components/mail_and_packages/translations/pt_BR.json @@ -36,7 +36,8 @@ "generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?", "generate_grid": "Criar grade de imagens para modelos de visão LLM", "allow_forwarded_emails": "Permitir e-mails encaminhados além do padrão de um serviço (ex: no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Incluir marcador de posição sem imagem USPS no GIF?" }, "description": "Conclua a configuração, personalizando o seguinte com base na sua estrutura de email e instalação do Home Assistant. \n\n Para obter detalhes sobre as opções [integração de Mail e Pacotes] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), revise as opções de [configuração, modelos e seção de automações] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) no GitHub.", "title": "Correio e pacotes (Etapa 2 de 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?", "generate_grid": "Criar grade de imagens para modelos de visão LLM", "allow_forwarded_emails": "Permitir e-mails encaminhados além do padrão de um serviço (ex: no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Incluir marcador de posição sem imagem USPS no GIF?" }, "description": "Conclua a configuração personalizando o seguinte com base na estrutura do seu email e na instalação do Home Assistant.\n\nPara detalhes sobre as opções de [integração de Mail e Pacotes](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration), consulte a [seção de configuração, modelos e automações](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) no GitHub.\n\nSe estiver usando emails encaminhados da Amazon, separe cada endereço com uma vírgula ou insira (nenhum) para limpar essa configuração.", "title": "Correio e pacotes (Etapa 2 de 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Falha na autenticação de Mail and Packages", + "fix_flow": { + "step": { + "confirm": { + "title": "Reautenticar o Servidor de E-mail", + "description": "A autenticação no seu servidor de e-mail IMAP falhou. Clique em enviar para iniciar o fluxo de reautenticação." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/ru.json b/custom_components/mail_and_packages/translations/ru.json index 32f9d3c..1436a33 100644 --- a/custom_components/mail_and_packages/translations/ru.json +++ b/custom_components/mail_and_packages/translations/ru.json @@ -36,7 +36,8 @@ "generic_custom_img": "Использовать пользовательское изображение общее 'нет доставки'?", "generate_grid": "Создать сетку изображений для моделей зрения LLM", "allow_forwarded_emails": "Разрешить переадресованные электронные письма в дополнение к значениям по умолчанию службы (например, no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Включить заполнитель без изображения USPS в GIF?" }, "description": "Завершите настройку, настроив следующие параметры в зависимости от структуры электронной почты и установки Home Assistant. \n\n Подробнее о параметрах [Интеграция с почтой и пакетами] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) см. В разделе [конфигурация, шаблоны и раздел автоматизации] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) на GitHub.", "title": "Почта и пакеты (шаг 2 из 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Использовать пользовательское изображение общее 'нет доставки'?", "generate_grid": "Создать сетку изображений для моделей зрения LLM", "allow_forwarded_emails": "Разрешить переадресованные электронные письма в дополнение к значениям по умолчанию службы (например, no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Включить заполнитель без изображения USPS в GIF?" }, "description": "Завершите настройку, настроив следующее в соответствии со структурой вашей электронной почты и установкой Home Assistant.\n\nДля получения подробной информации о вариантах [интеграции Почта и Пакеты](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) ознакомьтесь с [разделом конфигурации, шаблонов и автоматизации](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) на GitHub.\n\nЕсли вы используете переадресованные электронные письма Amazon, разделите каждый адрес запятой или введите (none), чтобы очистить эту настройку.", "title": "Почта и пакеты (шаг 2 из 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Ошибка авторизации Mail and Packages", + "fix_flow": { + "step": { + "confirm": { + "title": "Повторная авторизация почтового сервера", + "description": "Не удалось авторизоваться на вашем почтовом сервере IMAP. Нажмите «Отправить», чтобы начать процесс повторной авторизации." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/sk.json b/custom_components/mail_and_packages/translations/sk.json index 373dfb2..36271bb 100644 --- a/custom_components/mail_and_packages/translations/sk.json +++ b/custom_components/mail_and_packages/translations/sk.json @@ -36,7 +36,8 @@ "generic_custom_img": "Použiť vlastný obrázok všeobecný 'bez dodávky'?", "generate_grid": "Vytvoriť mriežku obrázkov pre modely videnia LLM", "allow_forwarded_emails": "Povoliť preposielané e-maily okrem predvolenej hodnoty služby (napr. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Zahrnúť zástupný symbol USPS bez obrázka do GIF?" }, "description": "Dokončite konfiguráciu prispôsobením nasledujúcich položiek na základe štruktúry e-mailu a inštalácie Home Assistant.\n\nPodrobnosti nájdete na [Pošta a balíky integrácia](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) možnosti si pozrite v časti [konfigurácia, šablóny a automatizácie](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHube.", "title": "Pošta a balíky (krok 2 z 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Použiť vlastný obrázok všeobecný 'bez dodávky'?", "generate_grid": "Vytvoriť mriežku obrázkov pre modely videnia LLM", "allow_forwarded_emails": "Povoliť preposielané e-maily okrem predvolenej hodnoty služby (napr. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Zahrnúť zástupný symbol USPS bez obrázka do GIF?" }, "description": "Dokončite konfiguráciu prispôsobením nasledujúceho na základe štruktúry vášho e-mailu a inštalácie Home Assistant.\n\nPre podrobnosti o možnostiach [integrácie Mail a Packages](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) si prečítajte [sekciu o konfigurácii, šablónach a automatizáciách](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHub.\n\nAk používate preposlané e-maily od Amazonu, oddelte každú adresu čiarkou alebo zadajte (none) na vymazanie tohto nastavenia.", "title": "Pošta a balíky (krok 2 z 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Overenie Mail and Packages zlyhalo", + "fix_flow": { + "step": { + "confirm": { + "title": "Znovu overiť poštový server", + "description": "Overenie k vášmu poštovému serveru IMAP zlyhalo. Kliknutím na odoslať spustíte proces opätovného overenia." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/sl.json b/custom_components/mail_and_packages/translations/sl.json index f203fb1..ec0d6c1 100644 --- a/custom_components/mail_and_packages/translations/sl.json +++ b/custom_components/mail_and_packages/translations/sl.json @@ -36,7 +36,8 @@ "generic_custom_img": "Uporabite prilagojeno splošno sliko 'brez dostave'?", "generate_grid": "Ustvari mrežo slik za vizualne modele LLM", "allow_forwarded_emails": "Dovoli posredovane e-poštne sporočila poleg privzete vrednosti storitve (npr. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Ali naj se v GIF vključi nadomestni znak USPS brez slike?" }, "description": "Končajte konfiguracijo s prilagoditvijo naslednjih na podlagi strukture e-pošte in namestitve Home Assistant. \n\n Za podrobnosti o možnostih [Integracija pošte in paketov] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) preglejte [konfiguracijo, predloge in oddelku za avtomatizacije] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHubu.", "title": "Pošta in paketi (2. korak od 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Uporabite prilagojeno splošno sliko 'brez dostave'?", "generate_grid": "Ustvari mrežo slik za vizualne modele LLM", "allow_forwarded_emails": "Dovoli posredovane e-poštne sporočila poleg privzete vrednosti storitve (npr. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Ali naj se v GIF vključi nadomestni znak USPS brez slike?" }, "description": "Konfiguracijo dokončajte z prilagajanjem naslednjega glede na strukturo vašega e-poštnega sporočila in namestitev Home Assistant.\n\nZa podrobnosti o možnostih [integracije Mail and Packages](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) si oglejte [razdelek o konfiguraciji, predlogah in avtomatizacijah](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) na GitHubu.\n\nČe uporabljate preusmerjena e-poštna sporočila Amazon, ločite vsak naslov z vejico ali vnesite (noben) za izbris te nastavitve.", "title": "Pošta in paketi (2. korak od 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Preverjanje pristnosti za Mail and Packages ni uspelo", + "fix_flow": { + "step": { + "confirm": { + "title": "Ponovno preveri poštni strežnik", + "description": "Preverjanje pristnosti z vašim poštnim strežnikom IMAP ni uspelo. Kliknite Pošlji, če želite začeti potek ponovnega preverjanja pristnosti." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/sv.json b/custom_components/mail_and_packages/translations/sv.json index 77ae548..8223da2 100644 --- a/custom_components/mail_and_packages/translations/sv.json +++ b/custom_components/mail_and_packages/translations/sv.json @@ -36,7 +36,8 @@ "generic_custom_img": "Använd anpassad generisk 'ingen leverans' bild?", "generate_grid": "Skapa bildrutnät för LLM-visionsmodeller", "allow_forwarded_emails": "Tillåt vidarebefordrade e-postmeddelanden utöver en tjänsts standardvärde (t.ex. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Inkludera USPS platshållare utan bild i GIF?" }, "description": "Avsluta konfigurationen genom att anpassa följande baserat på din e-poststruktur och installation av hemassistent. \n\n Mer information om alternativen [Mail and Packages] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) läser [konfiguration, mallar , och automatiseringsavsnitt] (https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) på GitHub.", "title": "Mail och paket (steg 2 av 2)" @@ -80,7 +81,8 @@ "generic_custom_img": "Använd anpassad generisk 'ingen leverans' bild?", "generate_grid": "Skapa bildrutnät för LLM-visionsmodeller", "allow_forwarded_emails": "Tillåt vidarebefordrade e-postmeddelanden utöver en tjänsts standardvärde (t.ex. no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "Inkludera USPS platshållare utan bild i GIF?" }, "description": "Slutför konfigurationen genom att anpassa följande baserat på din e-poststruktur och Home Assistant-installation.\n\nFör detaljer om [Mail and Packages integration](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) alternativen granska [konfiguration, mallar och automatiseringssektionen](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration) på GitHub.\n\nOm du använder vidarebefordrade e-postmeddelanden från Amazon, separera varje adress med ett kommatecken eller ange (ingen) för att rensa denna inställning.", "title": "Mail och paket (steg 2 av 2)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Mail and Packages-autentisering misslyckades", + "fix_flow": { + "step": { + "confirm": { + "title": "Återautentisera e-postserver", + "description": "Autentisering mot din IMAP-e-postserver misslyckades. Klicka på skicka för att starta återautentiseringsflödet." + } + } + } + } } } diff --git a/custom_components/mail_and_packages/translations/zh_Hant_HK.json b/custom_components/mail_and_packages/translations/zh_Hant_HK.json index 27df481..d33c860 100644 --- a/custom_components/mail_and_packages/translations/zh_Hant_HK.json +++ b/custom_components/mail_and_packages/translations/zh_Hant_HK.json @@ -36,7 +36,8 @@ "generic_custom_img": "使用自訂的通用「無送貨」圖像?", "generate_grid": "為LLM視覺模型創建圖像網格", "allow_forwarded_emails": "允許轉發的電郵,除了服務的預設值(例如 no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "在 GIF 中包含 USPS 無圖像預留位置?" }, "description": "通過根據您的電子郵件結構和Home Assistant安裝自定義以下內容來完成配置。 \n\n有關[郵件和軟件包集成](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration)選項的詳細信息,請查看[配置,模板和自動化部分](GitHub上的https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration)。", "title": "郵件和包裹(第2步,共2步)" @@ -80,7 +81,8 @@ "generic_custom_img": "使用自訂的通用「無送貨」圖像?", "generate_grid": "為LLM視覺模型創建圖像網格", "allow_forwarded_emails": "允許轉發的電郵,除了服務的預設值(例如 no-reply@usps.com)", - "custom_days": "Days back to check for package emails (minimum 1, default 3)" + "custom_days": "Days back to check for package emails (minimum 1, default 3)", + "usps_placeholder": "在 GIF 中包含 USPS 無圖像預留位置?" }, "description": "根據您的電郵結構和Home Assistant安裝來完成配置。\n\n有關[郵件和包裹整合](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration)選項的詳情,請查閱GitHub上的[配置、模板和自動化部分](https://github.com/moralmunky/Home-Assistant-Mail-And-Packages/wiki/Configuration-and-Email-Settings#configuration)。\n\n如果使用Amazon轉發的電郵,請用逗號分隔每個地址,或輸入(none)以清除此設定。", "title": "郵件和包裹(第2步,共2步)" @@ -199,5 +201,18 @@ "oauth2_google": "OAuth2 - Google (Gmail)" } } + }, + "issues": { + "auth_failed": { + "title": "Mail and Packages 認證失敗", + "fix_flow": { + "step": { + "confirm": { + "title": "重新認證郵件伺服器", + "description": "認證 IMAP 郵件伺服器失敗。請按提交以啟動重新認證流程。" + } + } + } + } } } diff --git a/custom_components/mail_and_packages/utils/__pycache__/__init__.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 18928d1..0000000 Binary files a/custom_components/mail_and_packages/utils/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/amazon.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/amazon.cpython-314.pyc deleted file mode 100644 index da501e3..0000000 Binary files a/custom_components/mail_and_packages/utils/__pycache__/amazon.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/cache.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/cache.cpython-314.pyc deleted file mode 100644 index ca28ac7..0000000 Binary files a/custom_components/mail_and_packages/utils/__pycache__/cache.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/date.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/date.cpython-314.pyc deleted file mode 100644 index 86f0359..0000000 Binary files a/custom_components/mail_and_packages/utils/__pycache__/date.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/email.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/email.cpython-314.pyc deleted file mode 100644 index 0a94b88..0000000 Binary files a/custom_components/mail_and_packages/utils/__pycache__/email.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/image.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/image.cpython-314.pyc deleted file mode 100644 index ae0a6ef..0000000 Binary files a/custom_components/mail_and_packages/utils/__pycache__/image.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/imap.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/imap.cpython-314.pyc deleted file mode 100644 index 68eb957..0000000 Binary files a/custom_components/mail_and_packages/utils/__pycache__/imap.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/utils/__pycache__/shipper.cpython-314.pyc b/custom_components/mail_and_packages/utils/__pycache__/shipper.cpython-314.pyc deleted file mode 100644 index 4e8d537..0000000 Binary files a/custom_components/mail_and_packages/utils/__pycache__/shipper.cpython-314.pyc and /dev/null differ diff --git a/custom_components/mail_and_packages/utils/amazon.py b/custom_components/mail_and_packages/utils/amazon.py index cee4ab5..26a747e 100644 --- a/custom_components/mail_and_packages/utils/amazon.py +++ b/custom_components/mail_and_packages/utils/amazon.py @@ -43,7 +43,12 @@ _MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB DOMAIN_LANG_MAP = { "amazon.de": ["versandbestaetigung", "Geliefert:", "Zugestellt:"], - "amazon.it": ["conferma-spedizione", "Consegna effettuata:", "Arriverà"], + "amazon.it": [ + "conferma-spedizione", + "Consegna effettuata:", + "Arriverà", + "Spedito:", + ], "amazon.nl": [ "update-bestelling", "verzending-volgen", diff --git a/custom_components/mail_and_packages/utils/image.py b/custom_components/mail_and_packages/utils/image.py index 9a3cc80..e0e42fb 100644 --- a/custom_components/mail_and_packages/utils/image.py +++ b/custom_components/mail_and_packages/utils/image.py @@ -374,7 +374,7 @@ def _get_courier_info( ), ] - base_path = f"{hass.config.path()}/{default_image_path(hass, config)}" + base_path = hass.config.path(default_image_path(hass, config)) for ( active, diff --git a/custom_components/mail_and_packages/utils/imap.py b/custom_components/mail_and_packages/utils/imap.py index 36d8061..a2003aa 100644 --- a/custom_components/mail_and_packages/utils/imap.py +++ b/custom_components/mail_and_packages/utils/imap.py @@ -5,6 +5,7 @@ import binascii import logging import re import unicodedata +from urllib.parse import quote, unquote import aioimaplib from aioimaplib import ( @@ -115,6 +116,30 @@ def quote_folder(folder: str) -> str: return folder if _is_imap_atom(folder) else f'"{folder}"' +def encode_folder_ref(folder: str) -> str: + """Percent-encode a folder name for use in a composite ``folder/uid`` ID. + + Multi-folder searches tag each UID with its source folder as + ``folder/uid``. Those composite IDs are space-joined and re-split on + whitespace at several call sites, and split on ``/`` to recover the + folder — so the folder component must contain neither whitespace nor + ``/``. A folder named ``# - Projects`` would otherwise shatter into + ``#``, ``-``, ``Projects/55`` when the joined ID list is ``.split()``. + ``quote(..., safe="")`` escapes both (and ``%`` itself, keeping the + round-trip lossless for any folder name). + """ + return quote(folder, safe="") + + +def decode_folder_ref(folder: str) -> str: + """Decode the percent-encoded folder component of a composite ID. + + Takes the already-split folder component (everything before the final + ``/`` of a ``folder/uid`` ID), not the full composite ID. + """ + return unquote(folder) + + class InvalidAuth(HomeAssistantError): """Raise exception for invalid credentials.""" @@ -382,7 +407,7 @@ def _parse_esearch_line(line_bytes: bytes) -> list[bytes]: pass else: uids.append(part) - return [f"{mailbox}/{uid}".encode() for uid in uids] + return [f"{encode_folder_ref(mailbox)}/{uid}".encode() for uid in uids] async def _execute_single_search(account: IMAP4_SSL, search_query: str) -> list[bytes]: # noqa: C901 @@ -446,7 +471,8 @@ async def _execute_single_search(account: IMAP4_SSL, search_query: str) -> list[ if res.result == "OK" and res.lines: parsed = parse_search_response(res.lines) all_uids.extend( - f"{folder}/{uid.decode()}".encode() for uid in parsed + f"{encode_folder_ref(folder)}/{uid.decode()}".encode() + for uid in parsed ) except TimeoutError: raise @@ -570,7 +596,7 @@ async def email_fetch(account: IMAP4_SSL, num, parts: str = "(RFC822)") -> tuple num_str = num.decode() if isinstance(num, bytes) else str(num) if "/" in num_str: folder, num_str = num_str.rsplit("/", 1) - await selectfolder(account, folder) + await selectfolder(account, decode_folder_ref(folder)) try: res = await account.uid("FETCH", num_str, parts) except TimeoutError: @@ -597,7 +623,7 @@ async def email_fetch_headers(account: IMAP4_SSL, num) -> tuple: num_str = num.decode() if isinstance(num, bytes) else str(num) if "/" in num_str: folder, num_str = num_str.rsplit("/", 1) - await selectfolder(account, folder) + await selectfolder(account, decode_folder_ref(folder)) try: res = await account.uid("FETCH", num_str, "(BODY[HEADER.FIELDS (SUBJECT)])") except TimeoutError: @@ -627,7 +653,7 @@ async def email_fetch_text(account: IMAP4_SSL, num, parts: str = "(BODY[1])") -> num_str = num.decode() if isinstance(num, bytes) else str(num) if "/" in num_str: folder, num_str = num_str.rsplit("/", 1) - await selectfolder(account, folder) + await selectfolder(account, decode_folder_ref(folder)) try: res = await account.uid("FETCH", num_str, parts) except TimeoutError: @@ -688,6 +714,7 @@ async def email_fetch_batch( # noqa: C901 num_str = num.decode() if isinstance(num, bytes) else str(num) if "/" in num_str: folder, actual_num = num_str.rsplit("/", 1) + folder = decode_folder_ref(folder) else: folder, actual_num = None, num_str folder_to_nums.setdefault(folder, []).append(actual_num) diff --git a/custom_components/mail_and_packages/utils/shipper.py b/custom_components/mail_and_packages/utils/shipper.py index d4dba5e..756a3c1 100644 --- a/custom_components/mail_and_packages/utils/shipper.py +++ b/custom_components/mail_and_packages/utils/shipper.py @@ -114,8 +114,21 @@ def _find_tracking_in_body( return None -def save_image_data_to_disk(shipper_name: str, path: str, image_data: bytes) -> bool: +def save_image_data_to_disk( + shipper_name: str, path: str, image_data: bytes | None +) -> bool: """Write image bytes to disk and verify.""" + if not image_data: + # Extraction can hand us zero bytes (e.g. a bare/empty base64 data URI + # in the email HTML). Writing that produces a 0-byte "photo" the + # camera then serves as a broken image — report failure instead so + # the caller falls through to the next extraction pass. + _LOGGER.debug( + "%s - No image data extracted; not writing %s", + shipper_name, + path, + ) + return False try: # Ensure directory exists directory = Path(path).parent @@ -240,8 +253,11 @@ def _extract_from_html( else str(payload) ) - # Base64 check - if matches := re.findall(base64_pattern, content): + # Base64 check. Skip empty matches: a bare "data:image/...;base64," + # URI (seen in real FedEx delivered emails) matches zero characters, + # and taking it would discard a real photo later in the same part. + matches = [m for m in re.findall(base64_pattern, content) if m] + if matches: try: base64_data = matches[0].replace(" ", "").replace("=3D", "=") return save_image_data_to_disk( diff --git a/custom_components/maintenance_supporter/__init__.py b/custom_components/maintenance_supporter/__init__.py index 0e2bd97..722edbd 100644 --- a/custom_components/maintenance_supporter/__init__.py +++ b/custom_components/maintenance_supporter/__init__.py @@ -54,7 +54,10 @@ from .const import ( DEFAULT_PANEL_ENABLED, DEFAULT_WARNING_DAYS, DOMAIN, + EVENT_UNSUBS_KEY, GLOBAL_UNIQUE_ID, + MAX_COST, + MAX_DURATION_MINUTES, PLATFORMS, SERVICE_ADD_OBJECT, SERVICE_ADD_TASK, @@ -67,6 +70,13 @@ from .const import ( SERVICE_UPDATE_TASK, SIGNAL_NEW_OBJECT_ENTRY, SIGNAL_OBJECT_ENTRY_REMOVED, + STORES_CACHE_KEY, +) +from .const import ( + DOCUMENT_STORE_KEY as _DS_KEY, +) +from .const import ( + NOTIFICATION_MANAGER_KEY as _NM_KEY, ) from .coordinator import MaintenanceCoordinator from .entity.summary_coordinator import MaintenanceSummaryCoordinator @@ -82,8 +92,9 @@ from .websocket import async_register_commands _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -NOTIFICATION_MANAGER_KEY = "_notification_manager" -DOCUMENT_STORE_KEY = "_document_store" +# Re-exported from const for the existing deferred `from . import ...` users. +NOTIFICATION_MANAGER_KEY = _NM_KEY +DOCUMENT_STORE_KEY = _DS_KEY @dataclass @@ -105,8 +116,8 @@ SERVICE_COMPLETE_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_id, vol.Optional("notes"): vol.All(cv.string, vol.Length(max=2000)), - vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1_000_000)), - vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=525_600)), + vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)), + vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)), # Meter readings (v2.20, #83): recorded value for `reading` tasks. vol.Optional("reading_value"): vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), } @@ -884,7 +895,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, lambda _e: unsub_digest()) # Store unsub callbacks so they can be cleaned up when domain is unloaded - hass.data[DOMAIN]["_event_unsubs"] = [ + hass.data[DOMAIN][EVENT_UNSUBS_KEY] = [ unsub_notification, unsub_tag, unsub_action, @@ -1069,16 +1080,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf _LOGGER.debug("Global config entry set up: %s", entry.entry_id) else: - # Maintenance object entry: create Store + coordinator - store = MaintenanceStore(hass, entry.entry_id) + # Maintenance object entry: fetch-or-create the Store. Reused across + # entry RELOADS (cache in a top-level hass.data key): a second Store + # instance for the same file would race the first one's pending + # debounced save — see STORES_CACHE_KEY in const.py. + stores: dict[str, MaintenanceStore] = hass.data.setdefault(STORES_CACHE_KEY, {}) + cached_store = stores.get(entry.entry_id) + store = cached_store if cached_store is not None else MaintenanceStore(hass, entry.entry_id) + stores[entry.entry_id] = store - # Migrate dynamic state from ConfigEntry.data → Store (one-time) - cleaned_data = await async_migrate_to_store(hass, entry.entry_id, entry.data, store) - if cleaned_data is not entry.data: - hass.config_entries.async_update_entry(entry, data=dict(cleaned_data)) + if cached_store is None: + # First setup this run: load from disk + one-time migration. + # Compare against the CAPTURED snapshot, not the live entry.data: + # the migration awaits store I/O, and a concurrent WS write during + # that window replaces entry.data — an identity check against the + # live attribute then fails spuriously and this write clobbers the + # concurrent update with the pre-await snapshot (lost update, seen + # live when rapid part creates raced a reload's setup). + data_before_migration = entry.data + cleaned_data = await async_migrate_to_store(hass, entry.entry_id, data_before_migration, store) + if cleaned_data is not data_before_migration: + hass.config_entries.async_update_entry(entry, data=dict(cleaned_data)) + # A cached store's memory is authoritative — re-loading from disk here + # would drop any change still sitting in its debounce window. # Reconcile the entry.data <-> Store split (journey I1): drop store # state orphaned by a crash between the two writes of a deletion. + # Same reconciliation for spare-part stock state (journey S6): a crash + # between the ConfigEntry write and the Store save on a part deletion + # leaves its stock orphaned forever otherwise. + pruned_parts = store.prune_part_orphans(set(entry.data.get("parts") or {})) + if pruned_parts: + _LOGGER.info("Pruned %d orphaned part stock state(s) for %s", pruned_parts, entry.title) + await store.async_save() + pruned = store.prune_orphans(set(entry.data.get(CONF_TASKS, {}))) if pruned: _LOGGER.info( @@ -1105,6 +1140,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf async_dispatcher_send(hass, SIGNAL_NEW_OBJECT_ENTRY, entry.entry_id) + # Buy-task catch-up (spare parts): converge the shopping reminders with + # the current stock/pause/archive state — covers import/restore, a + # resume/unarchive, and any change made while this entry was unloaded. + # Declarative + idempotent, reloads only when something changed. + if entry.data.get("parts"): + from .parts_runtime import schedule_buy_task_reconcile + + schedule_buy_task_reconcile(hass, entry) + _LOGGER.debug( "Maintenance object entry set up: %s (%s)", entry.title, @@ -1373,6 +1417,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon # Unregister panel when global entry is unloaded await async_unregister_panel(hass) + # Flush a pending debounced store save BEFORE tearing down — belt and + # suspenders next to the store cache: disk is current the moment the entry + # goes away, whether or not it ever comes back this run. + store = hass.data.get(STORES_CACHE_KEY, {}).get(entry.entry_id) + if store is not None: + await store.async_save() + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) # Clean up domain data if no entries left @@ -1381,7 +1432,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY) if nm is not None: await nm.async_unload() - for unsub in hass.data.get(DOMAIN, {}).get("_event_unsubs", []): + for unsub in hass.data.get(DOMAIN, {}).get(EVENT_UNSUBS_KEY, []): unsub() hass.data.pop(DOMAIN, None) @@ -1396,8 +1447,15 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: # as a fixable repair issue so the user can restore it (#86). _sync_missing_global_entry_issue(hass) return - store = MaintenanceStore(hass, entry.entry_id) + store = hass.data.get(STORES_CACHE_KEY, {}).pop(entry.entry_id, None) + if store is None: + store = MaintenanceStore(hass, entry.entry_id) await store.async_remove() + # Drop the per-entry reconcile lock — the module dict would otherwise grow + # by one lock per object ever created in this HA run. + from .parts_runtime import discard_reconcile_lock + + discard_reconcile_lock(entry.entry_id) # v1.5.4: also called from ws_delete_object — but if the user removes the # config entry from HA's "Configure" UI, that path doesn't run, leaving # phantom task_refs in groups. Belt-and-suspenders. diff --git a/custom_components/maintenance_supporter/__pycache__/__init__.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/__init__.cpython-314.pyc index 62c594a..5d7bd12 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/__init__.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/binary_sensor.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/binary_sensor.cpython-314.pyc index 0a62843..ad393d5 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/binary_sensor.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/binary_sensor.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/button.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/button.cpython-314.pyc index cd0bae0..1586480 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/button.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/button.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/calendar.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/calendar.cpython-314.pyc index 9b6a5be..ea4a4ae 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/calendar.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/calendar.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/condition.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/condition.cpython-314.pyc index 2a5eac7..992e53d 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/condition.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/condition.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/config_flow.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/config_flow.cpython-314.pyc index d6aca48..e2376f9 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/config_flow.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/config_flow.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/config_flow_helpers.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/config_flow_helpers.cpython-314.pyc index 1a7a6fe..10d942b 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/config_flow_helpers.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/config_flow_helpers.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/config_flow_options_global.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/config_flow_options_global.cpython-314.pyc index a84eaa3..6192bcb 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/config_flow_options_global.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/config_flow_options_global.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/config_flow_trigger.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/config_flow_trigger.cpython-314.pyc index 07c96c7..892f338 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/config_flow_trigger.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/config_flow_trigger.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/const.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/const.cpython-314.pyc index 74a2e81..a2e8646 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/const.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/const.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/coordinator.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/coordinator.cpython-314.pyc index 63c1c78..91058c6 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/coordinator.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/coordinator.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/diagnostics.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/diagnostics.cpython-314.pyc index 13801a0..43a974e 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/diagnostics.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/diagnostics.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/intent.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/intent.cpython-314.pyc new file mode 100644 index 0000000..ec38405 Binary files /dev/null and b/custom_components/maintenance_supporter/__pycache__/intent.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/logbook.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/logbook.cpython-314.pyc index 66e0f33..9918dc9 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/logbook.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/logbook.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/panel.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/panel.cpython-314.pyc index a97cc6d..f1ba5bf 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/panel.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/panel.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/parts_runtime.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/parts_runtime.cpython-314.pyc new file mode 100644 index 0000000..3a2b9aa Binary files /dev/null and b/custom_components/maintenance_supporter/__pycache__/parts_runtime.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/repairs.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/repairs.cpython-314.pyc index b6ffb29..8753d4e 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/repairs.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/repairs.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/sensor.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/sensor.cpython-314.pyc index 87623dd..a05a9bd 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/sensor.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/sensor.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/storage.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/storage.cpython-314.pyc index 6ac0afb..a28330a 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/storage.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/storage.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/templates.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/templates.cpython-314.pyc index 63dc466..27a5107 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/templates.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/templates.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/templates_i18n.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/templates_i18n.cpython-314.pyc index ecd6e89..308f026 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/templates_i18n.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/templates_i18n.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/todo.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/todo.cpython-314.pyc index 3c6331a..9b93207 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/todo.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/todo.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/trigger.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/trigger.cpython-314.pyc index f86b625..4f450ae 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/trigger.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/trigger.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/__pycache__/views.cpython-314.pyc b/custom_components/maintenance_supporter/__pycache__/views.cpython-314.pyc index 7636fc4..ed295d2 100644 Binary files a/custom_components/maintenance_supporter/__pycache__/views.cpython-314.pyc and b/custom_components/maintenance_supporter/__pycache__/views.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/calendar.py b/custom_components/maintenance_supporter/calendar.py index fc3342c..4bafb66 100644 --- a/custom_components/maintenance_supporter/calendar.py +++ b/custom_components/maintenance_supporter/calendar.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from datetime import date, datetime, time, timedelta -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -233,6 +233,44 @@ def _cal_t(key: str, lang: str, **kwargs: str) -> str: return text +# babel's get_day_names lazily reads locale data files from disk on first use +# per locale — blocking I/O that must not run on the event loop (#88). Cache the +# names per (width, locale) and warm the cache off-loop at calendar setup, so +# the synchronous state/event computation never blocks the loop. +_DAY_NAMES_CACHE: dict[tuple[str, str], Any] = {} + + +def _day_names(width: str, locale: str) -> Any: + """Return babel day names for (width, locale) from the warmed cache. + + Warmed off-loop by :func:`async_warm_day_names` at setup, so this is a pure + dict lookup on the hot path. A cold miss (a locale that was never warmed — + not reachable when the HA language is the only locale used) falls back to a + one-time synchronous load rather than crashing. + """ + key = (width, locale) + cached = _DAY_NAMES_CACHE.get(key) + if cached is None: + from babel.dates import get_day_names + + cached = get_day_names(width, locale=locale) # type: ignore[arg-type] + _DAY_NAMES_CACHE[key] = cached + return cached + + +async def async_warm_day_names(hass: HomeAssistant, locale: str) -> None: + """Preload babel day-name locale data off the event loop (#88).""" + + def _load() -> None: + from babel.dates import get_day_names + + for width in ("abbreviated", "wide"): + if (width, locale) not in _DAY_NAMES_CACHE: + _DAY_NAMES_CACHE[(width, locale)] = get_day_names(width, locale=locale) + + await hass.async_add_executor_job(_load) + + def _recurrence_text(task: MaintenanceTask, lang: str) -> str: """Localized recurrence label for the calendar event description. @@ -246,19 +284,18 @@ def _recurrence_text(task: MaintenanceTask, lang: str) -> str: if raw is not None and kind in ("weekdays", "nth_weekday", "day_of_month"): loc = lang or "en" try: - # babel (HA-provided) gives locale-correct weekday names; degrade - # gracefully (no label) if it is somehow absent, never crash. - from babel.dates import get_day_names - + # babel (HA-provided) gives locale-correct weekday names, served + # from a cache warmed off-loop at setup (#88); degrade gracefully + # (no label) if it is somehow absent, never crash. if kind == "weekdays": - names = get_day_names("abbreviated", locale=loc) + names = _day_names("abbreviated", loc) days = [d for d in raw.get("weekdays") or [] if isinstance(d, int) and 0 <= d <= 6] return " & ".join(names[d] for d in days) if kind == "nth_weekday": wd, nth = raw.get("weekday"), raw.get("nth") if not isinstance(wd, int) or not isinstance(nth, int): return "" - name = get_day_names("wide", locale=loc)[wd] + name = _day_names("wide", loc)[wd] ordinal = _cal_t("cal_last", lang) if nth == -1 else f"{nth}." return f"{ordinal} {name}" # day_of_month @@ -290,6 +327,11 @@ async def async_setup_entry( runtime_data.coordinator.register_calendar_entity(calendar) return + # Warm babel's day-name locale cache off the event loop BEFORE adding the + # entity — HA computes the calendar state during async_add_entities, and + # _recurrence_text would otherwise read locale files on the loop (#88). + await async_warm_day_names(hass, normalize_language(hass)) + calendar = MaintenanceCalendar(hass) hass.data.setdefault(DOMAIN, {})["_calendar_entity"] = calendar async_add_entities([calendar]) @@ -500,15 +542,16 @@ class MaintenanceCalendar(CalendarEntity): end: date | datetime = next_due + timedelta(days=1) if task.schedule_time and self._is_schedule_time_feature_enabled(): try: - hh, mm = task.schedule_time.split(":", 1) + # Tolerate "HH:MM" and "HH:MM:SS" (HA TimeSelector's format). + parts = str(task.schedule_time).split(":") start_dt = datetime.combine( next_due, - time(int(hh), int(mm)), + time(int(parts[0]), int(parts[1])), tzinfo=dt_util.DEFAULT_TIME_ZONE, ) start = start_dt end = start_dt + timedelta(minutes=30) - except (ValueError, TypeError): + except (ValueError, TypeError, IndexError): pass # malformed schedule_time → fall back to all-day return CalendarEvent( diff --git a/custom_components/maintenance_supporter/config_flow.py b/custom_components/maintenance_supporter/config_flow.py index 4a4e929..ef47f86 100644 --- a/custom_components/maintenance_supporter/config_flow.py +++ b/custom_components/maintenance_supporter/config_flow.py @@ -62,7 +62,7 @@ from .const import ( from .helpers.global_options import get_default_warning_days from .helpers.i18n import normalize_language from .helpers.schedule import KIND_WEEKDAYS, normalize_task_storage -from .helpers.task_fields import TASK_PRIORITIES, WARNING_DAYS_RANGE +from .helpers.task_fields import INTERVAL_DAYS_RANGE, TASK_PRIORITIES, WARNING_DAYS_RANGE from .templates import ( TEMPLATE_CATEGORIES, ObjectTemplate, @@ -149,7 +149,9 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA data_schema=vol.Schema( { vol.Optional(CONF_DEFAULT_WARNING_DAYS, default=DEFAULT_WARNING_DAYS): selector.NumberSelector( - selector.NumberSelectorConfig(min=1, max=365, step=1, mode=selector.NumberSelectorMode.BOX) + selector.NumberSelectorConfig( + min=1, max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX + ) ), vol.Optional(CONF_NOTIFICATIONS_ENABLED, default=False): selector.BooleanSelector(), vol.Optional(CONF_NOTIFY_SERVICE, default=""): selector.SelectSelector( @@ -476,13 +478,27 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA # the CSV/JSON import chokepoint (schedule-model v2). tasks[task_id] = normalize_task_storage(new_td) - return self.async_create_entry( - title=object_name, - data={ - CONF_OBJECT: obj_data, - CONF_TASKS: tasks, - }, - ) + # Spare parts: re-validate each imported definition through the same + # normalizer the WS CRUD uses (bad entries are dropped, not fatal). + parts_in = user_input.get("parts") + parts: dict[str, dict[str, Any]] = {} + if isinstance(parts_in, dict): + from .helpers.parts import PartValidationError, normalize_part + + for pid, praw in parts_in.items(): + try: + part = normalize_part({**praw, "id": pid}) + except (PartValidationError, TypeError): + continue + parts[part["id"]] = part + + data: dict[str, Any] = { + CONF_OBJECT: obj_data, + CONF_TASKS: tasks, + } + if parts: + data["parts"] = parts + return self.async_create_entry(title=object_name, data=data) async def async_step_create_object(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: """Create a new maintenance object.""" @@ -679,8 +695,8 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA { vol.Required(CONF_TASK_INTERVAL_DAYS, default=DEFAULT_INTERVAL_DAYS): selector.NumberSelector( selector.NumberSelectorConfig( - min=1, - max=3650, + min=INTERVAL_DAYS_RANGE[0], + max=INTERVAL_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX, ) @@ -692,8 +708,8 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA default=get_default_warning_days(self.hass), ): selector.NumberSelector( selector.NumberSelectorConfig( - min=0, - max=365, + min=WARNING_DAYS_RANGE[0], + max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX, ) @@ -767,8 +783,8 @@ class MaintenanceSupporterConfigFlow(TriggerConfigMixin, ConfigFlow, domain=DOMA default=get_default_warning_days(self.hass), ): selector.NumberSelector( selector.NumberSelectorConfig( - min=0, - max=365, + min=WARNING_DAYS_RANGE[0], + max=WARNING_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX, ) diff --git a/custom_components/maintenance_supporter/config_flow_options_task_adaptive.py b/custom_components/maintenance_supporter/config_flow_options_task_adaptive.py index 0cacddb..e28451d 100644 --- a/custom_components/maintenance_supporter/config_flow_options_task_adaptive.py +++ b/custom_components/maintenance_supporter/config_flow_options_task_adaptive.py @@ -24,10 +24,16 @@ from .const import ( from .helpers.schedule import ( read_legacy_fields, ) +from .helpers.task_fields import INTERVAL_DAYS_RANGE if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry +# UI-only cap for the adaptive MINIMUM interval (one year). Deliberately not a +# shared const: the adaptive min has no WS/schema twin to drift from — the +# engine clamps recommendations to the per-task min/max pair itself. +_ADAPTIVE_MIN_INTERVAL_CAP_DAYS = 365 + class AdaptiveMixin: """Adaptive scheduling configuration.""" @@ -144,7 +150,7 @@ class AdaptiveMixin: ): selector.NumberSelector( selector.NumberSelectorConfig( min=1, - max=365, + max=_ADAPTIVE_MIN_INTERVAL_CAP_DAYS, step=1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="days", @@ -156,7 +162,7 @@ class AdaptiveMixin: ): selector.NumberSelector( selector.NumberSelectorConfig( min=1, - max=3650, + max=INTERVAL_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="days", diff --git a/custom_components/maintenance_supporter/config_flow_options_task_add.py b/custom_components/maintenance_supporter/config_flow_options_task_add.py index 595745d..68391c3 100644 --- a/custom_components/maintenance_supporter/config_flow_options_task_add.py +++ b/custom_components/maintenance_supporter/config_flow_options_task_add.py @@ -37,7 +37,7 @@ from .helpers.global_options import get_default_warning_days from .helpers.schedule import ( KIND_WEEKDAYS, ) -from .helpers.task_fields import TASK_PRIORITIES, WARNING_DAYS_RANGE +from .helpers.task_fields import INTERVAL_DAYS_RANGE, TASK_PRIORITIES, WARNING_DAYS_RANGE if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -174,7 +174,9 @@ class AddTaskMixin: data_schema=vol.Schema( { vol.Required(CONF_TASK_INTERVAL_DAYS, default=DEFAULT_INTERVAL_DAYS): selector.NumberSelector( - selector.NumberSelectorConfig(min=1, max=3650, step=1, mode=selector.NumberSelectorMode.BOX) + selector.NumberSelectorConfig( + min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX + ) ), vol.Optional(CONF_TASK_INTERVAL_UNIT, default="days"): interval_unit_selector(), vol.Optional(CONF_TASK_INTERVAL_ANCHOR, default="completion"): selector.SelectSelector( diff --git a/custom_components/maintenance_supporter/config_flow_options_task_crud.py b/custom_components/maintenance_supporter/config_flow_options_task_crud.py index 93d126c..f5f6c78 100644 --- a/custom_components/maintenance_supporter/config_flow_options_task_crud.py +++ b/custom_components/maintenance_supporter/config_flow_options_task_crud.py @@ -53,6 +53,7 @@ from .helpers.schedule import ( ) from .helpers.task_fields import ( EARLIEST_COMPLETION_RANGE, + INTERVAL_DAYS_RANGE, TASK_PRIORITIES, WARNING_DAYS_RANGE, ) @@ -187,6 +188,12 @@ class TaskCrudMixin: # schedule_time only present when global advanced flag is on; clear by submitting "". if CONF_TASK_SCHEDULE_TIME in user_input: sched = (user_input.get(CONF_TASK_SCHEDULE_TIME) or "").strip() + # HA's TimeSelector serialises "HH:MM:SS"; every consumer + # parses "HH:MM", so normalise to the first two components + # here (else the calendar/next-due land at midnight). + parts = sched.split(":") + if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit(): + sched = f"{int(parts[0]):02d}:{int(parts[1]):02d}" if sched: updated_task["schedule_time"] = sched else: @@ -344,7 +351,12 @@ class TaskCrudMixin: CONF_TASK_INTERVAL_DAYS, default=sched["interval_days"] or DEFAULT_INTERVAL_DAYS, ): selector.NumberSelector( - selector.NumberSelectorConfig(min=1, max=3650, step=1, mode=selector.NumberSelectorMode.BOX) + selector.NumberSelectorConfig( + min=INTERVAL_DAYS_RANGE[0], + max=INTERVAL_DAYS_RANGE[1], + step=1, + mode=selector.NumberSelectorMode.BOX, + ) ), vol.Optional( CONF_TASK_INTERVAL_UNIT, diff --git a/custom_components/maintenance_supporter/config_flow_trigger.py b/custom_components/maintenance_supporter/config_flow_trigger.py index c0d7258..7551779 100644 --- a/custom_components/maintenance_supporter/config_flow_trigger.py +++ b/custom_components/maintenance_supporter/config_flow_trigger.py @@ -60,7 +60,7 @@ from .const import ( TriggerType, ) from .helpers.global_options import get_default_warning_days -from .helpers.task_fields import WARNING_DAYS_RANGE +from .helpers.task_fields import INTERVAL_DAYS_RANGE, WARNING_DAYS_RANGE # Domains allowed for trigger entity selection. # Includes all domains from entity_attributes.DOMAIN_ATTRIBUTE_MAP plus @@ -432,8 +432,8 @@ class TriggerConfigMixin: { vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector( selector.NumberSelectorConfig( - min=1, - max=3650, + min=INTERVAL_DAYS_RANGE[0], + max=INTERVAL_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX, ) @@ -531,8 +531,8 @@ class TriggerConfigMixin: { vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector( selector.NumberSelectorConfig( - min=1, - max=3650, + min=INTERVAL_DAYS_RANGE[0], + max=INTERVAL_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX, ) @@ -635,8 +635,8 @@ class TriggerConfigMixin: { vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector( selector.NumberSelectorConfig( - min=1, - max=3650, + min=INTERVAL_DAYS_RANGE[0], + max=INTERVAL_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX, ) @@ -740,8 +740,8 @@ class TriggerConfigMixin: { vol.Optional(CONF_TASK_INTERVAL_DAYS): selector.NumberSelector( selector.NumberSelectorConfig( - min=1, - max=3650, + min=INTERVAL_DAYS_RANGE[0], + max=INTERVAL_DAYS_RANGE[1], step=1, mode=selector.NumberSelectorMode.BOX, ) diff --git a/custom_components/maintenance_supporter/const.py b/custom_components/maintenance_supporter/const.py index 1248437..5706a01 100644 --- a/custom_components/maintenance_supporter/const.py +++ b/custom_components/maintenance_supporter/const.py @@ -9,14 +9,42 @@ from homeassistant.const import Platform DOMAIN = "maintenance_supporter" +# Keys of the shared runtime singletons inside hass.data[DOMAIN]. Defined here +# (not in __init__) so any module can import them without a circular import — +# a DRY audit (2026-07-10) found the notification-manager key duplicated as a +# string literal in four call sites and the budget/event keys with no constant +# at all (the #86 panel-flag desync came from exactly this literal-key class). +NOTIFICATION_MANAGER_KEY = "_notification_manager" +DOCUMENT_STORE_KEY = "_document_store" +BUDGET_CACHE_KEY = "_budget_cache" +EVENT_UNSUBS_KEY = "_event_unsubs" +# TOP-LEVEL hass.data key (survives the DOMAIN pop on last-entry unload): +# one MaintenanceStore per entry_id, REUSED across entry reloads. Two Store +# instances for the same file would race: a pending 60s debounced save on +# the pre-reload instance fires AFTER the post-reload instance loaded, and +# its whole-file write clobbers everything the new instance saved since +# (silent loss of completions/history — bug audit 2026-07-11). +STORES_CACHE_KEY = f"{DOMAIN}_stores" + def slugify_object_name(name: str) -> str: """Convert an object name to a safe slug for use in unique IDs. Replaces any non-alphanumeric character with underscore, collapses consecutive underscores, and strips leading/trailing underscores. + + A name with no ASCII alphanumerics (CJK, Cyrillic, emoji, punctuation-only) + slugifies to an empty string, which would make every such object collide on + the same ``maintenance_supporter_`` unique id (so a whole non-Latin-script + user base could only ever create ONE object). Fall back to a stable hash of + the original name so distinct names get distinct ids. """ - return re.sub(r"_+", "_", re.sub(r"[^a-z0-9]", "_", name.lower())).strip("_") + slug = re.sub(r"_+", "_", re.sub(r"[^a-z0-9]", "_", name.lower())).strip("_") + if not slug: + import hashlib + + slug = "obj_" + hashlib.sha1(name.strip().encode("utf-8")).hexdigest()[:12] + return slug PLATFORMS: list[Platform] = [ @@ -118,6 +146,22 @@ CONF_OBJECTS_TABLE_COLUMNS = "objects_table_columns" # removed from the pickers so a growing catalog never clutters the UI. CONF_DISABLED_TEMPLATE_IDS = "disabled_template_ids" +# v2.24: saved filter views — named, shared combinations of the panel list's +# filters (status / user / archived) + sort + group-by that any user can apply +# from the toolbar. Stored on the global entry as a list of view dicts; see +# helpers/saved_views.py for the shape + sanitiser. A view's id can later feed +# notification routing ("only notify me about view X"). +CONF_SAVED_FILTER_VIEWS = "saved_filter_views" +MAX_SAVED_VIEWS = 50 # coarse ceiling so the global entry can't grow unbounded +MAX_VIEW_NAME_LENGTH = 60 + +# v2.26: notes of a deleted ADOPTED problem-sensor task, keyed by the watched +# sensor's entity_id, so an un-adopt → re-adopt cycle restores them (roadmap +# "note persistence"). Internal state on the global entry (like saved views), +# not a user-facing setting. FIFO-capped; an entry is consumed on re-adopt. +CONF_ADOPTED_NOTES = "adopted_task_notes" +MAX_ADOPTED_NOTES = 100 + # Every column key the objects table can render. The Settings UI offers exactly # these; the WS update handler drops anything outside this set. KNOWN_OBJECT_TABLE_COLUMNS = [ @@ -190,6 +234,11 @@ CONF_NOTIFICATION_BUNDLE_THRESHOLD = "notification_bundle_threshold" # "object_name" — use the object name as the title (helps when phone stacks notifications). # "task_name" — use the task name as the title. CONF_NOTIFICATION_TITLE_STYLE = "notification_title_style" + +# v2.26: notification routing — id of a saved filter view; when set, only tasks +# MATCHING that view's task-selecting filters (label + responsible user) send +# reminders. "" = no scope (all tasks). See helpers/saved_views.view_matches_task. +CONF_NOTIFY_SCOPE_VIEW_ID = "notify_scope_view_id" NOTIFICATION_TITLE_STYLES = ("default", "object_name", "task_name") # --- Config Keys: Notification Actions --- @@ -259,6 +308,17 @@ CONF_OBJECT_DOCUMENTATION_URL = "documentation_url" # v1.4.10 (#46): per-object free-form notes (part numbers, procedures, etc.) CONF_OBJECT_NOTES = "notes" +# --- Config Keys: Spare parts & consumables --- +# Static part definitions live in entry.data["parts"] (like tasks); the +# mutable stock count lives in the per-entry Store. See helpers/parts.py. +CONF_PARTS = "parts" +# Task-side link: task["consumes_parts"] = [{"part_id", "quantity"}] — a +# completion decrements each linked part's stock. +CONF_TASK_CONSUMES_PARTS = "consumes_parts" +# Global setting: shopping-search URL template with a {q} placeholder, used +# for parts without a product_url (default: Amazon by UI language). +CONF_PART_SEARCH_URL_TEMPLATE = "part_search_url_template" + # --- Config Keys: Task --- CONF_TASKS = "tasks" CONF_TASK_NAME = "name" @@ -365,6 +425,7 @@ BUDGET_CURRENCIES: dict[str, str] = { "CHF": "Fr", "CAD": "C$", "AUD": "A$", + "NZD": "NZ$", # #96 "CNY": "¥", "INR": "₹", "BRL": "R$", @@ -426,6 +487,12 @@ EVENT_TASK_COMPLETED = f"{DOMAIN}_task_completed" EVENT_TASK_SKIPPED = f"{DOMAIN}_task_skipped" EVENT_TASK_RESET = f"{DOMAIN}_task_reset" +# Spare-part stock crossings — edge-triggered (one event per transition; a +# further decrease while already low never re-fires). See helpers/parts.py. +EVENT_PART_STOCK_LOW = f"{DOMAIN}_part_stock_low" +EVENT_PART_STOCK_OUT = f"{DOMAIN}_part_stock_out" +EVENT_PART_RESTOCKED = f"{DOMAIN}_part_restocked" + # --- Service Names --- SERVICE_COMPLETE = "complete" SERVICE_RESET = "reset" @@ -562,6 +629,12 @@ MAX_URL_LENGTH = 2048 MAX_ICON_LENGTH = 100 # "mdi:icon-name" MAX_META_LENGTH = 200 # manufacturer, model, user_id, area_id, etc. MAX_PANEL_TITLE_LENGTH = 50 # sidebar panel title override + +# Completion cost + duration bounds — one source for the WS schemas AND the +# config-flow-path sanitiser (they must agree, else a value the sanitiser +# accepts can bypass the WS cap or vice-versa). +MAX_COST = 1_000_000 # per completion / history entry +MAX_DURATION_MINUTES = 525_600 # one year in minutes MAX_TYPE_LENGTH = 50 # task_type, schedule_type MAX_CHECKLIST_ITEMS = 100 MAX_CHECKLIST_ITEM_LENGTH = 500 @@ -573,9 +646,22 @@ MAX_DATE_LENGTH = 20 # ISO 8601 date strings (e.g. 2026-04-21) MAX_ENTITY_ID_LENGTH = 255 # HA entity_id max MAX_ENTITY_SLUG_LENGTH = 64 # task entity_slug MAX_INTERVAL_DAYS = 3650 # 10 years — caps date arithmetic overflow -MAX_IMPORT_PAYLOAD_BYTES = 1_048_576 # 1 MB for csv_content / json_content +# Import payload byte caps. CSV is flat (one row per task) so 1 MB is ample; +# a JSON/YAML backup carries full history + every field, so it needs more head- +# room — a 1000-object export with history routinely exceeds 1 MB. Both were +# hardcoded literals at their call sites (bug audit 2026-07-11); named here so +# the two limits are visible and can't silently drift. +MAX_IMPORT_PAYLOAD_BYTES = 1_048_576 # 1 MB — csv_content +MAX_JSON_IMPORT_PAYLOAD_BYTES = 10 * 1_048_576 # 10 MB — json_content (history-heavy) MAX_SCHEDULE_TIME_LENGTH = 5 # "HH:MM" +# Per-object fan-out caps — a runaway automation or import loop must not be able +# to inflate one object's ConfigEntry.data without bound. Deliberately generous +# (far above any real device): they catch accidents/abuse, not real use. Parts +# already had MAX_PARTS_PER_OBJECT (50); tasks and documents were unbounded. +MAX_TASKS_PER_OBJECT = 200 # mirrors MAX_GROUP_TASK_REFS; ~10x any real device +MAX_DOCS_PER_OBJECT = 100 # manuals + invoices + photos — 100 is very generous + # --- Trigger Entity Availability --- STARTUP_GRACE_PERIOD_SECONDS = 300 # 5 minutes MISSING_ENTITY_THRESHOLD_REFRESHES = 6 # ~30 min at 5-min intervals diff --git a/custom_components/maintenance_supporter/coordinator.py b/custom_components/maintenance_supporter/coordinator.py index b88403d..c1d6343 100644 --- a/custom_components/maintenance_supporter/coordinator.py +++ b/custom_components/maintenance_supporter/coordinator.py @@ -20,6 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( + BUDGET_CACHE_KEY, BUDGET_CURRENCIES, CONF_ADVANCED_SCHEDULE_TIME, CONF_BUDGET_ALERT_THRESHOLD, @@ -39,6 +40,7 @@ from .const import ( GLOBAL_UNIQUE_ID, MANUAL_COMPLETION_DEDUP_SECONDS, MISSING_ENTITY_THRESHOLD_REFRESHES, + NOTIFICATION_MANAGER_KEY, SIGNAL_TASK_RESET, STARTUP_GRACE_PERIOD_SECONDS, TRIGGER_COMPLETION_COOLDOWN_SECONDS, @@ -409,7 +411,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): if not self._previous_statuses: from .helpers.notification_manager import NotificationManager - nm = self.hass.data.get(DOMAIN, {}).get("_notification_manager") + nm = self.hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY) notify_statuses = { MaintenanceStatus.DUE_SOON, MaintenanceStatus.OVERDUE, @@ -677,7 +679,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): """ from .helpers.notification_manager import NotificationManager - nm = self.hass.data.get(DOMAIN, {}).get("_notification_manager") + nm = self.hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY) if not isinstance(nm, NotificationManager): return @@ -700,6 +702,33 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): if new_status in notify_statuses: notifiable.append((task_id, task_result, new_status, old_status)) + # Vacation mode silences some tasks. Filter them out HERE — before the + # bundle-threshold check below — so a silenced/exempt task neither + # triggers a bundle nor rides inside one. async_send_bundled has no + # per-task vacation gate (async_task_status_changed does), and the bundle + # COUNT must also exclude them so N-1 silenced tasks don't force a bundle. + from .helpers.vacation import get_vacation_state + + _vac = get_vacation_state(self.hass) + notifiable = [row for row in notifiable if not _vac.is_silent_for(row[0])] + + # v2.26 notification routing: a saved-view scope ("only notify about + # view X") drops tasks the view's label/user filters don't match — + # BEFORE the bundle threshold, like the vacation filter above. A stale + # view id (view deleted) means no scope, never "silence everything". + from .const import CONF_NOTIFY_SCOPE_VIEW_ID + from .helpers.global_options import get_global_options + + scope_view_id = get_global_options(self.hass).get(CONF_NOTIFY_SCOPE_VIEW_ID) or "" + if scope_view_id: + from .helpers.saved_views import list_saved_views, view_matches_task + + scope = next((v for v in list_saved_views(self.hass) if v["id"] == scope_view_id), None) + if scope is not None: + notifiable = [ + row for row in notifiable if view_matches_task(scope["filters"], row[1]) + ] + if not notifiable: # No notifications needed — still update the cache for task_id, task_result in task_results.items(): @@ -756,7 +785,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): def _recalculate_budget_cache(self) -> None: """Recompute global budget totals from all entries' history. - Stores the result in hass.data[DOMAIN]["_budget_cache"] so that + Stores the result in hass.data[DOMAIN][BUDGET_CACHE_KEY] so that every coordinator reads from the same cache instead of each one re-scanning all entries on every 5-minute refresh. """ @@ -802,7 +831,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): if entry_dt.month == now.month: monthly += cost_val - self.hass.data.setdefault(DOMAIN, {})["_budget_cache"] = { + self.hass.data.setdefault(DOMAIN, {})[BUDGET_CACHE_KEY] = { "monthly_spent": monthly, "yearly_spent": yearly, "last_updated": now, @@ -812,7 +841,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Check budget thresholds using cached totals.""" from .helpers.notification_manager import NotificationManager - nm = self.hass.data.get(DOMAIN, {}).get("_notification_manager") + nm = self.hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY) if not isinstance(nm, NotificationManager) or not nm.enabled: return @@ -836,10 +865,10 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): currency_symbol = BUDGET_CURRENCIES.get(currency_code, "€") # Use cached budget totals (recalculate if stale or missing) - cache: dict[str, Any] | None = self.hass.data.get(DOMAIN, {}).get("_budget_cache") + cache: dict[str, Any] | None = self.hass.data.get(DOMAIN, {}).get(BUDGET_CACHE_KEY) if cache is None or (dt_util.now() - cache["last_updated"]).total_seconds() > 3600: self._recalculate_budget_cache() - cache = self.hass.data[DOMAIN]["_budget_cache"] + cache = self.hass.data[DOMAIN][BUDGET_CACHE_KEY] monthly_spent: float = cache["monthly_spent"] yearly_spent: float = cache["yearly_spent"] @@ -935,6 +964,7 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): completed_by: str | None = None, photo_doc_id: str | None = None, reading_value: float | None = None, + restock_quantity: int | None = None, ) -> None: """Mark a task as completed and persist.""" merged = self._get_merged_tasks_data() @@ -959,6 +989,11 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): time.monotonic() - last_manual, ) return + # Stamp the guard NOW, before any await (the photo-link below yields the + # loop). Stamping only at the end let two photo-carrying completions in + # the same tick both pass the check and interleave → double rotation / + # part-consume / history entry. + self._recent_manual_completions[task_id] = time.monotonic() task = MaintenanceTask.from_dict(merged[task_id]) pre_rotation_responsible = task.responsible_user_id @@ -1006,7 +1041,6 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): updated_config = analyzer.update_on_completion(task.adaptive_config, actual_interval, feedback) task.adaptive_config = updated_config - self._recent_manual_completions[task_id] = time.monotonic() await self._persist_and_signal_task_change(task_id, task) # Shared-task rotation: advance_rotation() (inside task.complete) @@ -1030,6 +1064,21 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): if cost is not None: self._recalculate_budget_cache() + # Spare parts: consume linked parts / restock a completed buy task. + # Best-effort side effect — a broken part link must never fail the + # completion. Runs AFTER persistence so a crash loses only stock math. + from .parts_runtime import async_handle_completion_parts + + try: + await async_handle_completion_parts( + self.hass, + self.entry, + merged[task_id], + restock_quantity=restock_quantity, + ) + except Exception: + _LOGGER.exception("Part consumption failed for task %s", task_id) + _LOGGER.debug("Maintenance completed: %s on %s", task.name, self.maintenance_object.name) # Fire event after persistence — power users wire HA automations on @@ -1065,6 +1114,11 @@ class MaintenanceCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Inert tasks never auto-complete. if task_data.get("archived_at") is not None or task_data.get("enabled") is False: return + # A paused object fires nothing — the periodic evaluator gates on this, + # but this event-driven recovery path must too (else a paused object + # whose sensor recovers records a real completion, defeating the pause). + if self.entry.data.get(CONF_OBJECT, {}).get("paused_at") is not None: + return # Race guard: if a completion was recorded moments ago (e.g. a manual # complete whose trigger reset crossed paths with a queued state # change), don't record a second one. diff --git a/custom_components/maintenance_supporter/diagnostics.py b/custom_components/maintenance_supporter/diagnostics.py index 101c49f..1c49228 100644 --- a/custom_components/maintenance_supporter/diagnostics.py +++ b/custom_components/maintenance_supporter/diagnostics.py @@ -33,7 +33,16 @@ TO_REDACT = { "nfc_tag_id", "notify_service", "responsible_user_id", + # HA user UUIDs — redact for parity with responsible_user_id (were leaking). + "assignee_pool", + "admin_panel_user_ids", "serial_number", + # Spare-part supplier/product identifiers (product_url can carry tokens). + "product_url", + "vendor", + "mpn", + "gtin", + "storage_location", } @@ -211,10 +220,11 @@ def _check_data_quality(data: Mapping[str, Any]) -> list[str]: sched = read_legacy_fields(task) if sched["schedule_type"] == "time_based" and not sched["interval_days"]: - warnings.append(f"Task '{task.get('name', task_id)}' is time-based but has no interval") + # Reference by id, not name — these warnings aren't redacted. + warnings.append(f"Task {task_id} is time-based but has no interval") trigger = task.get("trigger_config") if trigger and trigger.get("type") != "compound" and not trigger.get("entity_id"): - warnings.append(f"Task '{task.get('name', task_id)}' has trigger config but no entity") + warnings.append(f"Task {task_id} has trigger config but no entity") return warnings 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 92cd330..f4886a7 100644 Binary files a/custom_components/maintenance_supporter/entity/__pycache__/__init__.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/__pycache__/entity_base.cpython-314.pyc b/custom_components/maintenance_supporter/entity/__pycache__/entity_base.cpython-314.pyc index 0173390..14c5c66 100644 Binary files a/custom_components/maintenance_supporter/entity/__pycache__/entity_base.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/__pycache__/entity_base.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/__pycache__/summary_coordinator.cpython-314.pyc b/custom_components/maintenance_supporter/entity/__pycache__/summary_coordinator.cpython-314.pyc index 2a20743..3d36df2 100644 Binary files a/custom_components/maintenance_supporter/entity/__pycache__/summary_coordinator.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/__pycache__/summary_coordinator.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/__init__.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/__init__.cpython-314.pyc index c28b8a7..e9a06fc 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/__init__.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/__init__.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/base_trigger.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/base_trigger.cpython-314.pyc index 8f54040..4df757a 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/base_trigger.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/base_trigger.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/compound.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/compound.cpython-314.pyc index f7ad5cd..6a942ca 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/compound.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/compound.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/counter.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/counter.cpython-314.pyc index 50495c2..60c634c 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/counter.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/counter.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/runtime.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/runtime.cpython-314.pyc index 7eee229..46b76e5 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/runtime.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/runtime.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/state_change.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/state_change.cpython-314.pyc index b592eac..cbb8df0 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/state_change.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/state_change.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/entity/triggers/__pycache__/threshold.cpython-314.pyc b/custom_components/maintenance_supporter/entity/triggers/__pycache__/threshold.cpython-314.pyc index 1aff80c..84e1434 100644 Binary files a/custom_components/maintenance_supporter/entity/triggers/__pycache__/threshold.cpython-314.pyc and b/custom_components/maintenance_supporter/entity/triggers/__pycache__/threshold.cpython-314.pyc differ diff --git a/custom_components/maintenance_supporter/export.py b/custom_components/maintenance_supporter/export.py index 2c9e25b..bc308d2 100644 --- a/custom_components/maintenance_supporter/export.py +++ b/custom_components/maintenance_supporter/export.py @@ -28,8 +28,9 @@ def _export_documents(doc_store: Any, object_id: str) -> list[dict[str, Any]]: File binaries are NOT in the JSON export (they live under /config and travel via the HA backup). An import without a matching backup therefore recreates file metadata pointing at a missing blob — the storage-hygiene repair issue - catches those as dangling. Web-links round-trip fully. task_ids are dropped: - tasks get fresh ids on import, so the links wouldn't resolve. + catches those as dangling. Web-links round-trip fully. ``task_ids`` are + carried too (as the OLD task ids); the importer remaps them onto the fresh + task ids so a doc's task links survive a backup/restore. """ out: list[dict[str, Any]] = [] for d in doc_store.for_object(object_id): @@ -40,6 +41,8 @@ def _export_documents(doc_store: Any, object_id: str) -> list[dict[str, Any]]: "url": d.get("url"), "title": d.get("title"), "tags": d.get("tags") or [], + "task_ids": d.get("task_ids") or [], + "part_ids": d.get("part_ids") or [], } ) else: @@ -52,6 +55,8 @@ def _export_documents(doc_store: Any, object_id: str) -> list[dict[str, Any]]: "mime": d.get("mime"), "size": d.get("size"), "tags": d.get("tags") or [], + "task_ids": d.get("task_ids") or [], + "part_ids": d.get("part_ids") or [], } ) return out @@ -81,6 +86,14 @@ def _build_export_object( "name": tdata.get("name", ""), "type": tdata.get("type", "custom"), "enabled": tdata.get("enabled", True), + # Provenance + lifecycle a backup must preserve: created_at is the + # next_due fallback anchor when last_performed is None (dropping it + # silently shifts due dates on restore), and the archived_* pair + # keeps a retired task retired — without them an archived task comes + # back ACTIVE after a restore. + "created_at": tdata.get("created_at"), + "archived_at": tdata.get("archived_at"), + "archived_reason": tdata.get("archived_reason"), "schedule_type": sched["schedule_type"], "interval_days": sched["interval_days"], "interval_unit": sched["interval_unit"], @@ -115,6 +128,9 @@ def _build_export_object( "assignee_pool": tdata.get("assignee_pool") or [], "rotation_strategy": tdata.get("rotation_strategy"), "reading_unit": tdata.get("reading_unit"), + # Spare parts: consumption links + the auto-buy-task marker. + "consumes_parts": tdata.get("consumes_parts"), + "part_ref": tdata.get("part_ref"), "status": ct.get("_status", "ok"), "days_until_due": ct.get("_days_until_due"), "next_due": ct.get("_next_due"), @@ -168,20 +184,39 @@ def _build_export_object( }, "tasks": tasks, "documents": documents, + # Spare parts: full static definition + the tracked stock (dynamic, + # read from the Store) so a backup/restore keeps the shelf state. + "parts": [ + {**part, "stock": store.get_part_stock(part["id"]) if store is not None else None} + for part in (entry.data.get("parts") or {}).values() + ], } +def object_entries(hass: HomeAssistant, entry_ids: set[str] | None = None) -> list[ConfigEntry]: + """The maintenance OBJECT entries (never the global hub), optionally + narrowed to a selection. Shared by every exporter so JSON/YAML/CSV apply + the same selective-export filter. ``entry_ids=None`` means all objects.""" + return [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.unique_id != GLOBAL_UNIQUE_ID and (entry_ids is None or entry.entry_id in entry_ids) + ] + + def build_export_data( hass: HomeAssistant, include_history: bool = True, + entry_ids: set[str] | None = None, ) -> dict[str, Any]: """Gather all maintenance data into a plain dict. This must be called from the event loop (accesses HA APIs). The returned dict contains no HA objects and is safe to - serialize in an executor thread. + serialize in an executor thread. ``entry_ids`` narrows the export to a + selection of objects (None = all). """ - entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID] + entries = object_entries(hass, entry_ids) objects = [] for entry in entries: diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/adopt-problem-sensors-dialog.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/adopt-problem-sensors-dialog.test.ts new file mode 100644 index 0000000..ce128f1 --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/adopt-problem-sensors-dialog.test.ts @@ -0,0 +1,64 @@ +/** + * : the suggested-spare-part link. + * + * Pins: a candidate carrying suggested_part_id/name renders the part chip, and + * the adopt payload forwards part_id for exactly those candidates. + */ + +import { expect, fixture, html } from "@open-wc/testing"; +import "../components/adopt-problem-sensors-dialog.js"; +import type { MaintenanceAdoptProblemSensorsDialog } from "../components/adopt-problem-sensors-dialog"; +import { type SentMessage, createMockHass } from "./_test-utils.js"; + +const SENSORS = [ + { + entity_id: "binary_sensor.toner_low", name: "Toner low", state: "on", + device_id: "d1", device_name: "Printer", area_name: "Office", + suggested_entry_id: "e1", suggested_object_name: "Printer", + suggested_part_id: "part_toner", suggested_part_name: "Toner cartridge", + }, + { + entity_id: "binary_sensor.pump_problem", name: "Pump problem", state: "off", + device_id: "d2", device_name: "Pump", area_name: null, + suggested_entry_id: null, suggested_object_name: "Pump", + suggested_part_id: null, suggested_part_name: null, + }, +]; + +async function mountOpen(): Promise<{ el: MaintenanceAdoptProblemSensorsDialog; sent: SentMessage[] }> { + const { hass, sent } = createMockHass({ + handlers: { + "maintenance_supporter/problem_sensors/discover": () => ({ sensors: SENSORS }), + "maintenance_supporter/problem_sensors/adopt": () => ({ tasks_created: 2, objects_created: 1, total: 2 }), + }, + }); + const el = await fixture(html` + + `); + await el.open(); + await el.updateComplete; + return { el, sent }; +} + +describe("adopt-problem-sensors dialog: suggested part", () => { + it("renders the part chip only for candidates with a suggested part", async () => { + const { el } = await mountOpen(); + const rows = el.shadowRoot!.querySelectorAll(".row"); + expect(rows.length).to.equal(2); + expect(rows[0].querySelector(".row-part")?.textContent).to.include("Toner cartridge"); + expect(rows[1].querySelector(".row-part")).to.equal(null); + }); + + it("forwards part_id in the adopt payload for the matched candidate only", async () => { + const { el, sent } = await mountOpen(); + el.shadowRoot!.querySelectorAll("ha-button")[1].click(); // Adopt selected + await el.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + const adopt = sent.find((m) => m.type === "maintenance_supporter/problem_sensors/adopt")! as { + selections: Array<{ entity_id: string; part_id?: string }>; + }; + const byId = Object.fromEntries(adopt.selections.map((s) => [s.entity_id, s])); + expect(byId["binary_sensor.toner_low"].part_id).to.equal("part_toner"); + expect(byId["binary_sensor.pump_problem"].part_id).to.equal(undefined); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/card-saved-view.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/card-saved-view.test.ts new file mode 100644 index 0000000..f3828cd --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/card-saved-view.test.ts @@ -0,0 +1,108 @@ +/** + * Component test for the Lovelace card's saved-view scope (v2.26 — the last + * open piece of roadmap item 2). + * + * Pins: + * - `view_id` in the card config applies the view's status/user/label + * filters ON TOP of the card's own filters (AND semantics, unlike the + * panel where applying a view replaces the filter state) + * - the `current_user` sentinel resolves against hass.user client-side + * - a deleted/unknown view id degrades to "no view filter", never an + * inexplicably empty card (same fallback as backend notification routing) + */ + +import { expect, fixture, html, waitUntil } from "@open-wc/testing"; +import "../maintenance-card.js"; +import type { MaintenanceSupporterCard } from "../maintenance-card"; + +const T = ( + id: string, name: string, status: string, + extra: Record = {}, +) => ({ id, name, status, days_until_due: 1, type: "service", ...extra }); +const O = (entry_id: string, name: string, tasks: unknown[]) => ({ + entry_id, object: { id: entry_id, name }, tasks, +}); + +function mockObjects() { + return [ + O("e1", "Garden shed", [ + T("t1", "Sharpen mower blades", "overdue", { labels: ["garden"], responsible_user_id: "u1" }), + T("t2", "Oil hedge trimmer", "due_soon", { labels: ["garden"], responsible_user_id: "u2" }), + ]), + O("e2", "Kitchen", [ + T("t3", "Descale kettle", "overdue", { labels: ["kitchen"], responsible_user_id: "u1" }), + T("t4", "Clean extractor", "ok", { labels: [], responsible_user_id: null }), + ]), + ]; +} + +const VIEWS = [ + { id: "vgarden", name: "Garden", filters: { status: "", user_id: null, label: "garden", archived: false, sort_mode: "due_date", group_by: "none" } }, + { id: "vmine", name: "Mine", filters: { status: "", user_id: "current_user", label: null, archived: false, sort_mode: "due_date", group_by: "none" } }, + { id: "voverdue", name: "Overdue only", filters: { status: "overdue", user_id: null, label: null, archived: false, sort_mode: "due_date", group_by: "none" } }, +]; + +function mockHass() { + return { + language: "en", + user: { id: "u1", name: "Tester", is_admin: true, is_owner: true }, + connection: { + sendMessagePromise: async (msg: { type: string }) => { + if (msg.type === "maintenance_supporter/objects") return { objects: mockObjects() }; + if (msg.type === "maintenance_supporter/views/list") return { views: VIEWS }; + return { overdue: 0, due_soon: 0, triggered: 0, ok: 0, total: 0 }; + }, + subscribeMessage: async () => () => {}, + }, + }; +} + +async function mount(config: Record = {}): Promise { + const el = await fixture( + html`` + ); + el.setConfig({ type: "custom:maintenance-supporter-card", show_actions: false, ...config } as never); + await waitUntil( + () => el.shadowRoot!.querySelectorAll(".task-name, .empty-card").length > 0, + "card renders", + { timeout: 2000 } + ); + await el.updateComplete; + return el; +} + +const names = (el: MaintenanceSupporterCard) => + [...el.shadowRoot!.querySelectorAll(".task-name")].map((n) => n.textContent?.trim() || ""); + +describe("maintenance-card saved-view scope", () => { + it("applies the view's label filter", async () => { + const el = await mount({ view_id: "vgarden" }); + await waitUntil(() => names(el).length === 2, "view filter applied", { timeout: 2000 }); + expect(names(el)).to.deep.equal(["Sharpen mower blades", "Oil hedge trimmer"]); + }); + + it("resolves the current_user sentinel against hass.user", async () => { + const el = await mount({ view_id: "vmine" }); + await waitUntil(() => names(el).length === 2, "user filter applied", { timeout: 2000 }); + // u1 owns t1 + t3; t2 is u2's, t4 unassigned. + expect(names(el)).to.deep.equal(["Sharpen mower blades", "Descale kettle"]); + }); + + it("ANDs the view with the card's own filters", async () => { + const el = await mount({ view_id: "vgarden", filter_status: ["overdue"] }); + await waitUntil(() => names(el).length === 1, "combined filter applied", { timeout: 2000 }); + expect(names(el)).to.deep.equal(["Sharpen mower blades"]); + }); + + it("applies the view's own status filter", async () => { + const el = await mount({ view_id: "voverdue" }); + await waitUntil(() => names(el).length === 2, "status filter applied", { timeout: 2000 }); + expect(names(el)).to.deep.equal(["Sharpen mower blades", "Descale kettle"]); + }); + + it("a deleted view id degrades to no view filter, not an empty card", async () => { + const el = await mount({ view_id: "deleted_view" }); + await waitUntil(() => names(el).length === 4, "fallback shows all", { timeout: 2000 }); + expect(names(el)).to.have.length(4); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/parts-section.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/parts-section.test.ts new file mode 100644 index 0000000..3d8751d --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/parts-section.test.ts @@ -0,0 +1,92 @@ +/** + * Spare-parts section: rows render the stock badge / identifiers / storage + * location, the low state is flagged, and the add form only appears for + * writers. + */ +import { expect, fixture, html } from "@open-wc/testing"; +import "../components/parts-section"; +import type { MaintenancePartsSection } from "../components/parts-section"; +import type { MaintenancePart } from "../types"; + +const PARTS: MaintenancePart[] = [ + { + id: "p1", + name: "HEPA-Filter", + mpn: "00754869", + vendor: "Bosch", + storage_location: "Keller Regal B", + stock: 1, + reorder_threshold: 1, + is_low: true, + unit: "pcs", + shopping_url: "https://example.com/buy", + }, + { id: "p2", name: "Brush", stock: null, is_low: false }, +]; + +async function mount(canWrite: boolean): Promise { + const el = await fixture(html` + ({}) } } as never} + .entryId=${"e1"} + .parts=${PARTS} + .canWrite=${canWrite} + > + `); + await el.updateComplete; + return el; +} + +describe("parts-section", () => { + it("renders a row per part with stock badge, identifiers and location", async () => { + const el = await mount(false); + const rows = el.shadowRoot!.querySelectorAll(".part-row"); + expect(rows.length).to.equal(2); + const first = rows[0] as HTMLElement; + expect(first.classList.contains("low")).to.be.true; + expect(first.querySelector(".stock-badge")!.textContent).to.include("1"); + expect(first.querySelector(".part-meta")!.textContent).to.include("MPN: 00754869"); + expect(first.querySelector(".part-meta")!.textContent).to.include("Keller Regal B"); + // Catalog-only part (untracked stock) shows no badge. + expect((rows[1] as HTMLElement).querySelector(".stock-badge")).to.equal(null); + // Shopping link resolves on the name. + const link = first.querySelector(".part-name a") as HTMLAnchorElement; + expect(link.href).to.equal("https://example.com/buy"); + }); + + it("gates editing on canWrite", async () => { + const reader = await mount(false); + // Read-only users keep the (read-action) documents paperclip — one per + // part — but no edit/delete/restock buttons. + const readerBtns = [...reader.shadowRoot!.querySelectorAll("ha-icon-button")]; + expect(readerBtns.length).to.equal(reader.shadowRoot!.querySelectorAll(".part-row").length); + expect(readerBtns.every((b) => b.querySelector('ha-icon[icon="mdi:paperclip"]'))).to.be.true; + const writer = await mount(true); + expect(writer.shadowRoot!.querySelectorAll("ha-icon-button").length).to.be.greaterThan(0); + // Add button opens the inline form with native inputs (dialog-input trap). + (writer.shadowRoot!.querySelector(".section-head ha-button") as HTMLElement).click(); + await writer.updateComplete; + expect(writer.shadowRoot!.querySelector(".part-form")).to.not.equal(null); + expect(writer.shadowRoot!.querySelectorAll(".part-form input").length).to.be.greaterThan(5); + }); + + it("does not render a non-http(s) shopping_url as a link (XSS guard)", async () => { + const el = await fixture(html` + ({}) } } as never} + .entryId=${"e1"} + .parts=${[ + { id: "p1", name: "Evil", stock: null, is_low: false, shopping_url: "javascript:alert(1)" }, + { id: "p2", name: "Good", stock: null, is_low: false, shopping_url: "https://ok.example/x" }, + ] as MaintenancePart[]} + .canWrite=${false} + > + `); + await el.updateComplete; + const rows = el.shadowRoot!.querySelectorAll(".part-row"); + // Row 0 (javascript:) → plain text, NO anchor. Row 1 (https) → anchor. + expect(rows[0].querySelector(".part-name a"), "javascript: url must not become a link").to.equal(null); + expect(rows[0].querySelector(".part-name")!.textContent!.trim()).to.equal("Evil"); + expect(rows[1].querySelector(".part-name a"), "https url stays a link").to.not.equal(null); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/saved-views-dialog.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/saved-views-dialog.test.ts new file mode 100644 index 0000000..3865eeb --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/saved-views-dialog.test.ts @@ -0,0 +1,109 @@ +/** + * Component tests for the saved-views dialog (v2.24). + * + * Pins: + * - Save sends the CURRENT filters under the typed name and emits the + * server's returned list via `saved-views-changed`. + * - Delete sends the view id and emits the trimmed list. + * - The name input is a native (the panel-context trap). + */ + +import { expect, fixture, html } from "@open-wc/testing"; +import "../components/saved-views-dialog.js"; +import type { MaintenanceSavedViewsDialog } from "../components/saved-views-dialog"; +import type { SavedView, SavedViewFilters } from "../types"; + +function makeHass(sent: unknown[]) { + return { + language: "en", + connection: { + sendMessagePromise: async (msg: Record) => { + sent.push(msg); + if (msg.type === "maintenance_supporter/views/save") { + return { views: [{ id: "new1", name: msg.name, filters: msg.filters }] }; + } + return { views: [] }; // delete -> empty + }, + }, + }; +} + +const FILTERS: SavedViewFilters = { + status: "overdue", + user_id: "current_user", + archived: true, + sort_mode: "area", + group_by: "user", +}; + +const EXISTING: SavedView[] = [ + { id: "v1", name: "Kitchen overdue", filters: { ...FILTERS } }, +]; + +async function mount(sent: unknown[]) { + const el = await fixture(html` + + `); + await el.open(FILTERS, EXISTING); + await el.updateComplete; + return el; +} + +describe("saved-views dialog", () => { + it("lists existing views and uses a native input for the name", async () => { + const el = await mount([]); + const names = [...el.shadowRoot!.querySelectorAll(".row-name")].map((n) => n.textContent?.trim()); + expect(names).to.deep.equal(["Kitchen overdue"]); + const input = el.shadowRoot!.querySelector(".name-input"); + expect(input?.tagName).to.equal("INPUT"); + }); + + it("saves the current filters under the typed name and emits the new list", async () => { + const sent: unknown[] = []; + const el = await mount(sent); + let emitted: SavedView[] | null = null; + el.addEventListener("saved-views-changed", (e) => { + emitted = (e as CustomEvent<{ views: SavedView[] }>).detail.views; + }); + + const input = el.shadowRoot!.querySelector(".name-input")!; + input.value = "My view"; + input.dispatchEvent(new Event("input")); + await el.updateComplete; + + const saveBtn = [...el.shadowRoot!.querySelectorAll("ha-button")].find((b) => + (b.textContent || "").toLowerCase().includes("save"), + )!; + (saveBtn as HTMLElement).click(); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + + const saveMsg = (sent as Record[]).find( + (m) => m.type === "maintenance_supporter/views/save", + )!; + expect(saveMsg.name).to.equal("My view"); + expect(saveMsg.filters).to.deep.equal(FILTERS); + expect(emitted, "emits server list").to.not.be.null; + expect(emitted![0].name).to.equal("My view"); + }); + + it("deletes a view by id and emits the trimmed list", async () => { + const sent: unknown[] = []; + const el = await mount(sent); + let emitted: SavedView[] | null = null; + el.addEventListener("saved-views-changed", (e) => { + emitted = (e as CustomEvent<{ views: SavedView[] }>).detail.views; + }); + + const delBtn = el.shadowRoot!.querySelector(".row ha-icon-button")!; + delBtn.click(); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + + const delMsg = (sent as Record[]).find( + (m) => m.type === "maintenance_supporter/views/delete", + )!; + expect(delMsg.view_id).to.equal("v1"); + expect(emitted).to.deep.equal([]); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/status-badge-contrast.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/status-badge-contrast.test.ts new file mode 100644 index 0000000..6d248be --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/status-badge-contrast.test.ts @@ -0,0 +1,84 @@ +/** + * Contrast tripwire for status badges (dark-mode & a11y QA, v2.24). + * + * The roadmap claimed status colours were "routed through theme tokens with a + * tripwire blocking bare colours" — but no such test existed, and the badges + * used white text on light backgrounds (green/orange/grey) at 2.2–2.8:1, below + * the 3:1 WCAG floor for UI components. This renders each badge with the REAL + * sharedStyles (fallback hex applies with no HA theme loaded) and asserts the + * computed text-on-background contrast clears 3:1 — so reverting a light badge + * to white text fails the build. + */ + +import { expect, fixture, html } from "@open-wc/testing"; +import { LitElement, css } from "lit"; +import { customElement } from "lit/decorators.js"; +import { sharedStyles } from "../styles"; +import { panelStyles } from "../panel-styles"; + +const STATUSES = ["ok", "due_soon", "overdue", "triggered", "done", "archived", "paused"]; +// The task-detail view uses a SEPARATE `.status-chip` set in panel-styles.ts. +const CHIP_STATUSES = ["ok", "warning", "overdue", "done"]; + +@customElement("badge-contrast-probe") +class BadgeContrastProbe extends LitElement { + static styles = [sharedStyles, css`:host { display: block; }`]; + render() { + return html`${STATUSES.map( + (s) => html`${s}`, + )}`; + } +} + +@customElement("chip-contrast-probe") +class ChipContrastProbe extends LitElement { + static styles = [panelStyles, css`:host { display: block; }`]; + render() { + return html`${CHIP_STATUSES.map( + (s) => html`${s}`, + )}`; + } +} + +function parseRgb(v: string): [number, number, number] { + const m = v.match(/rgba?\(([^)]+)\)/); + if (!m) throw new Error("not rgb: " + v); + const [r, g, b] = m[1].split(",").map((x) => parseFloat(x)); + return [r, g, b]; +} +function relLum([r, g, b]: [number, number, number]): number { + const lin = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} +function contrast(fg: string, bg: string): number { + const a = relLum(parseRgb(fg)); + const b = relLum(parseRgb(bg)); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + +describe("status badge contrast (WCAG UI 3:1)", () => { + it("every status badge clears 3:1 text-on-background", async () => { + const el = await fixture(html``); + await el.updateComplete; + for (const s of STATUSES) { + const badge = el.shadowRoot!.querySelector(`.status-badge[data-s="${s}"]`)!; + const cs = getComputedStyle(badge); + const ratio = contrast(cs.color, cs.backgroundColor); + expect(ratio, `${s}: ${cs.color} on ${cs.backgroundColor} = ${ratio.toFixed(2)}:1`).to.be.greaterThan(3.0); + } + }); + + it("every task-detail status chip clears 3:1 text-on-background", async () => { + const el = await fixture(html``); + await el.updateComplete; + for (const s of CHIP_STATUSES) { + const chip = el.shadowRoot!.querySelector(`.status-chip[data-s="${s}"]`)!; + const cs = getComputedStyle(chip); + const ratio = contrast(cs.color, cs.backgroundColor); + expect(ratio, `chip ${s}: ${cs.color} on ${cs.backgroundColor} = ${ratio.toFixed(2)}:1`).to.be.greaterThan(3.0); + } + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-hint.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-hint.test.ts new file mode 100644 index 0000000..a1c8606 --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-dialog-trigger-hint.test.ts @@ -0,0 +1,101 @@ +/** + * : the live "what happens next" trigger hint. + * + * The sensor-based trigger form reads the bound entity's CURRENT state and + * spells out the semantics (a delta counter counts from the current reading, + * not from zero, and restarts after each completion). Pins: the hint renders + * with the live value + computed due point, updates per trigger type, and + * stays absent when no entity/state is available. + */ + +import { expect, fixture, html } from "@open-wc/testing"; +import "../components/task-dialog.js"; +import type { MaintenanceTaskDialog } from "../components/task-dialog"; +import { createMockHass } from "./_test-utils.js"; + +async function mountCreate(states: Record): Promise { + const { hass } = createMockHass({ states }); + const el = await fixture(html` + + `); + el.openCreate("e1", []); + await el.updateComplete; + (el as any)._scheduleType = "sensor_based"; + await el.updateComplete; + return el; +} + +const hint = (el: MaintenanceTaskDialog): string | null => + el.shadowRoot!.querySelector(".trigger-live-hint")?.textContent?.trim() ?? null; + +describe("task-dialog live trigger hint", () => { + it("delta counter: shows current reading, computed due point, and restart semantics", async () => { + const el = await mountCreate({ + "sensor.pump_hours": { state: "660", attributes: { unit_of_measurement: "h" } }, + }); + (el as any)._triggerEntityId = "sensor.pump_hours"; + (el as any)._triggerEntityIds = ["sensor.pump_hours"]; + (el as any)._triggerType = "counter"; + (el as any)._triggerDeltaMode = true; + (el as any)._triggerTargetValue = "100"; + await el.updateComplete; + const text = hint(el)!; + expect(text, "hint rendered").to.not.equal(null); + expect(text).to.include("660 h"); // current reading + expect(text).to.include("760 h"); // computed due point (660 + 100) + }); + + it("threshold: shows current value and the above-target", async () => { + const el = await mountCreate({ + "sensor.pressure": { state: "1.2", attributes: { unit_of_measurement: "bar" } }, + }); + (el as any)._triggerEntityId = "sensor.pressure"; + (el as any)._triggerEntityIds = ["sensor.pressure"]; + (el as any)._triggerType = "threshold"; + (el as any)._triggerAbove = "1.5"; + await el.updateComplete; + const text = hint(el)!; + expect(text).to.include("1.2 bar"); + expect(text).to.include("1.5 bar"); + }); + + it("renders nothing without a bound entity or without targets", async () => { + const el = await mountCreate({ + "sensor.pressure": { state: "1.2", attributes: {} }, + }); + (el as any)._triggerType = "threshold"; + (el as any)._triggerAbove = "1.5"; // target set, but NO entity + await el.updateComplete; + expect(hint(el)).to.equal(null); + + (el as any)._triggerEntityId = "sensor.pressure"; + (el as any)._triggerEntityIds = ["sensor.pressure"]; + (el as any)._triggerAbove = ""; // entity set, but no target + await el.updateComplete; + expect(hint(el)).to.equal(null); + }); + + it("editing a delta task uses the since-last-completion wording (baseline is not the current reading)", async () => { + const { hass } = createMockHass({ + states: { "sensor.pump_hours": { state: "660", attributes: { unit_of_measurement: "h" } } }, + }); + const el = await fixture(html` + + `); + await el.openEdit("e1", { + id: "t1", name: "Service", type: "custom", + schedule_type: "sensor_based", warning_days: 7, enabled: true, + trigger_config: { + type: "counter", entity_id: "sensor.pump_hours", entity_ids: ["sensor.pump_hours"], + trigger_target_value: 100, trigger_delta_mode: true, + }, + } as any); + await el.updateComplete; + const text = hint(el)!; + expect(text, "hint rendered in edit mode").to.not.equal(null); + // Must NOT claim the count starts at the current reading (the stored + // baseline is the last completion, not "now") — no computed 760 h. + expect(text).to.not.include("760"); + expect(text).to.include("100 h"); + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts b/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts index 22510a0..8456dae 100644 --- a/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts +++ b/custom_components/maintenance_supporter/frontend-src/__tests__/task-documents.test.ts @@ -123,3 +123,48 @@ describe("task-documents", () => { } }); }); + +// ── part mode (v2.26): same component, linking via part_ids ───────────────── + +const P_LINKED = { id: "d1", kind: "file", title: "Datasheet", filename: "d.pdf", mime: "application/pdf", size: 100, tags: [], task_ids: [], part_ids: ["p1"] }; +const P_AVAIL = { id: "d2", kind: "weblink", title: "Spec page", url: "https://x/spec", tags: [], task_ids: [], part_ids: [] }; + +async function mountPart(docs: unknown[] = [P_LINKED, P_AVAIL]) { + const { hass, sent } = createMockHass({ + handlers: { + "maintenance_supporter/documents/list": () => ({ documents: docs }), + "maintenance_supporter/documents/update": () => ({ id: "d1" }), + }, + }); + const el = await fixture(html` + + `); + await new Promise((r) => setTimeout(r, 30)); + await el.updateComplete; + return { el, sent }; +} + +describe("task-documents in part mode", () => { + it("filters by part_ids and links via part_ids (never task_ids)", async () => { + const { el, sent } = await mountPart(); + const rows = el.shadowRoot!.querySelectorAll(".tdoc-row"); + expect(rows.length).to.equal(1); + expect(rows[0].querySelector(".tdoc-title")!.textContent).to.contain("Datasheet"); + + const select = el.shadowRoot!.querySelector(".tdoc-select")!; + select.value = "d2"; + select.dispatchEvent(new Event("change")); + await el.updateComplete; + el.shadowRoot!.querySelector(".tdoc-btn")!.click(); + await el.updateComplete; + const msg = sent.find((m) => m.type === "maintenance_supporter/documents/update" && m.doc_id === "d2"); + expect(msg, "link WS sent").to.exist; + expect(msg!.part_ids).to.deep.equal(["p1"]); + expect(msg!.task_ids, "task_ids untouched in part mode").to.be.undefined; + }); + + it("offers no per-task page input for a linked PDF in part mode", async () => { + const { el } = await mountPart([P_LINKED]); + expect(el.shadowRoot!.querySelector(".tdoc-page")).to.not.exist; + }); +}); diff --git a/custom_components/maintenance_supporter/frontend-src/calendar-styles.ts b/custom_components/maintenance_supporter/frontend-src/calendar-styles.ts index c1be70e..051a483 100644 --- a/custom_components/maintenance_supporter/frontend-src/calendar-styles.ts +++ b/custom_components/maintenance_supporter/frontend-src/calendar-styles.ts @@ -189,10 +189,14 @@ export const calendarStyles = css` letter-spacing: 0.4px; color: #fff; } - .cal-status-overdue { background: #d32f2f; } - .cal-status-triggered { background: #038fc7; } - .cal-status-due_soon { background: #f9a825; color: #000; } - .cal-status-ok { background: #2e7d32; } + /* Same tokens as .status-badge (status-constants.ts) — the calendar used + to keep its own palette (triggered was even BLUE here) so identical + statuses wore different colors per view, and none followed the theme. */ + .cal-status-overdue { background: var(--error-color, #f44336); } + .cal-status-triggered { background: var(--deep-orange-color, #ff5722); } + .cal-status-due_soon { background: var(--warning-color, #ff9800); color: #000; } + /* Dark text — white on green is only 2.8:1 (below the 3:1 UI floor). */ + .cal-status-ok { background: var(--success-color, #4caf50); color: #000; } @media (max-width: 600px) { .cal-controls { padding: 10px 12px; } diff --git a/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts new file mode 100644 index 0000000..7796bf6 --- /dev/null +++ b/custom_components/maintenance_supporter/frontend-src/components/adopt-problem-sensors-dialog.ts @@ -0,0 +1,358 @@ +/** Dialog to discover HA "problem" sensors and adopt them as maintenance tasks. + * + * Lists binary/problem sensors that aren't already tracked, preselects them all, + * and turns each into a maintenance task that triggers while the problem is + * active and clears when it resolves. A selection either attaches to a suggested + * existing object or spins up a fresh object bound to the sensor's device. + */ + +import { css, html, LitElement, nothing } from "lit"; +import { property, state } from "lit/decorators.js"; + +import { t, ensureLocale } from "../styles"; +import { describeWsError } from "../ws-errors"; +import type { HomeAssistant } from "../types"; + +interface ProblemSensor { + entity_id: string; + name: string; + state: string; + device_id: string | null; + device_name: string | null; + area_name: string | null; + suggested_entry_id: string | null; + suggested_object_name: string; + suggested_part_id: string | null; + suggested_part_name: string | null; +} + +interface DiscoverResponse { + sensors: ProblemSensor[]; +} + +interface AdoptResponse { + tasks_created: number; + objects_created: number; + total: number; + errors?: string[]; +} + +export class MaintenanceAdoptProblemSensorsDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _open = false; + @state() private _loading = false; + @state() private _adopting = false; + @state() private _error = ""; + @state() private _sensors: ProblemSensor[] = []; + @state() private _selected: Set = new Set(); + + private _localeReady = false; + + private get _lang(): string { + return this.hass?.language || "en"; + } + + updated(changed: Map): void { + if (changed.has("hass") && this.hass && !this._localeReady) { + this._localeReady = true; + ensureLocale(this._lang).then(() => this.requestUpdate()); + } + } + + public async open(): Promise { + this._open = true; + this._loading = true; + this._error = ""; + this._sensors = []; + this._selected = new Set(); + try { + const resp = await this.hass.connection.sendMessagePromise({ + type: "maintenance_supporter/problem_sensors/discover", + }); + this._sensors = resp.sensors || []; + this._selected = new Set(this._sensors.map((s) => s.entity_id)); + } catch (e) { + this._error = describeWsError(e, this._lang); + } finally { + this._loading = false; + } + } + + private _close(): void { + this._open = false; + } + + private _toggle = (entityId: string): void => { + const next = new Set(this._selected); + if (next.has(entityId)) next.delete(entityId); + else next.add(entityId); + this._selected = next; + }; + + private _toggleAll = (): void => { + if (this._selected.size === this._sensors.length) { + this._selected = new Set(); + } else { + this._selected = new Set(this._sensors.map((s) => s.entity_id)); + } + }; + + private _adopt = async (): Promise => { + if (this._selected.size === 0 || this._adopting) return; + this._adopting = true; + this._error = ""; + try { + const selections = this._sensors + .filter((s) => this._selected.has(s.entity_id)) + .map((s) => ({ + entity_id: s.entity_id, + name: s.name, + entry_id: s.suggested_entry_id ?? undefined, + object_name: s.suggested_object_name, + device_id: s.device_id ?? undefined, + part_id: s.suggested_part_id ?? undefined, + })); + const result = await this.hass.connection.sendMessagePromise({ + type: "maintenance_supporter/problem_sensors/adopt", + selections, + }); + this.dispatchEvent( + new CustomEvent("problem-sensors-adopted", { + bubbles: true, + composed: true, + detail: result, + }), + ); + this._open = false; + } catch (e) { + this._error = describeWsError(e, this._lang); + } finally { + this._adopting = false; + } + }; + + render() { + if (!this._open) return html``; + const L = this._lang; + const allSelected = + this._sensors.length > 0 && this._selected.size === this._sensors.length; + + return html` +
+
e.stopPropagation()}> +
${t("adopt_problem_title", L)}
+
${t("adopt_problem_hint", L)}
+ ${this._error ? html`
${this._error}
` : nothing} + + ${this._loading + ? html`
` + : this._sensors.length === 0 + ? html`
${t("adopt_problem_none", L)}
` + : html` + +
+ ${this._sensors.map((s) => { + const checked = this._selected.has(s.entity_id); + const active = s.state === "on"; + const sub = [s.device_name, s.area_name] + .filter(Boolean) + .join(" · "); + return html` + + `; + })} +
+ `} + +
+ + ${t("cancel", L)} + + + ${t("adopt_problem_adopt", L)} + +
+
+
+ `; + } + + static styles = css` + .overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + .card { + background: var(--card-background-color, #fff); + color: var(--primary-text-color); + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 360px; + max-width: 560px; + width: 90vw; + max-height: 80vh; + overflow: hidden; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + } + .title { + font-size: 18px; + font-weight: 500; + } + .hint { + color: var(--secondary-text-color); + font-size: 13px; + } + .error { + color: var(--error-color, #f44336); + font-size: 13px; + } + .loading, + .empty { + color: var(--secondary-text-color); + font-size: 14px; + padding: 12px 0; + } + .select-all { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--secondary-text-color); + cursor: pointer; + } + .select-all input { + cursor: pointer; + } + .list { + display: flex; + flex-direction: column; + gap: 6px; + overflow-y: auto; + max-height: 50vh; + } + .row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px; + border: 1px solid var(--divider-color); + border-radius: 6px; + cursor: pointer; + } + .row input { + margin-top: 2px; + cursor: pointer; + } + .row-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; + } + .row-top { + display: flex; + align-items: center; + gap: 8px; + } + .row-name { + font-weight: 500; + font-size: 13px; + } + .row-sub { + color: var(--secondary-text-color); + font-size: 12px; + } + .row-target { + color: var(--secondary-text-color); + font-size: 12px; + } + .row-part { + color: var(--secondary-text-color); + font-size: 12px; + display: flex; + align-items: center; + gap: 4px; + } + .row-part ha-icon { + --mdc-icon-size: 14px; + } + .new-tag { + font-style: italic; + } + .chip { + font-size: 11px; + padding: 1px 8px; + border-radius: 10px; + white-space: nowrap; + } + .chip-active { + background: var(--error-color, #f44336); + color: #fff; + } + .chip-ok { + background: var(--divider-color); + color: var(--secondary-text-color); + } + .actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + } + `; +} + +if (!customElements.get("maintenance-adopt-problem-sensors-dialog")) { + customElements.define( + "maintenance-adopt-problem-sensors-dialog", + MaintenanceAdoptProblemSensorsDialog, + ); +} diff --git a/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts b/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts index 4d75362..0d0d399 100644 --- a/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts +++ b/custom_components/maintenance_supporter/frontend-src/components/complete-dialog.ts @@ -3,7 +3,7 @@ import { LitElement, html, css, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import type { HomeAssistant } from "../types"; -import { t } from "../styles"; +import { t, nativeFieldStyles } from "../styles"; import { describeWsError } from "../ws-errors"; export class MaintenanceCompleteDialog extends LitElement { @@ -17,6 +17,10 @@ export class MaintenanceCompleteDialog extends LitElement { // v2.20 (#83): task type + unit drive the reading-value field below. @property() public taskType = ""; @property() public readingUnit = ""; + /** Buy task (part_ref): default restock quantity — shows an editable qty field. */ + @property({ attribute: false }) public restockDefault: number | null = null; + /** "Consumes: 1× HEPA-Filter (Shelf B)" hint lines for consuming tasks. */ + @property({ type: Array }) public consumesInfo: string[] = []; @state() private _open = false; @state() private _notes = ""; @state() private _cost = ""; @@ -29,6 +33,7 @@ export class MaintenanceCompleteDialog extends LitElement { @state() private _photoPreview = ""; @state() private _photoUploading = false; @state() private _readingValue = ""; + @state() private _restockQty = ""; public open(): void { if (this._open) return; @@ -43,6 +48,7 @@ export class MaintenanceCompleteDialog extends LitElement { this._photoPreview = ""; this._photoUploading = false; this._readingValue = ""; + this._restockQty = this.restockDefault !== null ? String(this.restockDefault) : ""; } private _toggleCheck(idx: number): void { @@ -129,6 +135,10 @@ export class MaintenanceCompleteDialog extends LitElement { const rv = parseFloat(this._readingValue); if (!isNaN(rv)) data.reading_value = rv; } + if (this.restockDefault !== null && this._restockQty !== "") { + const rq = parseInt(this._restockQty, 10); + if (!isNaN(rq) && rq >= 1) data.restock_quantity = rq; + } await this.hass.connection.sendMessagePromise(data); this._open = false; this.dispatchEvent(new CustomEvent("task-completed")); @@ -171,6 +181,20 @@ export class MaintenanceCompleteDialog extends LitElement { @input=${(e: Event) => (this._readingValue = (e.target as HTMLInputElement).value)} /> ` : nothing} + ${this.consumesInfo.length + ? html`
+ ${this.consumesInfo.map((line) => html`
${line}
`)} +
` + : nothing} + ${this.restockDefault !== null + ? html` + ` + : nothing} + ${this._views.length > 0 + ? html` +
+
${t("card_saved_view", L)}
+ +
${t("card_saved_view_help", L)}
+
+ ` + : nothing} + void) | null = null; + @state() private _viewFilters: SavedViewFilters | null = null; private get _lang(): string { return this.hass?.language || "en"; @@ -54,7 +57,13 @@ export class MaintenanceSupporterCard extends LitElement { } setConfig(config: CardConfig): void { + const viewChanged = config.view_id !== this._config.view_id; this._config = config; + // Config can change after the initial load (editor preview) — re-resolve + // the referenced view then; the initial resolve happens in _loadData. + if (viewChanged && this._dataLoaded && this.hass) { + this._loadViewFilters(); + } } getCardSize(): number { @@ -113,6 +122,26 @@ export class MaintenanceSupporterCard extends LitElement { } catch { // WS not available yet } + await this._loadViewFilters(); + } + + /** Resolve the configured saved view's filters (best-effort). A missing or + * deleted view degrades to "no view filter" — same fallback semantics as + * the backend's notification routing, never an inexplicably empty card. */ + private async _loadViewFilters(): Promise { + if (!this._config.view_id) { + this._viewFilters = null; + return; + } + try { + const res = await this.hass.connection.sendMessagePromise({ + type: "maintenance_supporter/views/list", + }) as { views: SavedView[] }; + const view = (res.views || []).find((v) => v.id === this._config.view_id); + this._viewFilters = view ? view.filters : null; + } catch { + this._viewFilters = null; + } } private async _subscribe(): Promise { @@ -148,6 +177,14 @@ export class MaintenanceSupporterCard extends LitElement { const entityFilter = entity_ids?.length ? new Set(entity_ids) : null; const hasDueRange = filter_due_min_days !== undefined || filter_due_max_days !== undefined; + // Saved-view scope: unlike the panel (where applying a view REPLACES the + // filter state), the card ANDs the view's task-selecting filters with its + // own config — the card's filters are static YAML, not transient UI state. + // The view's `current_user` sentinel resolves against the logged-in user + // here (client-side), which the backend notification routing cannot do. + const vf = this._viewFilters; + const viewUser = + vf?.user_id === "current_user" ? (this.hass.user?.id ?? null) : (vf?.user_id ?? null); for (const obj of this._objects) { if (filter_objects?.length && !filter_objects.includes(obj.object.name)) continue; @@ -175,6 +212,11 @@ export class MaintenanceSupporterCard extends LitElement { if (filter_due_min_days !== undefined && days < filter_due_min_days) continue; if (filter_due_max_days !== undefined && days > filter_due_max_days) continue; } + if (vf) { + if (vf.status && task.status !== vf.status) continue; + if (vf.label && !(task.labels || []).includes(vf.label)) continue; + if (viewUser && task.responsible_user_id !== viewUser) continue; + } tasks.push({ entry_id: obj.entry_id, object_name: obj.object.name, task }); } } diff --git a/custom_components/maintenance_supporter/frontend-src/maintenance-dashboard-strategy.ts b/custom_components/maintenance_supporter/frontend-src/maintenance-dashboard-strategy.ts index 6c3bb53..6e3d99a 100644 --- a/custom_components/maintenance_supporter/frontend-src/maintenance-dashboard-strategy.ts +++ b/custom_components/maintenance_supporter/frontend-src/maintenance-dashboard-strategy.ts @@ -30,6 +30,8 @@ * On older HA versions the registration is a silent no-op. */ +import { STATUS_ICONS } from "./status-constants"; + interface MaintenanceObjectResp { entry_id: string; object: { @@ -128,10 +130,13 @@ const STATUS_VIEWS: Array<{ icon: string; path: string; }> = [ - { status: "overdue", title: "Overdue", icon: "mdi:alert-circle", path: "overdue" }, - { status: "triggered", title: "Triggered", icon: "mdi:flash", path: "triggered" }, - { status: "due_soon", title: "Due Soon", icon: "mdi:clock-alert-outline", path: "due-soon" }, - { status: "ok", title: "OK", icon: "mdi:check-circle-outline", path: "ok" }, + // Icons come from the shared STATUS_ICONS so the generated dashboard + // matches the panel (this table used to hardcode its own set — "Overdue" + // wore the panel's due-soon icon). + { status: "overdue", title: "Overdue", icon: STATUS_ICONS.overdue, path: "overdue" }, + { status: "triggered", title: "Triggered", icon: STATUS_ICONS.triggered, path: "triggered" }, + { status: "due_soon", title: "Due Soon", icon: STATUS_ICONS.due_soon, path: "due-soon" }, + { status: "ok", title: "OK", icon: STATUS_ICONS.ok, path: "ok" }, ]; const DUE_DATE_VIEWS: Array<{ diff --git a/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts b/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts index e37e505..bf710fa 100644 --- a/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts +++ b/custom_components/maintenance_supporter/frontend-src/maintenance-panel.ts @@ -1,8 +1,10 @@ /** Maintenance Supporter Sidebar Panel. */ import { LitElement, html, nothing } from "lit"; +import { isSafeHttpUrl } from "./helpers/url"; import { customElement, property, state } from "lit/decorators.js"; import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence } from "./styles"; +import { LS_KEYS } from "./helpers/storage-keys"; import { daysProgress } from "./helpers/interval"; import { buildObjectReportHtml, type ReportLabels } from "./helpers/report"; import { warrantyStatus } from "./helpers/warranty"; @@ -23,12 +25,15 @@ import type { HistoryEntry, TriggerConfig, StatisticsPoint, + SavedView, + SavedViewFilters, } from "./types"; import { StatisticsService } from "./statistics-service"; import { UserService } from "./user-service"; import "./components/object-dialog"; import type { MaintenanceObjectDialog } from "./components/object-dialog"; import "./components/documents-section"; +import "./components/parts-section"; import "./components/task-documents"; import "./components/task-dialog"; import type { MaintenanceTaskDialog } from "./components/task-dialog"; @@ -36,6 +41,8 @@ import "./components/complete-dialog"; import type { MaintenanceCompleteDialog } from "./components/complete-dialog"; import "./components/qr-dialog"; import type { MaintenanceQrDialog } from "./components/qr-dialog"; +import "./components/adopt-problem-sensors-dialog"; +import type { MaintenanceAdoptProblemSensorsDialog } from "./components/adopt-problem-sensors-dialog"; // v2.0.0: panel uses the extracted Calendar Card instead of its own // _renderCalendar() method — single source of truth for the calendar view. import "./maintenance-calendar-card"; @@ -53,6 +60,8 @@ import "./components/seasonal-overrides-dialog"; import type { SeasonalOverridesDialog } from "./components/seasonal-overrides-dialog"; import "./components/group-dialog"; import type { MaintenanceGroupDialog } from "./components/group-dialog"; +import "./components/saved-views-dialog"; +import type { MaintenanceSavedViewsDialog } from "./components/saved-views-dialog"; import { type SparklineContext } from "./renderers/sparkline"; import { buildCalendarBuckets, isoDateLocal, type CalendarEvent } from "./helpers/calendar-bucket"; import { renderTriggerProgress, renderMiniSparkline } from "./renderers/progress"; @@ -84,17 +93,22 @@ export class MaintenanceSupporterPanel extends LitElement { @state() private _selectedTaskId: string | null = null; @state() private _filterStatus = ""; @state() private _filterUser: string | null = null; + @state() private _filterLabel: string | null = null; + // v2.24: shared saved filter views + the id of the one currently applied ("" = + // none; cleared the moment the user hand-edits any filter control). + @state() private _savedViews: SavedView[] = []; + @state() private _activeViewId = ""; @state() private _unsub: (() => void) | null = null; @state() private _chartRangeDays = (() => { try { - const v = parseInt(localStorage.getItem("msp-chart-range") || "", 10); + const v = parseInt(localStorage.getItem(LS_KEYS.chartRange) || "", 10); return [7, 30, 90, 365].includes(v) ? v : 30; } catch { return 30; } })(); @state() private _hideOutliers = (() => { - try { return localStorage.getItem("msp-chart-hide-outliers") === "1"; } catch { return false; } + try { return localStorage.getItem(LS_KEYS.chartHideOutliers) === "1"; } catch { return false; } })(); @state() private _historyFilter: string | null = null; @state() private _budget: BudgetStatus | null = null; @@ -121,7 +135,7 @@ export class MaintenanceSupporterPanel extends LitElement { // Dashboard redesign state @state() private _overviewTab: "today" | "dashboard" | "calendar" | "settings" = (() => { try { - const v = localStorage.getItem("msp-overview-tab"); + const v = localStorage.getItem(LS_KEYS.overviewTab); return v === "today" || v === "calendar" ? v : "dashboard"; } catch { return "dashboard"; } })(); @@ -153,7 +167,7 @@ export class MaintenanceSupporterPanel extends LitElement { // v2.15.0: collapsed analysis sections on the task-detail overview tab, // remembered per section across visits. @state() private _collapsedSections: Set = (() => { - try { return new Set(JSON.parse(localStorage.getItem("msp-collapsed-sections") || "[]")); } + try { return new Set(JSON.parse(localStorage.getItem(LS_KEYS.collapsedSections) || "[]")); } catch { return new Set(); } })(); // v2.15.0: command palette ("/" since 2.18.1 — Ctrl+K clashed with HA's own @@ -208,21 +222,29 @@ export class MaintenanceSupporterPanel extends LitElement { window.addEventListener("popstate", this._popstateHandler); window.addEventListener("keydown", this._paletteKeydown); window.addEventListener("resize", this._onVirtualScroll, { passive: true }); - const saved = localStorage.getItem("maintenance_supporter_sort"); - if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) { - this._sortMode = saved as SortMode; - } - const savedObj = localStorage.getItem("maintenance_supporter_object_sort"); - if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) { - this._objectSortMode = savedObj as ObjectSortMode; - } - const savedGroup = localStorage.getItem("maintenance_supporter_groupby"); - if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) { - this._groupByMode = savedGroup as GroupByMode; - } - const savedView = localStorage.getItem("maintenance_supporter_object_view"); - if (savedView === "cards" || savedView === "table") { - this._objectViewMode = savedView; + // localStorage.getItem can THROW (Safari private mode / locked-down policies + // where storage access raises rather than returning null) — an unguarded + // read here would abort connectedCallback and leave the panel blank. Every + // other storage access in this file is wrapped; wrap these too. + try { + const saved = localStorage.getItem(LS_KEYS.taskSort); + if (saved && ["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(saved)) { + this._sortMode = saved as SortMode; + } + const savedObj = localStorage.getItem(LS_KEYS.objectSort); + if (savedObj && ["alphabetical", "due_soonest", "task_count"].includes(savedObj)) { + this._objectSortMode = savedObj as ObjectSortMode; + } + const savedGroup = localStorage.getItem(LS_KEYS.groupBy); + if (savedGroup && ["none", "area", "group", "user"].includes(savedGroup)) { + this._groupByMode = savedGroup as GroupByMode; + } + const savedView = localStorage.getItem(LS_KEYS.objectView); + if (savedView === "cards" || savedView === "table") { + this._objectViewMode = savedView; + } + } catch { + // storage blocked — keep the defaults } } @@ -334,13 +356,15 @@ export class MaintenanceSupporterPanel extends LitElement { } private async _loadData(): Promise { - const [objResult, statsResult, budgetResult, groupsResult, settingsResult] = await Promise.all([ + const [objResult, statsResult, budgetResult, groupsResult, settingsResult, viewsResult] = 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 (viewsResult) this._savedViews = (viewsResult as { views: SavedView[] }).views || []; if (objResult) this._objects = (objResult as { objects: MaintenanceObjectResponse[] }).objects; if (statsResult) this._stats = statsResult as StatisticsResponse; if (budgetResult) this._budget = budgetResult as BudgetStatus; @@ -479,7 +503,7 @@ export class MaintenanceSupporterPanel extends LitElement { private _setChartRange(days: number): void { if (days === this._chartRangeDays) return; this._chartRangeDays = days; - try { localStorage.setItem("msp-chart-range", String(days)); } catch { /* private mode */ } + try { localStorage.setItem(LS_KEYS.chartRange, String(days)); } catch { /* private mode */ } const task = this._selectedEntryId && this._selectedTaskId ? this._getTask(this._selectedEntryId, this._selectedTaskId) : null; @@ -498,7 +522,7 @@ export class MaintenanceSupporterPanel extends LitElement { // Outlier filtering is client-side on the already-fetched series, so just // flip the flag and let renderChart re-filter — no re-fetch needed. this._hideOutliers = hide; - try { localStorage.setItem("msp-chart-hide-outliers", hide ? "1" : "0"); } catch { /* private mode */ } + try { localStorage.setItem(LS_KEYS.chartHideOutliers, hide ? "1" : "0"); } catch { /* private mode */ } } private async _fetchMiniStatsForOverview(): Promise { @@ -557,6 +581,9 @@ export class MaintenanceSupporterPanel extends LitElement { if (task.responsible_user_id !== userId) continue; } + // Label filter (v2.26 — also captured by saved views) + if (this._filterLabel && !(task.labels || []).includes(this._filterLabel)) continue; + // Collect groups that contain this task const groupNames: string[] = []; for (const group of Object.values(this._groups)) { @@ -695,6 +722,7 @@ export class MaintenanceSupporterPanel extends LitElement { * the task list. Empty string clears the filter (used by "Tasks" KPI). */ private _filterByStatus(status: string): void { this._filterStatus = status; + this._activeViewId = ""; // Make sure we're on the dashboard tab so the task list is actually // visible — pointless to filter on the calendar/settings tabs. if (this._overviewTab !== "dashboard") { @@ -703,6 +731,75 @@ export class MaintenanceSupporterPanel extends LitElement { this._scrollContentToTop(); } + // ── v2.24: saved filter views ────────────────────────────────────────────── + + /** Distinct labels across all loaded tasks (for the label-filter dropdown). */ + private get _allLabels(): string[] { + const seen = new Set(); + for (const obj of this._objects) { + for (const task of obj.tasks) { + for (const lb of task.labels || []) seen.add(lb); + } + } + return [...seen].sort((a, b) => a.localeCompare(b)); + } + + /** The panel's current task-list filter state, in the shape a view stores. */ + private get _currentFilters(): SavedViewFilters { + return { + status: this._filterStatus, + user_id: this._filterUser, + label: this._filterLabel, + archived: this._showArchived, + sort_mode: this._sortMode, + group_by: this._groupByMode, + }; + } + + /** Apply a saved view's filters to the live controls (or clear on ""). */ + private _applyView(viewId: string): void { + this._activeViewId = viewId; + if (!viewId) return; + const view = this._savedViews.find((v) => v.id === viewId); + if (!view) return; + const f = view.filters; + this._filterStatus = f.status || ""; + this._filterUser = f.user_id || null; + this._filterLabel = f.label || null; + this._showArchived = !!f.archived; + // Validate against the current enums before assigning — a view saved by an + // older/renamed build could carry a mode no longer valid, which would then + // be persisted to localStorage and silently break sorting/grouping. + if (["due_date", "object", "type", "task_name", "area", "assigned_user", "group"].includes(f.sort_mode)) { + this._sortMode = f.sort_mode as SortMode; + } + if (["none", "area", "group", "user"].includes(f.group_by)) { + this._groupByMode = f.group_by as GroupByMode; + } + // Persist sort/group like the manual controls do, so they stick after reload. + try { + localStorage.setItem(LS_KEYS.taskSort, this._sortMode); + localStorage.setItem(LS_KEYS.groupBy, this._groupByMode); + } catch { + // ignore private-mode storage errors + } + if (this._overviewTab !== "dashboard") this._overviewTab = "dashboard"; + } + + private _openSavedViewsDialog(): void { + this.shadowRoot! + .querySelector("maintenance-saved-views-dialog") + ?.open(this._currentFilters, this._savedViews); + } + + private _onSavedViewsChanged(e: CustomEvent<{ views: SavedView[] }>): void { + this._savedViews = e.detail.views || []; + // If the applied view was deleted, drop the stale selection. + if (this._activeViewId && !this._savedViews.some((v) => v.id === this._activeViewId)) { + this._activeViewId = ""; + } + } + /** Reset scroll on the panel's .content container. Used by every * navigation action so the user always lands at the top of the new * view, never at a stale scroll position from the previous view. @@ -880,6 +977,20 @@ export class MaintenanceSupporterPanel extends LitElement { `; } + // --- Adopt problem sensors --- + + private _openAdoptProblemSensors(): void { + this.shadowRoot! + .querySelector("maintenance-adopt-problem-sensors-dialog") + ?.open(); + } + + private _onProblemSensorsAdopted(e: CustomEvent): void { + const tasks = e.detail?.tasks_created ?? 0; + this._showToast(t("adopt_problem_done", this._lang).replace("{tasks}", String(tasks))); + this._loadData(); + } + // --- Template gallery --- private async _openTemplateGallery(): Promise { @@ -1545,7 +1656,19 @@ export class MaintenanceSupporterPanel extends LitElement { never: t("worksheet_never", L), typeLabel: (ty: string) => t(ty, L), statusLabel: (st: string) => t(st, L), + parts: t("consumes_parts_label", L), }; + // Required parts as checkable lines: qty × name (stock unit) — location. + const wsParts = obj.parts || []; + const partsLines = (task.consumes_parts || []) + .map((link) => { + const pt = wsParts.find((x) => x.id === link.part_id); + if (!pt) return ""; + const stock = pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""; + const loc = pt.storage_location ? ` — ${pt.storage_location}` : ""; + return `${link.quantity}× ${pt.name}${stock}${loc}`; + }) + .filter(Boolean); const html = buildTaskWorksheetHtml( task, obj.object.name, labels, (iso) => formatDate(iso, L), @@ -1554,6 +1677,7 @@ export class MaintenanceSupporterPanel extends LitElement { qrComplete?.svg_data_uri || null, excerpt, new Date().toISOString(), + partsLines, ); const url = URL.createObjectURL(new Blob([html], { type: "text/html" })); window.open(url, "_blank"); @@ -1579,6 +1703,21 @@ export class MaintenanceSupporterPanel extends LitElement { ?.tasks.find((tsk) => tsk.id === taskId); dlg.taskType = tk?.type || ""; dlg.readingUnit = tk?.reading_unit || ""; + // Spare parts: a buy task gets an editable restock-qty field; a consuming + // task shows what it will decrement (incl. the storage location). + const objParts = this._objects.find((o) => o.entry_id === entryId)?.parts || []; + const partById = new Map(objParts.map((pt) => [pt.id, pt])); + const refPart = tk?.part_ref ? partById.get(tk.part_ref.part_id) : undefined; + dlg.restockDefault = tk?.part_ref ? (refPart?.restock_quantity ?? 1) : null; + dlg.consumesInfo = (tk?.consumes_parts || []) + .map((link) => { + const pt = partById.get(link.part_id); + if (!pt) return ""; + const loc = pt.storage_location ? ` — ${pt.storage_location}` : ""; + const stock = pt.stock !== null && pt.stock !== undefined ? ` (${pt.stock}${pt.unit ? " " + pt.unit : ""})` : ""; + return `${link.quantity}× ${pt.name}${stock}${loc}`; + }) + .filter(Boolean); dlg.open(); } @@ -1649,6 +1788,14 @@ export class MaintenanceSupporterPanel extends LitElement { .objects=${this._objects} @group-saved=${this._onDialogEvent} > + this._onProblemSensorsAdopted(e)} + > + ) => this._onSavedViewsChanged(e)} + > ${this._toastMessage ? html`
${this._toastMessage} ${this._toastUndo ? html`` : nothing} @@ -1828,7 +1975,7 @@ export class MaintenanceSupporterPanel extends LitElement { private _setOverviewTab(tab: "today" | "dashboard" | "calendar" | "settings"): void { this._overviewTab = tab; - try { localStorage.setItem("msp-overview-tab", tab); } catch { /* private mode */ } + try { localStorage.setItem(LS_KEYS.overviewTab, tab); } catch { /* private mode */ } this._scrollContentToTop(); } @@ -1901,11 +2048,32 @@ export class MaintenanceSupporterPanel extends LitElement { ${this._features.budget ? this._renderBudgetBar() : nothing}
+ + ${!isOperator ? html` + this._openSavedViewsDialog()} + > + ` : nothing} + ${this._allLabels.length > 0 ? html` + + ` : nothing}
@@ -1420,7 +1440,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" id="chips" type="checkbox" .checked=${o} - @change=${s=>this._valueChanged("show_window_chips",s.target.checked)} + @change=${i=>this._valueChanged("show_window_chips",i.target.checked)} />
@@ -1433,14 +1453,14 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" id="userf" type="checkbox" .checked=${r} - @change=${s=>this._valueChanged("show_user_filter",s.target.checked)} + @change=${i=>this._valueChanged("show_user_filter",i.target.checked)} />
- `}};O.styles=S` + `}};O.styles=x` :host { display: block; padding: 8px 0; } .editor { display: flex; flex-direction: column; gap: 12px; } .row { display: flex; flex-direction: column; gap: 4px; } @@ -1473,7 +1493,7 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" font-size: 12px; color: var(--secondary-text-color, #666); } - `,v([V({attribute:!1})],O.prototype,"hass",2),v([k()],O.prototype,"_config",2);customElements.get("maintenance-supporter-calendar-card")||customElements.define("maintenance-supporter-calendar-card",b);customElements.get("maintenance-supporter-calendar-card-editor")||customElements.define("maintenance-supporter-calendar-card-editor",O);var re=window;re.customCards=re.customCards||[];var Qe="maintenance-supporter-calendar-card",St=re.customCards.some(n=>n.type===Qe);St||re.customCards.push({type:Qe,name:"Maintenance Supporter \u2014 Calendar",description:"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",preview:!0});export{b as MaintenanceCalendarCard}; + `,v([V({attribute:!1})],O.prototype,"hass",2),v([S()],O.prototype,"_config",2);customElements.get("maintenance-supporter-calendar-card")||customElements.define("maintenance-supporter-calendar-card",b);customElements.get("maintenance-supporter-calendar-card-editor")||customElements.define("maintenance-supporter-calendar-card-editor",O);var re=window;re.customCards=re.customCards||[];var Qe="maintenance-supporter-calendar-card",$t=re.customCards.some(n=>n.type===Qe);$t||re.customCards.push({type:Qe,name:"Maintenance Supporter \u2014 Calendar",description:"Rolling calendar of maintenance tasks with 7/14/30/365 day windows, source icons, and prediction-confidence pills.",preview:!0});export{b as MaintenanceCalendarCard}; /*! Bundled license information: @lit/reactive-element/css-tag.js: diff --git a/custom_components/maintenance_supporter/frontend/maintenance-card.js b/custom_components/maintenance_supporter/frontend/maintenance-card.js index 5b68e2d..7fa3cd8 100644 --- a/custom_components/maintenance_supporter/frontend/maintenance-card.js +++ b/custom_components/maintenance_supporter/frontend/maintenance-card.js @@ -1,13 +1,26 @@ -var st=Object.defineProperty;var ui=Object.getOwnPropertyDescriptor;var y=(a,s,e)=>()=>{if(e)throw e[0];try{return a&&(s=a(a=0)),s}catch(t){throw e=[t],t}};var gi=(a,s)=>{for(var e in s)st(a,e,{get:s[e],enumerable:!0})};var l=(a,s,e,t)=>{for(var i=t>1?void 0:t?ui(s,e):s,n=a.length-1,c;n>=0;n--)(c=a[n])&&(i=(t?c(s,e,i):c(i))||i);return t&&i&&st(s,e,i),i};var ye,xe,Pe,rt,oe,at,E,nt,ze,Ne=y(()=>{ye=globalThis,xe=ye.ShadowRoot&&(ye.ShadyCSS===void 0||ye.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,Pe=Symbol(),rt=new WeakMap,oe=class{constructor(s,e,t){if(this._$cssResult$=!0,t!==Pe)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=s,this.t=e}get styleSheet(){let s=this.o,e=this.t;if(xe&&s===void 0){let t=e!==void 0&&e.length===1;t&&(s=rt.get(e)),s===void 0&&((this.o=s=new CSSStyleSheet).replaceSync(this.cssText),t&&rt.set(e,s))}return s}toString(){return this.cssText}},at=a=>new oe(typeof a=="string"?a:a+"",void 0,Pe),E=(a,...s)=>{let e=a.length===1?a[0]:s.reduce((t,i,n)=>t+(c=>{if(c._$cssResult$===!0)return c.cssText;if(typeof c=="number")return c;throw Error("Value passed to 'css' function must be a 'css' function result: "+c+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+a[n+1],a[0]);return new oe(e,a,Pe)},nt=(a,s)=>{if(xe)a.adoptedStyleSheets=s.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of s){let t=document.createElement("style"),i=ye.litNonce;i!==void 0&&t.setAttribute("nonce",i),t.textContent=e.cssText,a.appendChild(t)}},ze=xe?a=>a:a=>a instanceof CSSStyleSheet?(s=>{let e="";for(let t of s.cssRules)e+=t.cssText;return at(e)})(a):a});var mi,fi,vi,bi,yi,xi,$e,ot,$i,wi,le,ce,we,lt,q,de=y(()=>{Ne();Ne();({is:mi,defineProperty:fi,getOwnPropertyDescriptor:vi,getOwnPropertyNames:bi,getOwnPropertySymbols:yi,getPrototypeOf:xi}=Object),$e=globalThis,ot=$e.trustedTypes,$i=ot?ot.emptyScript:"",wi=$e.reactiveElementPolyfillSupport,le=(a,s)=>a,ce={toAttribute(a,s){switch(s){case Boolean:a=a?$i:null;break;case Object:case Array:a=a==null?a:JSON.stringify(a)}return a},fromAttribute(a,s){let e=a;switch(s){case Boolean:e=a!==null;break;case Number:e=a===null?null:Number(a);break;case Object:case Array:try{e=JSON.parse(a)}catch{e=null}}return e}},we=(a,s)=>!mi(a,s),lt={attribute:!0,type:String,converter:ce,reflect:!1,useDefault:!1,hasChanged:we};Symbol.metadata??=Symbol("metadata"),$e.litPropertyMetadata??=new WeakMap;q=class extends HTMLElement{static addInitializer(s){this._$Ei(),(this.l??=[]).push(s)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(s,e=lt){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(s)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(s,e),!e.noAccessor){let t=Symbol(),i=this.getPropertyDescriptor(s,t,e);i!==void 0&&fi(this.prototype,s,i)}}static getPropertyDescriptor(s,e,t){let{get:i,set:n}=vi(this.prototype,s)??{get(){return this[e]},set(c){this[e]=c}};return{get:i,set(c){let d=i?.call(this);n?.call(this,c),this.requestUpdate(s,d,t)},configurable:!0,enumerable:!0}}static getPropertyOptions(s){return this.elementProperties.get(s)??lt}static _$Ei(){if(this.hasOwnProperty(le("elementProperties")))return;let s=xi(this);s.finalize(),s.l!==void 0&&(this.l=[...s.l]),this.elementProperties=new Map(s.elementProperties)}static finalize(){if(this.hasOwnProperty(le("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(le("properties"))){let e=this.properties,t=[...bi(e),...yi(e)];for(let i of t)this.createProperty(i,e[i])}let s=this[Symbol.metadata];if(s!==null){let e=litPropertyMetadata.get(s);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(s){let e=[];if(Array.isArray(s)){let t=new Set(s.flat(1/0).reverse());for(let i of t)e.unshift(ze(i))}else s!==void 0&&e.push(ze(s));return e}static _$Eu(s,e){let t=e.attribute;return t===!1?void 0:typeof t=="string"?t:typeof s=="string"?s.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(s=>this.enableUpdating=s),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(s=>s(this))}addController(s){(this._$EO??=new Set).add(s),this.renderRoot!==void 0&&this.isConnected&&s.hostConnected?.()}removeController(s){this._$EO?.delete(s)}_$E_(){let s=new Map,e=this.constructor.elementProperties;for(let t of e.keys())this.hasOwnProperty(t)&&(s.set(t,this[t]),delete this[t]);s.size>0&&(this._$Ep=s)}createRenderRoot(){let s=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return nt(s,this.constructor.elementStyles),s}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(s=>s.hostConnected?.())}enableUpdating(s){}disconnectedCallback(){this._$EO?.forEach(s=>s.hostDisconnected?.())}attributeChangedCallback(s,e,t){this._$AK(s,t)}_$ET(s,e){let t=this.constructor.elementProperties.get(s),i=this.constructor._$Eu(s,t);if(i!==void 0&&t.reflect===!0){let n=(t.converter?.toAttribute!==void 0?t.converter:ce).toAttribute(e,t.type);this._$Em=s,n==null?this.removeAttribute(i):this.setAttribute(i,n),this._$Em=null}}_$AK(s,e){let t=this.constructor,i=t._$Eh.get(s);if(i!==void 0&&this._$Em!==i){let n=t.getPropertyOptions(i),c=typeof n.converter=="function"?{fromAttribute:n.converter}:n.converter?.fromAttribute!==void 0?n.converter:ce;this._$Em=i;let d=c.fromAttribute(e,n.type);this[i]=d??this._$Ej?.get(i)??d,this._$Em=null}}requestUpdate(s,e,t,i=!1,n){if(s!==void 0){let c=this.constructor;if(i===!1&&(n=this[s]),t??=c.getPropertyOptions(s),!((t.hasChanged??we)(n,e)||t.useDefault&&t.reflect&&n===this._$Ej?.get(s)&&!this.hasAttribute(c._$Eu(s,t))))return;this.C(s,e,t)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(s,e,{useDefault:t,reflect:i,wrapped:n},c){t&&!(this._$Ej??=new Map).has(s)&&(this._$Ej.set(s,c??e??this[s]),n!==!0||c!==void 0)||(this._$AL.has(s)||(this.hasUpdated||t||(e=void 0),this._$AL.set(s,e)),i===!0&&this._$Em!==s&&(this._$Eq??=new Set).add(s))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let s=this.scheduleUpdate();return s!=null&&await s,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[i,n]of this._$Ep)this[i]=n;this._$Ep=void 0}let t=this.constructor.elementProperties;if(t.size>0)for(let[i,n]of t){let{wrapped:c}=n,d=this[i];c!==!0||this._$AL.has(i)||d===void 0||this.C(i,void 0,n,d)}}let s=!1,e=this._$AL;try{s=this.shouldUpdate(e),s?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(t){throw s=!1,this._$EM(),t}s&&this._$AE(e)}willUpdate(s){}_$AE(s){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(s)),this.updated(s)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(s){return!0}update(s){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(s){}firstUpdated(s){}};q.elementStyles=[],q.shadowRootOptions={mode:"open"},q[le("elementProperties")]=new Map,q[le("finalized")]=new Map,wi?.({ReactiveElement:q}),($e.reactiveElementVersions??=[]).push("2.1.2")});function bt(a,s){if(!Ve(a)||!a.hasOwnProperty("raw"))throw Error("invalid template strings array");return dt!==void 0?dt.createHTML(s):s}function te(a,s,e=a,t){if(s===J)return s;let i=t!==void 0?e._$Co?.[t]:e._$Cl,n=_e(s)?void 0:s._$litDirective$;return i?.constructor!==n&&(i?._$AO?.(!1),n===void 0?i=void 0:(i=new n(a),i._$AT(a,e,t)),t!==void 0?(e._$Co??=[])[t]=i:e._$Cl=i),i!==void 0&&(s=te(a,i._$AS(a,s.values),i,t)),s}var We,ct,ke,dt,mt,B,ft,ki,Y,he,_e,Ve,Ei,Me,pe,pt,ht,G,_t,ut,vt,Be,o,se,gs,J,_,gt,K,Ai,ue,qe,ge,ie,Ue,Oe,De,Fe,Si,yt,Ee=y(()=>{We=globalThis,ct=a=>a,ke=We.trustedTypes,dt=ke?ke.createPolicy("lit-html",{createHTML:a=>a}):void 0,mt="$lit$",B=`lit$${Math.random().toFixed(9).slice(2)}$`,ft="?"+B,ki=`<${ft}>`,Y=document,he=()=>Y.createComment(""),_e=a=>a===null||typeof a!="object"&&typeof a!="function",Ve=Array.isArray,Ei=a=>Ve(a)||typeof a?.[Symbol.iterator]=="function",Me=`[ -\f\r]`,pe=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,pt=/-->/g,ht=/>/g,G=RegExp(`>|${Me}(?:([^\\s"'>=/]+)(${Me}*=${Me}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`,"g"),_t=/'/g,ut=/"/g,vt=/^(?:script|style|textarea|title)$/i,Be=a=>(s,...e)=>({_$litType$:a,strings:s,values:e}),o=Be(1),se=Be(2),gs=Be(3),J=Symbol.for("lit-noChange"),_=Symbol.for("lit-nothing"),gt=new WeakMap,K=Y.createTreeWalker(Y,129);Ai=(a,s)=>{let e=a.length-1,t=[],i,n=s===2?"":s===3?"":"",c=pe;for(let d=0;d"?(c=i??pe,m=-1):b[1]===void 0?m=-2:(m=c.lastIndex-b[2].length,u=b[1],c=b[3]===void 0?G:b[3]==='"'?ut:_t):c===ut||c===_t?c=G:c===pt||c===ht?c=pe:(c=G,i=void 0);let f=c===G&&a[d+1].startsWith("/>")?" ":"";n+=c===pe?h+ki:m>=0?(t.push(u),h.slice(0,m)+mt+h.slice(m)+B+f):h+B+(m===-2?d:f)}return[bt(a,n+(a[e]||"")+(s===2?"":s===3?"":"")),t]},ue=class a{constructor({strings:s,_$litType$:e},t){let i;this.parts=[];let n=0,c=0,d=s.length-1,h=this.parts,[u,b]=Ai(s,e);if(this.el=a.createElement(u,t),K.currentNode=this.el.content,e===2||e===3){let m=this.el.content.firstChild;m.replaceWith(...m.childNodes)}for(;(i=K.nextNode())!==null&&h.length0){i.textContent=ke?ke.emptyScript:"";for(let f=0;f2||t[0]!==""||t[1]!==""?(this._$AH=Array(t.length-1).fill(new String),this.strings=t):this._$AH=_}_$AI(s,e=this,t,i){let n=this.strings,c=!1;if(n===void 0)s=te(this,s,e,0),c=!_e(s)||s!==this._$AH&&s!==J,c&&(this._$AH=s);else{let d=s,h,u;for(s=n[0],h=0;h{let t=e?.renderBefore??s,i=t._$litPart$;if(i===void 0){let n=e?.renderBefore??null;t._$litPart$=i=new ge(s.insertBefore(he(),n),n,void 0,e??{})}return i._$AI(a),i}});var Ge,$,Ti,xt=y(()=>{de();de();Ee();Ee();Ge=globalThis,$=class extends q{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let s=super.createRenderRoot();return this.renderOptions.renderBefore??=s.firstChild,s}update(s){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(s),this._$Do=yt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return J}};$._$litElement$=!0,$.finalized=!0,Ge.litElementHydrateSupport?.({LitElement:$});Ti=Ge.litElementPolyfillSupport;Ti?.({LitElement:$});(Ge.litElementVersions??=[]).push("4.2.2")});var $t=y(()=>{});var T=y(()=>{de();Ee();xt();$t()});var wt=y(()=>{});function v(a){return(s,e)=>typeof e=="object"?Ii(a,s,e):((t,i,n)=>{let c=i.hasOwnProperty(n);return i.constructor.createProperty(n,t),c?Object.getOwnPropertyDescriptor(i,n):void 0})(a,s,e)}var Ci,Ii,Ke=y(()=>{de();Ci={attribute:!0,type:String,converter:ce,reflect:!1,hasChanged:we},Ii=(a=Ci,s,e)=>{let{kind:t,metadata:i}=e,n=globalThis.litPropertyMetadata.get(i);if(n===void 0&&globalThis.litPropertyMetadata.set(i,n=new Map),t==="setter"&&((a=Object.create(a)).wrapped=!0),n.set(e.name,a),t==="accessor"){let{name:c}=e;return{set(d){let h=s.get.call(this);s.set.call(this,d),this.requestUpdate(c,h,a,!0,d)},init(d){return d!==void 0&&this.C(c,void 0,a,d),d}}}if(t==="setter"){let{name:c}=e;return function(d){let h=this[c];s.call(this,d),this.requestUpdate(c,h,a,!0,d)}}throw Error("Unsupported decorator location: "+t)}});function p(a){return v({...a,state:!0,attribute:!1})}var kt=y(()=>{Ke();});var Et=y(()=>{});var re=y(()=>{});var At=y(()=>{re();});var St=y(()=>{re();});var Tt=y(()=>{re();});var Ct=y(()=>{re();});var It=y(()=>{re();});var j=y(()=>{wt();Ke();kt();Et();At();St();Tt();Ct();It()});var Ht,Lt=y(()=>{Ht={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 +var st=Object.defineProperty;var fi=Object.getOwnPropertyDescriptor;var x=(a,s,e)=>()=>{if(e)throw e[0];try{return a&&(s=a(a=0)),s}catch(t){throw e=[t],t}};var bi=(a,s)=>{for(var e in s)st(a,e,{get:s[e],enumerable:!0})};var l=(a,s,e,t)=>{for(var i=t>1?void 0:t?fi(s,e):s,n=a.length-1,d;n>=0;n--)(d=a[n])&&(i=(t?d(s,e,i):d(i))||i);return t&&i&&st(s,e,i),i};var ye,xe,Pe,rt,le,at,E,nt,Ne,ze=x(()=>{ye=globalThis,xe=ye.ShadowRoot&&(ye.ShadyCSS===void 0||ye.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,Pe=Symbol(),rt=new WeakMap,le=class{constructor(s,e,t){if(this._$cssResult$=!0,t!==Pe)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=s,this.t=e}get styleSheet(){let s=this.o,e=this.t;if(xe&&s===void 0){let t=e!==void 0&&e.length===1;t&&(s=rt.get(e)),s===void 0&&((this.o=s=new CSSStyleSheet).replaceSync(this.cssText),t&&rt.set(e,s))}return s}toString(){return this.cssText}},at=a=>new le(typeof a=="string"?a:a+"",void 0,Pe),E=(a,...s)=>{let e=a.length===1?a[0]:s.reduce((t,i,n)=>t+(d=>{if(d._$cssResult$===!0)return d.cssText;if(typeof d=="number")return d;throw Error("Value passed to 'css' function must be a 'css' function result: "+d+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(i)+a[n+1],a[0]);return new le(e,a,Pe)},nt=(a,s)=>{if(xe)a.adoptedStyleSheets=s.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of s){let t=document.createElement("style"),i=ye.litNonce;i!==void 0&&t.setAttribute("nonce",i),t.textContent=e.cssText,a.appendChild(t)}},Ne=xe?a=>a:a=>a instanceof CSSStyleSheet?(s=>{let e="";for(let t of s.cssRules)e+=t.cssText;return at(e)})(a):a});var yi,xi,$i,wi,ki,Ei,$e,ot,Ai,Si,ce,de,we,lt,O,pe=x(()=>{ze();ze();({is:yi,defineProperty:xi,getOwnPropertyDescriptor:$i,getOwnPropertyNames:wi,getOwnPropertySymbols:ki,getPrototypeOf:Ei}=Object),$e=globalThis,ot=$e.trustedTypes,Ai=ot?ot.emptyScript:"",Si=$e.reactiveElementPolyfillSupport,ce=(a,s)=>a,de={toAttribute(a,s){switch(s){case Boolean:a=a?Ai:null;break;case Object:case Array:a=a==null?a:JSON.stringify(a)}return a},fromAttribute(a,s){let e=a;switch(s){case Boolean:e=a!==null;break;case Number:e=a===null?null:Number(a);break;case Object:case Array:try{e=JSON.parse(a)}catch{e=null}}return e}},we=(a,s)=>!yi(a,s),lt={attribute:!0,type:String,converter:de,reflect:!1,useDefault:!1,hasChanged:we};Symbol.metadata??=Symbol("metadata"),$e.litPropertyMetadata??=new WeakMap;O=class extends HTMLElement{static addInitializer(s){this._$Ei(),(this.l??=[]).push(s)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(s,e=lt){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(s)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(s,e),!e.noAccessor){let t=Symbol(),i=this.getPropertyDescriptor(s,t,e);i!==void 0&&xi(this.prototype,s,i)}}static getPropertyDescriptor(s,e,t){let{get:i,set:n}=$i(this.prototype,s)??{get(){return this[e]},set(d){this[e]=d}};return{get:i,set(d){let c=i?.call(this);n?.call(this,d),this.requestUpdate(s,c,t)},configurable:!0,enumerable:!0}}static getPropertyOptions(s){return this.elementProperties.get(s)??lt}static _$Ei(){if(this.hasOwnProperty(ce("elementProperties")))return;let s=Ei(this);s.finalize(),s.l!==void 0&&(this.l=[...s.l]),this.elementProperties=new Map(s.elementProperties)}static finalize(){if(this.hasOwnProperty(ce("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(ce("properties"))){let e=this.properties,t=[...wi(e),...ki(e)];for(let i of t)this.createProperty(i,e[i])}let s=this[Symbol.metadata];if(s!==null){let e=litPropertyMetadata.get(s);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(s){let e=[];if(Array.isArray(s)){let t=new Set(s.flat(1/0).reverse());for(let i of t)e.unshift(Ne(i))}else s!==void 0&&e.push(Ne(s));return e}static _$Eu(s,e){let t=e.attribute;return t===!1?void 0:typeof t=="string"?t:typeof s=="string"?s.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(s=>this.enableUpdating=s),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(s=>s(this))}addController(s){(this._$EO??=new Set).add(s),this.renderRoot!==void 0&&this.isConnected&&s.hostConnected?.()}removeController(s){this._$EO?.delete(s)}_$E_(){let s=new Map,e=this.constructor.elementProperties;for(let t of e.keys())this.hasOwnProperty(t)&&(s.set(t,this[t]),delete this[t]);s.size>0&&(this._$Ep=s)}createRenderRoot(){let s=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return nt(s,this.constructor.elementStyles),s}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(s=>s.hostConnected?.())}enableUpdating(s){}disconnectedCallback(){this._$EO?.forEach(s=>s.hostDisconnected?.())}attributeChangedCallback(s,e,t){this._$AK(s,t)}_$ET(s,e){let t=this.constructor.elementProperties.get(s),i=this.constructor._$Eu(s,t);if(i!==void 0&&t.reflect===!0){let n=(t.converter?.toAttribute!==void 0?t.converter:de).toAttribute(e,t.type);this._$Em=s,n==null?this.removeAttribute(i):this.setAttribute(i,n),this._$Em=null}}_$AK(s,e){let t=this.constructor,i=t._$Eh.get(s);if(i!==void 0&&this._$Em!==i){let n=t.getPropertyOptions(i),d=typeof n.converter=="function"?{fromAttribute:n.converter}:n.converter?.fromAttribute!==void 0?n.converter:de;this._$Em=i;let c=d.fromAttribute(e,n.type);this[i]=c??this._$Ej?.get(i)??c,this._$Em=null}}requestUpdate(s,e,t,i=!1,n){if(s!==void 0){let d=this.constructor;if(i===!1&&(n=this[s]),t??=d.getPropertyOptions(s),!((t.hasChanged??we)(n,e)||t.useDefault&&t.reflect&&n===this._$Ej?.get(s)&&!this.hasAttribute(d._$Eu(s,t))))return;this.C(s,e,t)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(s,e,{useDefault:t,reflect:i,wrapped:n},d){t&&!(this._$Ej??=new Map).has(s)&&(this._$Ej.set(s,d??e??this[s]),n!==!0||d!==void 0)||(this._$AL.has(s)||(this.hasUpdated||t||(e=void 0),this._$AL.set(s,e)),i===!0&&this._$Em!==s&&(this._$Eq??=new Set).add(s))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let s=this.scheduleUpdate();return s!=null&&await s,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(let[i,n]of this._$Ep)this[i]=n;this._$Ep=void 0}let t=this.constructor.elementProperties;if(t.size>0)for(let[i,n]of t){let{wrapped:d}=n,c=this[i];d!==!0||this._$AL.has(i)||c===void 0||this.C(i,void 0,n,c)}}let s=!1,e=this._$AL;try{s=this.shouldUpdate(e),s?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(t){throw s=!1,this._$EM(),t}s&&this._$AE(e)}willUpdate(s){}_$AE(s){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(s)),this.updated(s)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(s){return!0}update(s){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(s){}firstUpdated(s){}};O.elementStyles=[],O.shadowRootOptions={mode:"open"},O[ce("elementProperties")]=new Map,O[ce("finalized")]=new Map,Si?.({ReactiveElement:O}),($e.reactiveElementVersions??=[]).push("2.1.2")});function bt(a,s){if(!We(a)||!a.hasOwnProperty("raw"))throw Error("invalid template strings array");return dt!==void 0?dt.createHTML(s):s}function se(a,s,e=a,t){if(s===Z)return s;let i=t!==void 0?e._$Co?.[t]:e._$Cl,n=ue(s)?void 0:s._$litDirective$;return i?.constructor!==n&&(i?._$AO?.(!1),n===void 0?i=void 0:(i=new n(a),i._$AT(a,e,t)),t!==void 0?(e._$Co??=[])[t]=i:e._$Cl=i),i!==void 0&&(s=se(a,i._$AS(a,s.values),i,t)),s}var Ve,ct,ke,dt,mt,G,vt,Ti,J,_e,ue,We,Ci,Me,he,pt,ht,K,_t,ut,ft,Be,o,ae,bs,Z,h,gt,Y,Ii,ge,qe,me,re,Ue,Fe,Oe,De,Li,yt,Ee=x(()=>{Ve=globalThis,ct=a=>a,ke=Ve.trustedTypes,dt=ke?ke.createPolicy("lit-html",{createHTML:a=>a}):void 0,mt="$lit$",G=`lit$${Math.random().toFixed(9).slice(2)}$`,vt="?"+G,Ti=`<${vt}>`,J=document,_e=()=>J.createComment(""),ue=a=>a===null||typeof a!="object"&&typeof a!="function",We=Array.isArray,Ci=a=>We(a)||typeof a?.[Symbol.iterator]=="function",Me=`[ +\f\r]`,he=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,pt=/-->/g,ht=/>/g,K=RegExp(`>|${Me}(?:([^\\s"'>=/]+)(${Me}*=${Me}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`,"g"),_t=/'/g,ut=/"/g,ft=/^(?:script|style|textarea|title)$/i,Be=a=>(s,...e)=>({_$litType$:a,strings:s,values:e}),o=Be(1),ae=Be(2),bs=Be(3),Z=Symbol.for("lit-noChange"),h=Symbol.for("lit-nothing"),gt=new WeakMap,Y=J.createTreeWalker(J,129);Ii=(a,s)=>{let e=a.length-1,t=[],i,n=s===2?"":s===3?"":"",d=he;for(let c=0;c"?(d=i??he,m=-1):v[1]===void 0?m=-2:(m=d.lastIndex-v[2].length,u=v[1],d=v[3]===void 0?K:v[3]==='"'?ut:_t):d===ut||d===_t?d=K:d===pt||d===ht?d=he:(d=K,i=void 0);let b=d===K&&a[c+1].startsWith("/>")?" ":"";n+=d===he?_+Ti:m>=0?(t.push(u),_.slice(0,m)+mt+_.slice(m)+G+b):_+G+(m===-2?c:b)}return[bt(a,n+(a[e]||"")+(s===2?"":s===3?"":"")),t]},ge=class a{constructor({strings:s,_$litType$:e},t){let i;this.parts=[];let n=0,d=0,c=s.length-1,_=this.parts,[u,v]=Ii(s,e);if(this.el=a.createElement(u,t),Y.currentNode=this.el.content,e===2||e===3){let m=this.el.content.firstChild;m.replaceWith(...m.childNodes)}for(;(i=Y.nextNode())!==null&&_.length0){i.textContent=ke?ke.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(s,e=this,t,i){let n=this.strings,d=!1;if(n===void 0)s=se(this,s,e,0),d=!ue(s)||s!==this._$AH&&s!==Z,d&&(this._$AH=s);else{let c=s,_,u;for(s=n[0],_=0;_{let t=e?.renderBefore??s,i=t._$litPart$;if(i===void 0){let n=e?.renderBefore??null;t._$litPart$=i=new me(s.insertBefore(_e(),n),n,void 0,e??{})}return i._$AI(a),i}});var Ge,w,Hi,xt=x(()=>{pe();pe();Ee();Ee();Ge=globalThis,w=class extends O{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let s=super.createRenderRoot();return this.renderOptions.renderBefore??=s.firstChild,s}update(s){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(s),this._$Do=yt(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return Z}};w._$litElement$=!0,w.finalized=!0,Ge.litElementHydrateSupport?.({LitElement:w});Hi=Ge.litElementPolyfillSupport;Hi?.({LitElement:w});(Ge.litElementVersions??=[]).push("4.2.2")});var $t=x(()=>{});var C=x(()=>{pe();Ee();xt();$t()});var wt=x(()=>{});function f(a){return(s,e)=>typeof e=="object"?ji(a,s,e):((t,i,n)=>{let d=i.hasOwnProperty(n);return i.constructor.createProperty(n,t),d?Object.getOwnPropertyDescriptor(i,n):void 0})(a,s,e)}var Ri,ji,Ke=x(()=>{pe();Ri={attribute:!0,type:String,converter:de,reflect:!1,hasChanged:we},ji=(a=Ri,s,e)=>{let{kind:t,metadata:i}=e,n=globalThis.litPropertyMetadata.get(i);if(n===void 0&&globalThis.litPropertyMetadata.set(i,n=new Map),t==="setter"&&((a=Object.create(a)).wrapped=!0),n.set(e.name,a),t==="accessor"){let{name:d}=e;return{set(c){let _=s.get.call(this);s.set.call(this,c),this.requestUpdate(d,_,a,!0,c)},init(c){return c!==void 0&&this.C(d,void 0,a,c),c}}}if(t==="setter"){let{name:d}=e;return function(c){let _=this[d];s.call(this,c),this.requestUpdate(d,_,a,!0,c)}}throw Error("Unsupported decorator location: "+t)}});function p(a){return f({...a,state:!0,attribute:!1})}var kt=x(()=>{Ke();});var Et=x(()=>{});var ne=x(()=>{});var At=x(()=>{ne();});var St=x(()=>{ne();});var Tt=x(()=>{ne();});var Ct=x(()=>{ne();});var It=x(()=>{ne();});var N=x(()=>{wt();Ke();kt();Et();At();St();Tt();Ct();It()});var Ht,Lt=x(()=>{Ht={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",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"}});function Je(a){return(a||Ye).substring(0,2).toLowerCase()}function r(a,s){let e=Je(s);return Z[e]?.[a]??Z.en[a]??a}function Te(a){let s=Je(a);return s===Ye||s in Z}function Ce(a){let s=Je(a);return s===Ye||s in Z||!Hi.has(s)?Promise.resolve():(s in me||(me[s]=fetch(`${Ri}/${s}.json`).then(e=>e.ok?e.json():null).then(e=>{e?Z[s]=e:delete me[s]}).catch(()=>{delete me[s]})),me[s])}function Pt(a){let s=(a||"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"}[s]??"en-US"}function Q(a,s){if(!a)return"\u2014";try{let e=a.includes("T")?a:a+"T00:00:00";return new Date(e).toLocaleDateString(Pt(s),{day:"2-digit",month:"2-digit",year:"numeric"})}catch{return a}}function zt(a,s){if(!a)return"\u2014";try{let e=Pt(s),t=new Date(a);return t.toLocaleDateString(e,{day:"2-digit",month:"2-digit",year:"numeric"})+" "+t.toLocaleTimeString(e,{hour:"2-digit",minute:"2-digit"})}catch{return a}}function Rt(a,s,e){return a==null?"\u2014":`${a} ${r("unit_"+(s||"days"),e)}`}function Se(a,s,e="long"){let t=(s||"en").substring(0,2);return new Date(Date.UTC(2024,0,1+a)).toLocaleDateString(t,{weekday:e,timeZone:"UTC"})}function Nt(a,s){let e=a.schedule,t=e?.offset?` ${e.offset>0?"+":"\u2212"}${Math.abs(e.offset)}d`:"";switch(e?.kind){case"weekdays":return((e.weekdays||[]).map(i=>Se(i,s,"short")).join(" & ")||"\u2014")+t;case"nth_weekday":return e.weekday==null||e.nth==null?"\u2014":`${e.nth===-1?r("ord_last",s):r("ord_"+e.nth,s)} ${Se(e.weekday,s,"long")}${t}`;case"day_of_month":return e.day==null?"\u2014":(e.day===-1?r(e.business?"last_business_day_month":"last_day_month",s):`${r("day_word",s)} ${e.day}`)+t;case"one_time":return a.due_date?Q(a.due_date,s):r("one_time",s);case"manual":return r("manual",s);case"interval":return Rt(e.every,e.unit,s)}return a.schedule_type==="one_time"?a.due_date?Q(a.due_date,s):r("one_time",s):a.schedule_type==="manual"?r("manual",s):a.schedule_type==="sensor_based"?r("sensor_based",s):a.interval_days!=null?Rt(a.interval_days,a.interval_unit,s):"\u2014"}function Mt(a,s){a.currentTarget.dispatchEvent(new CustomEvent("hass-more-info",{detail:{entityId:s},bubbles:!0,composed:!0}))}var ae,Ye,jt,Z,Hi,Ri,me,Ie,C=y(()=>{"use strict";T();Lt();ae={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)"},Ye="en",jt=(()=>{let a=window;return a.__msLocales||(a.__msLocales={store:{},inflight:{}}),a.__msLocales})(),Z=jt.store;Z.en||(Z.en=Ht);Hi=new Set(["de","nl","fr","it","es","pt","ru","uk","pl","cs","sv","zh","da","fi","nb","ja","hi"]),Ri="/maintenance_supporter_locales",me=jt.inflight;Ie=E` +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."}});var Q,Rt=x(()=>{"use strict";Q={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)"}});function Je(a){return(a||Ye).substring(0,2).toLowerCase()}function r(a,s){let e=Je(s);return X[e]?.[a]??X.en[a]??a}function Te(a){let s=Je(a);return s===Ye||s in X}function Ce(a){let s=Je(a);return s===Ye||s in X||!Ni.has(s)?Promise.resolve():(s in ve||(ve[s]=fetch(`${zi}/${s}.json`).then(e=>e.ok?e.json():null).then(e=>{e?X[s]=e:delete ve[s]}).catch(()=>{delete ve[s]})),ve[s])}function Nt(a){let s=(a||"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"}[s]??"en-US"}function ee(a,s){if(!a)return"\u2014";try{let e=a.includes("T")?a:a+"T00:00:00";return new Date(e).toLocaleDateString(Nt(s),{day:"2-digit",month:"2-digit",year:"numeric"})}catch{return a}}function zt(a,s){if(!a)return"\u2014";try{let e=Nt(s),t=new Date(a);return t.toLocaleDateString(e,{day:"2-digit",month:"2-digit",year:"numeric"})+" "+t.toLocaleTimeString(e,{hour:"2-digit",minute:"2-digit"})}catch{return a}}function jt(a,s,e){return a==null?"\u2014":`${a} ${r("unit_"+(s||"days"),e)}`}function Se(a,s,e="long"){let t=(s||"en").substring(0,2);return new Date(Date.UTC(2024,0,1+a)).toLocaleDateString(t,{weekday:e,timeZone:"UTC"})}function Mt(a,s){let e=a.schedule,t=e?.offset?` ${e.offset>0?"+":"\u2212"}${Math.abs(e.offset)}d`:"";switch(e?.kind){case"weekdays":return((e.weekdays||[]).map(i=>Se(i,s,"short")).join(" & ")||"\u2014")+t;case"nth_weekday":return e.weekday==null||e.nth==null?"\u2014":`${e.nth===-1?r("ord_last",s):r("ord_"+e.nth,s)} ${Se(e.weekday,s,"long")}${t}`;case"day_of_month":return e.day==null?"\u2014":(e.day===-1?r(e.business?"last_business_day_month":"last_day_month",s):`${r("day_word",s)} ${e.day}`)+t;case"one_time":return a.due_date?ee(a.due_date,s):r("one_time",s);case"manual":return r("manual",s);case"interval":return jt(e.every,e.unit,s)}return a.schedule_type==="one_time"?a.due_date?ee(a.due_date,s):r("one_time",s):a.schedule_type==="manual"?r("manual",s):a.schedule_type==="sensor_based"?r("sensor_based",s):a.interval_days!=null?jt(a.interval_days,a.interval_unit,s):"\u2014"}function qt(a,s){a.currentTarget.dispatchEvent(new CustomEvent("hass-more-info",{detail:{entityId:s},bubbles:!0,composed:!0}))}var Ye,Pt,X,Ni,zi,ve,Ut,Ie,I=x(()=>{"use strict";C();Lt();Rt();Ye="en",Pt=(()=>{let a=window;return a.__msLocales||(a.__msLocales={store:{},inflight:{}}),a.__msLocales})(),X=Pt.store;X.en||(X.en=Ht);Ni=new Set(["de","nl","fr","it","es","pt","ru","uk","pl","cs","sv","zh","da","fi","nb","ja","hi"]),zi="/maintenance_supporter_locales",ve=Pt.inflight;Ut=E` + .field { display: flex; flex-direction: column; gap: 4px; } + .field-label { font-size: 12px; color: var(--secondary-text-color); } + .field-input { + padding: 8px 10px; font-size: 14px; + background: var(--secondary-background-color, rgba(0,0,0,0.06)); + color: var(--primary-text-color); + border: 1px solid var(--divider-color); border-radius: 6px; + font-family: inherit; width: 100%; box-sizing: border-box; + } + .field-input:focus { outline: none; border-color: var(--primary-color); } +`,Ie=E` :host { --maint-ok-color: var(--success-color, #4caf50); --maint-due-soon-color: var(--warning-color, #ff9800); --maint-overdue-color: var(--error-color, #f44336); - --maint-triggered-color: #ff5722; + /* Theme-token first so it follows dark/custom themes (was a bare #ff5722, + inconsistent with STATUS_COLORS.triggered which already tokenised it). */ + --maint-triggered-color: var(--deep-orange-color, #ff5722); } .status-badge { @@ -29,14 +42,17 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" /* Shape icon so status is not conveyed by colour alone (accessibility). */ .status-badge ha-icon { --mdc-icon-size: 14px; margin-left: -1px; } - .status-badge.ok { background-color: var(--maint-ok-color); } - .status-badge.due_soon { background-color: var(--maint-due-soon-color); } + /* Light-background statuses (green/orange/grey) carry DARK text: white on + them fails even the 3:1 WCAG UI-contrast floor (2.2–2.8:1), while the + saturated statuses below keep white (≥3.1:1). Matches the calendar pills. */ + .status-badge.ok { background-color: var(--maint-ok-color); color: #000; } + .status-badge.due_soon { background-color: var(--maint-due-soon-color); color: #000; } .status-badge.overdue { background-color: var(--maint-overdue-color); } .status-badge.triggered { background-color: var(--maint-triggered-color); } /* Completed one-time task ("done") — muted blue-grey. */ .status-badge.done { background-color: var(--maint-done-color, #78909c); } /* v2.10.0: archived (retire-but-retain) — neutral grey, clearly inert. */ - .status-badge.archived { background-color: var(--disabled-color, #9e9e9e); } + .status-badge.archived { background-color: var(--disabled-color, #9e9e9e); color: #000; } /* v2.20 (N3): paused — frozen but present, info blue. */ .status-badge.paused { background-color: var(--info-color, #2196f3); } @@ -1130,11 +1146,11 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" .stat-item { min-width: 60px; } .stat-value { font-size: 20px; } } -`});function zi(a,s){let e=Pi[a];if(!e)return a;let t=r(e,s);return t&&t!==e?t:a}function Ni(a){let e=a.match(/data\['([^']+)'\]/)?.[1],t;return(t=a.match(/length of value must be at most (\d+)/))?{field:e,rule:"too_long",param:t[1]}:(t=a.match(/length of value must be at least (\d+)/))?{field:e,rule:"too_short",param:t[1]}:(t=a.match(/value must be at most (\S+)/))?{field:e,rule:"value_too_high",param:t[1]}:(t=a.match(/value must be at least (\S+)/))?{field:e,rule:"value_too_low",param:t[1]}:/required key not provided/.test(a)?{field:e,rule:"required"}:(t=a.match(/expected (\w+)/))?{field:e,rule:"wrong_type",param:t[1]}:/value must be one of/.test(a)?{field:e,rule:"invalid_choice"}:/not a valid value/.test(a)?{field:e,rule:"invalid_value"}:{field:e,rule:"unknown"}}function I(a,s,e){if(e=e??r("action_error",s),typeof a=="string")return a;if(typeof a!="object"||a===null)return e;let t=a,i=t.message||t.error?.message||"";if(!i)return e;let n=Ni(i),c=n.field?zi(n.field,s):"",d=h=>r(h,s).replace("{field}",c).replace("{n}",n.param??"");switch(n.rule){case"too_long":return d("err_too_long");case"too_short":return d("err_too_short");case"value_too_high":return d("err_value_too_high");case"value_too_low":return d("err_value_too_low");case"required":return d("err_required");case"wrong_type":return d("err_wrong_type").replace("{type}",n.param??"");case"invalid_choice":return d("err_invalid_choice");case"invalid_value":return d("err_invalid_value");default:return i||e}}var Pi,X=y(()=>{"use strict";C();Pi={name:"name",task_type:"maintenance_type",schedule_type:"schedule_type",interval_days:"interval_days",interval_anchor:"interval_anchor",warning_days:"warning_days",last_performed:"last_performed_optional",notes:"notes_optional",documentation_url:"documentation_url_optional",custom_icon:"custom_icon_optional",nfc_tag_id:"nfc_tag_id_optional",responsible_user_id:"responsible_user",entity_slug:"entity_slug",entity_id:"entity_id",area_id:"area_id_optional",manufacturer:"manufacturer_optional",model:"model_optional",serial_number:"serial_number_optional",installation_date:"installation_date_optional",warranty_expiry:"warranty_expiry_optional",checklist:"checklist_steps_optional",reason:"reason",feedback:"feedback",cost:"cost",duration:"duration",description:"description_optional",group_name:"name",group_description:"description_optional",environmental_entity:"environmental_entity_optional",environmental_attribute:"environmental_attribute_optional",trigger_above:"trigger_above",trigger_below:"trigger_below",trigger_for_minutes:"trigger_for_minutes"}});var w,Ze=y(()=>{"use strict";T();j();C();X();w=class extends ${constructor(){super(...arguments);this.entryId="";this.taskId="";this.taskName="";this.lang="en";this.checklist=[];this.adaptiveEnabled=!1;this.taskType="";this.readingUnit="";this._open=!1;this._notes="";this._cost="";this._duration="";this._loading=!1;this._error="";this._checklistState={};this._feedback="needed";this._photoDocId="";this._photoPreview="";this._photoUploading=!1;this._readingValue=""}open(){this._open||(this._open=!0,this._notes="",this._cost="",this._duration="",this._error="",this._checklistState={},this._feedback="needed",this._photoDocId="",this._photoPreview="",this._photoUploading=!1,this._readingValue="")}_toggleCheck(e){let t=String(e);this._checklistState={...this._checklistState,[t]:!this._checklistState[t]}}_setFeedback(e){this._feedback=e}async _onPhotoInput(e){let t=e.target,i=t.files?.[0];if(t.value="",!!i){this._photoUploading=!0,this._error="";try{let n=new FormData;n.append("entry_id",this.entryId),n.append("tags","photo"),n.append("file",i,i.name);let c=await fetch("/api/maintenance_supporter/document/upload",{method:"POST",headers:{Authorization:`Bearer ${this.hass.auth?.data?.access_token??""}`},body:n});if(!c.ok){this._error=c.status===413?r("doc_too_large",this.lang):r("doc_upload_failed",this.lang);return}let d=await c.json();d.id&&(this._photoDocId=d.id,this._photoPreview=URL.createObjectURL(i))}catch{this._error=r("doc_upload_failed",this.lang)}finally{this._photoUploading=!1}}}_removePhoto(){this._photoPreview&&URL.revokeObjectURL(this._photoPreview),this._photoDocId="",this._photoPreview=""}async _complete(){this._loading=!0,this._error="";try{let e={type:"maintenance_supporter/task/complete",entry_id:this.entryId,task_id:this.taskId};if(this._notes&&(e.notes=this._notes),this._cost){let t=parseFloat(this._cost);!isNaN(t)&&t>=0&&(e.cost=t)}if(this._duration){let t=parseInt(this._duration,10);!isNaN(t)&&t>=0&&(e.duration=t)}if(this.checklist.length>0&&(e.checklist_state=this._checklistState),this.adaptiveEnabled&&(e.feedback=this._feedback),this._photoDocId&&(e.photo_doc_id=this._photoDocId),this._readingValue!==""){let t=parseFloat(this._readingValue);isNaN(t)||(e.reading_value=t)}await this.hass.connection.sendMessagePromise(e),this._open=!1,this.dispatchEvent(new CustomEvent("task-completed"))}catch(e){this._error=I(e,this.lang,r("save_error",this.lang))}finally{this._loading=!1}}_close(){this._open=!1}render(){if(!this._open)return o``;let e=this.lang||this.hass?.language||"en";return o` +`});function Ui(a,s){let e=qi[a];if(!e)return a;let t=r(e,s);return t&&t!==e?t:a}function Fi(a){let e=a.match(/data\['([^']+)'\]/)?.[1],t;return(t=a.match(/length of value must be at most (\d+)/))?{field:e,rule:"too_long",param:t[1]}:(t=a.match(/length of value must be at least (\d+)/))?{field:e,rule:"too_short",param:t[1]}:(t=a.match(/value must be at most (\S+)/))?{field:e,rule:"value_too_high",param:t[1]}:(t=a.match(/value must be at least (\S+)/))?{field:e,rule:"value_too_low",param:t[1]}:/required key not provided/.test(a)?{field:e,rule:"required"}:(t=a.match(/expected (\w+)/))?{field:e,rule:"wrong_type",param:t[1]}:/value must be one of/.test(a)?{field:e,rule:"invalid_choice"}:/not a valid value/.test(a)?{field:e,rule:"invalid_value"}:{field:e,rule:"unknown"}}function L(a,s,e){if(e=e??r("action_error",s),typeof a=="string")return a;if(typeof a!="object"||a===null)return e;let t=a,i=t.message||t.error?.message||"";if(!i)return e;let n=Fi(i),d=n.field?Ui(n.field,s):"",c=_=>r(_,s).replace("{field}",d).replace("{n}",n.param??"");switch(n.rule){case"too_long":return c("err_too_long");case"too_short":return c("err_too_short");case"value_too_high":return c("err_value_too_high");case"value_too_low":return c("err_value_too_low");case"required":return c("err_required");case"wrong_type":return c("err_wrong_type").replace("{type}",n.param??"");case"invalid_choice":return c("err_invalid_choice");case"invalid_value":return c("err_invalid_value");default:return i||e}}var qi,te=x(()=>{"use strict";I();qi={name:"name",task_type:"maintenance_type",schedule_type:"schedule_type",interval_days:"interval_days",interval_anchor:"interval_anchor",warning_days:"warning_days",last_performed:"last_performed_optional",notes:"notes_optional",documentation_url:"documentation_url_optional",custom_icon:"custom_icon_optional",nfc_tag_id:"nfc_tag_id_optional",responsible_user_id:"responsible_user",entity_slug:"entity_slug",entity_id:"entity_id",area_id:"area_id_optional",manufacturer:"manufacturer_optional",model:"model_optional",serial_number:"serial_number_optional",installation_date:"installation_date_optional",warranty_expiry:"warranty_expiry_optional",checklist:"checklist_steps_optional",reason:"reason",feedback:"feedback",cost:"cost",duration:"duration",description:"description_optional",group_name:"name",group_description:"description_optional",environmental_entity:"environmental_entity_optional",environmental_attribute:"environmental_attribute_optional",trigger_above:"trigger_above",trigger_below:"trigger_below",trigger_for_minutes:"trigger_for_minutes"}});var $,Ze=x(()=>{"use strict";C();N();I();te();$=class extends w{constructor(){super(...arguments);this.entryId="";this.taskId="";this.taskName="";this.lang="en";this.checklist=[];this.adaptiveEnabled=!1;this.taskType="";this.readingUnit="";this.restockDefault=null;this.consumesInfo=[];this._open=!1;this._notes="";this._cost="";this._duration="";this._loading=!1;this._error="";this._checklistState={};this._feedback="needed";this._photoDocId="";this._photoPreview="";this._photoUploading=!1;this._readingValue="";this._restockQty=""}open(){this._open||(this._open=!0,this._notes="",this._cost="",this._duration="",this._error="",this._checklistState={},this._feedback="needed",this._photoDocId="",this._photoPreview="",this._photoUploading=!1,this._readingValue="",this._restockQty=this.restockDefault!==null?String(this.restockDefault):"")}_toggleCheck(e){let t=String(e);this._checklistState={...this._checklistState,[t]:!this._checklistState[t]}}_setFeedback(e){this._feedback=e}async _onPhotoInput(e){let t=e.target,i=t.files?.[0];if(t.value="",!!i){this._photoUploading=!0,this._error="";try{let n=new FormData;n.append("entry_id",this.entryId),n.append("tags","photo"),n.append("file",i,i.name);let d=await fetch("/api/maintenance_supporter/document/upload",{method:"POST",headers:{Authorization:`Bearer ${this.hass.auth?.data?.access_token??""}`},body:n});if(!d.ok){this._error=d.status===413?r("doc_too_large",this.lang):r("doc_upload_failed",this.lang);return}let c=await d.json();c.id&&(this._photoDocId=c.id,this._photoPreview=URL.createObjectURL(i))}catch{this._error=r("doc_upload_failed",this.lang)}finally{this._photoUploading=!1}}}_removePhoto(){this._photoPreview&&URL.revokeObjectURL(this._photoPreview),this._photoDocId="",this._photoPreview=""}async _complete(){this._loading=!0,this._error="";try{let e={type:"maintenance_supporter/task/complete",entry_id:this.entryId,task_id:this.taskId};if(this._notes&&(e.notes=this._notes),this._cost){let t=parseFloat(this._cost);!isNaN(t)&&t>=0&&(e.cost=t)}if(this._duration){let t=parseInt(this._duration,10);!isNaN(t)&&t>=0&&(e.duration=t)}if(this.checklist.length>0&&(e.checklist_state=this._checklistState),this.adaptiveEnabled&&(e.feedback=this._feedback),this._photoDocId&&(e.photo_doc_id=this._photoDocId),this._readingValue!==""){let t=parseFloat(this._readingValue);isNaN(t)||(e.reading_value=t)}if(this.restockDefault!==null&&this._restockQty!==""){let t=parseInt(this._restockQty,10);!isNaN(t)&&t>=1&&(e.restock_quantity=t)}await this.hass.connection.sendMessagePromise(e),this._open=!1,this.dispatchEvent(new CustomEvent("task-completed"))}catch(e){this._error=L(e,this.lang,r("save_error",this.lang))}finally{this._loading=!1}}_close(){this._open=!1}render(){if(!this._open)return o``;let e=this.lang||this.hass?.language||"en";return o`
${r("complete_title",e)}${this.taskName}
- ${this._error?o`
${this._error}
`:_} + ${this._error?o`
${this._error}
`:h} ${this.checklist.length>0?o`
@@ -1145,14 +1161,24 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" `)}
- `:_} + `:h} ${this.taskType==="reading"?o` `:_} + `:h} + ${this.consumesInfo.length?o`
+ ${this.consumesInfo.map(t=>o`
${t}
`)} +
`:h} + ${this.restockDefault!==null?o` + `:h}
${r("card_filter_status",e)}
- ${ji.map(d=>o` -
@@ -3411,12 +3486,12 @@ ${h?`
${h}
`:""}
${r("card_filter_objects",e)}
${this._loadingObjects?o`
${r("card_loading_objects",e)}
`:this._loadError?o`
${r("card_load_error",e)}
`:n.length===0?o`
${r("no_objects",e)}
`:o`
- ${n.map(d=>o` + ${n.map(c=>o` `)}
@@ -3433,30 +3508,52 @@ ${h?`
${h}
`:""} .hass=${this.hass} .value=${this._config.entity_ids||[]} .includeDomains=${["sensor","binary_sensor"]} - .includeEntities=${c} + .includeEntities=${d} @value-changed=${this._onEntitiesChanged} >
${r("card_filter_entities_help",e)}
+ + ${this._views.length>0?o` +
+
${r("card_saved_view",e)}
+ +
${r("card_saved_view_help",e)}
+
+ `:h} + this._valueChanged("show_header",d.target.checked)} + @change=${c=>this._valueChanged("show_header",c.target.checked)} > this._valueChanged("show_actions",d.target.checked)} + @change=${c=>this._valueChanged("show_actions",c.target.checked)} > this._valueChanged("compact",d.target.checked)} + @change=${c=>this._valueChanged("compact",c.target.checked)} > @@ -3464,11 +3561,11 @@ ${h?`
${h}
`:""} label="${r("card_max_items",e)}" type="number" .value=${String(this._config.max_items||0)} - @input=${d=>this._valueChanged("max_items",parseInt(d.target.value,10)||0)} + @input=${c=>this._valueChanged("max_items",parseInt(c.target.value,10)||0)} > - ${_} + ${h}
- `}};U.styles=E` + `}};M.styles=E` .editor { display: flex; flex-direction: column; @@ -3538,18 +3635,27 @@ ${h?`
${h}
`:""} cursor: pointer; } .object-row input { cursor: pointer; } - `,l([v({attribute:!1})],U.prototype,"hass",2),l([p()],U.prototype,"_config",2),l([p()],U.prototype,"_objects",2),l([p()],U.prototype,"_loadingObjects",2),l([p()],U.prototype,"_loadError",2);customElements.get("maintenance-supporter-card-editor")||customElements.define("maintenance-supporter-card-editor",U);Ze();z();var V=class extends ${constructor(){super(...arguments);this._config={type:"custom:maintenance-supporter-card"};this._objects=[];this._stats=null;this._unsub=null;this._dataLoaded=!1;this._lastConnection=null;this._onCompleted=async()=>{await this._loadData()}}get _lang(){return this.hass?.language||"en"}static getConfigElement(){return document.createElement("maintenance-supporter-card-editor")}static getStubConfig(){return{type:"custom:maintenance-supporter-card",show_header:!0,show_actions:!0,filter_status:["overdue","triggered","due_soon"],max_items:10}}setConfig(e){this._config=e}getCardSize(){return 3}connectedCallback(){super.connectedCallback()}disconnectedCallback(){super.disconnectedCallback(),this._unsub&&(this._unsub(),this._unsub=null),this._dataLoaded=!1,this._lastConnection=null}updated(e){super.updated(e);let t=this.hass?.language;if(t&&!Te(t)&&Ce(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{}}get _flatTasks(){let e=[],{filter_status:t,filter_objects:i,entity_ids:n,filter_due_min_days:c,filter_due_max_days:d,max_items:h}=this._config,u=n?.length?new Set(n):null,b=c!==void 0||d!==void 0;for(let x of this._objects)if(!(i?.length&&!i.includes(x.object.name))){for(let f of x.tasks)if(!f.is_done&&!(f.archived||x.object.archived)&&!(t?.length&&!t.includes(f.status))&&!(u&&!(f.sensor_entity_id&&u.has(f.sensor_entity_id)||f.binary_sensor_entity_id&&u.has(f.binary_sensor_entity_id)))){if(b){let k=f.days_until_due;if(k==null||c!==void 0&&kd)continue}e.push({entry_id:x.entry_id,object_name:x.object.name,task:f})}}let m={overdue:0,triggered:1,due_soon:2,ok:3};return e.sort((x,f)=>{let k=(m[x.task.status]??9)-(m[f.task.status]??9);return k!==0?k:(x.task.days_until_due??1/0)-(f.task.days_until_due??1/0)}),h&&h>0?e.slice(0,h):e}_openTaskDetail(e,t){tt(e,t)}render(){let e=this._lang,t=this._config.title||r("maintenance",e),i=this._config.show_header!==!1,n=this._config.show_actions!==!1,c=this._config.compact||!1,d=this._flatTasks,h=this._stats;return o` + .view-select { + padding: 8px; + border: 1px solid var(--divider-color); + border-radius: 4px; + background: var(--card-background-color, #fff); + color: var(--primary-text-color); + font-size: 14px; + max-width: 320px; + } + `,l([f({attribute:!1})],M.prototype,"hass",2),l([p()],M.prototype,"_config",2),l([p()],M.prototype,"_objects",2),l([p()],M.prototype,"_loadingObjects",2),l([p()],M.prototype,"_loadError",2),l([p()],M.prototype,"_views",2);customElements.get("maintenance-supporter-card-editor")||customElements.define("maintenance-supporter-card-editor",M);Ze();q();var F=class extends w{constructor(){super(...arguments);this._config={type:"custom:maintenance-supporter-card"};this._objects=[];this._stats=null;this._unsub=null;this._viewFilters=null;this._dataLoaded=!1;this._lastConnection=null;this._onCompleted=async()=>{await this._loadData()}}get _lang(){return this.hass?.language||"en"}static getConfigElement(){return document.createElement("maintenance-supporter-card-editor")}static getStubConfig(){return{type:"custom:maintenance-supporter-card",show_header:!0,show_actions:!0,filter_status:["overdue","triggered","due_soon"],max_items:10}}setConfig(e){let t=e.view_id!==this._config.view_id;this._config=e,t&&this._dataLoaded&&this.hass&&this._loadViewFilters()}getCardSize(){return 3}connectedCallback(){super.connectedCallback()}disconnectedCallback(){super.disconnectedCallback(),this._unsub&&(this._unsub(),this._unsub=null),this._dataLoaded=!1,this._lastConnection=null}updated(e){super.updated(e);let t=this.hass?.language;if(t&&!Te(t)&&Ce(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{}await this._loadViewFilters()}async _loadViewFilters(){if(!this._config.view_id){this._viewFilters=null;return}try{let t=((await this.hass.connection.sendMessagePromise({type:"maintenance_supporter/views/list"})).views||[]).find(i=>i.id===this._config.view_id);this._viewFilters=t?t.filters:null}catch{this._viewFilters=null}}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 _flatTasks(){let e=[],{filter_status:t,filter_objects:i,entity_ids:n,filter_due_min_days:d,filter_due_max_days:c,max_items:_}=this._config,u=n?.length?new Set(n):null,v=d!==void 0||c!==void 0,m=this._viewFilters,y=m?.user_id==="current_user"?this.hass.user?.id??null:m?.user_id??null;for(let A of this._objects)if(!(i?.length&&!i.includes(A.object.name))){for(let k of A.tasks)if(!k.is_done&&!(k.archived||A.object.archived)&&!(t?.length&&!t.includes(k.status))&&!(u&&!(k.sensor_entity_id&&u.has(k.sensor_entity_id)||k.binary_sensor_entity_id&&u.has(k.binary_sensor_entity_id)))){if(v){let R=k.days_until_due;if(R==null||d!==void 0&&Rc)continue}m&&(m.status&&k.status!==m.status||m.label&&!(k.labels||[]).includes(m.label)||y&&k.responsible_user_id!==y)||e.push({entry_id:A.entry_id,object_name:A.object.name,task:k})}}let b={overdue:0,triggered:1,due_soon:2,ok:3};return e.sort((A,k)=>{let R=(b[A.task.status]??9)-(b[k.task.status]??9);return R!==0?R:(A.task.days_until_due??1/0)-(k.task.days_until_due??1/0)}),_&&_>0?e.slice(0,_):e}_openTaskDetail(e,t){tt(e,t)}render(){let e=this._lang,t=this._config.title||r("maintenance",e),i=this._config.show_header!==!1,n=this._config.show_actions!==!1,d=this._config.compact||!1,c=this._flatTasks,_=this._stats;return o`

${t}

- ${i&&h?o` + ${i&&_?o`
- ${h.overdue>0?o`${h.overdue}`:_} - ${h.due_soon>0?o`${h.due_soon}`:_} - ${h.triggered>0?o`${h.triggered}`:_} + ${_.overdue>0?o`${_.overdue}`:h} + ${_.due_soon>0?o`${_.due_soon}`:h} + ${_.triggered>0?o`${_.triggered}`:h}
- `:_} + `:h} ${n?o` ${h}
`:""} > - `:_} + `:h}
- ${d.length===0?this._objects.some(u=>u.tasks.length>0)?o`
+ ${c.length===0?this._objects.some(u=>u.tasks.length>0)?o`
✓ ${r("card_all_caught_up",e)}
@@ -3576,12 +3682,12 @@ ${h?`
${h}
`:""}
${r("card_no_tasks_title",e)}
${r("card_no_tasks_cta",e)}
`:o` -
- ${d.map(({entry_id:u,object_name:b,task:m})=>o` +
+ ${c.map(({entry_id:u,object_name:v,task:m})=>o`
this._openTaskDetail(u,m.id)} title="${r("open_task",e)||"Open task"}"> -
+
${m.name} @@ -3589,9 +3695,9 @@ ${h?`
${h}
`:""} class="postponed-icon" icon="mdi:calendar-clock" title="${r("postponed",e)||"Postponed"}" - >`:_} + >`:h}
- ${c?_:o`
${b} · ${r(m.type,e)}
`} + ${d?h:o`
${v} · ${r(m.type,e)}
`}
${m.days_until_due!==null&&m.days_until_due!==void 0?m.days_until_due<0?o`${Math.abs(m.days_until_due)}${e.startsWith("de")?"T":"d"}`:m.days_until_due===0?r("today",e):`${m.days_until_due}${e.startsWith("de")?"T":"d"}`:m.trigger_active?"\u26A1":"\u2014"} @@ -3600,11 +3706,11 @@ ${h?`
${h}
`:""} {x.stopPropagation();let f=this.shadowRoot.querySelector("maintenance-complete-dialog");f.entryId=u,f.taskId=m.id,f.taskName=m.name,f.checklist=m.checklist||[],f.adaptiveEnabled=!!m.adaptive_config?.enabled,f.taskType=m.type||"",f.readingUnit=m.reading_unit||"",f.lang=e,f.open()}} + @click=${y=>{y.stopPropagation();let b=this.shadowRoot.querySelector("maintenance-complete-dialog");b.entryId=u,b.taskId=m.id,b.taskName=m.name,b.checklist=m.checklist||[],b.adaptiveEnabled=!!m.adaptive_config?.enabled,b.taskType=m.type||"",b.readingUnit=m.reading_unit||"",b.lang=e,b.open()}} > - `:_} + `:h}
`)}
@@ -3614,7 +3720,7 @@ ${h?`
${h}
`:""} .hass=${this.hass} @task-completed=${this._onCompleted} > - `}};V.styles=[Ie,E` + `}};F.styles=[Ie,E` ha-card { overflow: hidden; } .card-header { @@ -3698,7 +3804,7 @@ ${h?`
${h}
`:""} --mdc-icon-size: 18px; color: var(--primary-color); } - `],l([v({attribute:!1})],V.prototype,"hass",2),l([p()],V.prototype,"_config",2),l([p()],V.prototype,"_objects",2),l([p()],V.prototype,"_stats",2),l([p()],V.prototype,"_unsub",2);customElements.get("maintenance-supporter-card")||customElements.define("maintenance-supporter-card",V);window.customCards=window.customCards||[];window.customCards.push({type:"maintenance-supporter-card",name:"Maintenance Supporter",description:"Overview of your maintenance tasks with quick actions.",preview:!0});export{V as MaintenanceSupporterCard}; + `],l([f({attribute:!1})],F.prototype,"hass",2),l([p()],F.prototype,"_config",2),l([p()],F.prototype,"_objects",2),l([p()],F.prototype,"_stats",2),l([p()],F.prototype,"_unsub",2),l([p()],F.prototype,"_viewFilters",2);customElements.get("maintenance-supporter-card")||customElements.define("maintenance-supporter-card",F);window.customCards=window.customCards||[];window.customCards.push({type:"maintenance-supporter-card",name:"Maintenance Supporter",description:"Overview of your maintenance tasks with quick actions.",preview:!0});export{F as MaintenanceSupporterCard}; /*! Bundled license information: @lit/reactive-element/css-tag.js: diff --git a/custom_components/maintenance_supporter/frontend/maintenance-panel.js b/custom_components/maintenance_supporter/frontend/maintenance-panel.js index 132bc43..bc38da4 100644 --- a/custom_components/maintenance_supporter/frontend/maintenance-panel.js +++ b/custom_components/maintenance_supporter/frontend/maintenance-panel.js @@ -1,13 +1,26 @@ -var Ui=Object.defineProperty;var Bi=Object.getOwnPropertyDescriptor;var d=(r,n,e,t)=>{for(var i=t>1?void 0:t?Bi(n,e):n,a=r.length-1,l;a>=0;a--)(l=r[a])&&(i=(t?l(n,e,i):l(i))||i);return t&&i&&Ui(n,e,i),i};var Qe=globalThis,Je=Qe.ShadowRoot&&(Qe.ShadyCSS===void 0||Qe.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,xt=Symbol(),Ft=new WeakMap,Le=class{constructor(n,e,t){if(this._$cssResult$=!0,t!==xt)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=n,this.t=e}get styleSheet(){let n=this.o,e=this.t;if(Je&&n===void 0){let t=e!==void 0&&e.length===1;t&&(n=Ft.get(e)),n===void 0&&((this.o=n=new CSSStyleSheet).replaceSync(this.cssText),t&&Ft.set(e,n))}return n}toString(){return this.cssText}},Ut=r=>new Le(typeof r=="string"?r:r+"",void 0,xt),j=(r,...n)=>{let e=r.length===1?r[0]:n.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)+r[a+1],r[0]);return new Le(e,r,xt)},Bt=(r,n)=>{if(Je)r.adoptedStyleSheets=n.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of n){let t=document.createElement("style"),i=Qe.litNonce;i!==void 0&&t.setAttribute("nonce",i),t.textContent=e.cssText,r.appendChild(t)}},$t=Je?r=>r:r=>r instanceof CSSStyleSheet?(n=>{let e="";for(let t of n.cssRules)e+=t.cssText;return Ut(e)})(r):r;var{is:Wi,defineProperty:Vi,getOwnPropertyDescriptor:Ki,getOwnPropertyNames:Yi,getOwnPropertySymbols:Gi,getPrototypeOf:Qi}=Object,Ze=globalThis,Wt=Ze.trustedTypes,Ji=Wt?Wt.emptyScript:"",Zi=Ze.reactiveElementPolyfillSupport,Pe=(r,n)=>r,ze={toAttribute(r,n){switch(n){case Boolean:r=r?Ji:null;break;case Object:case Array:r=r==null?r:JSON.stringify(r)}return r},fromAttribute(r,n){let e=r;switch(n){case Boolean:e=r!==null;break;case Number:e=r===null?null:Number(r);break;case Object:case Array:try{e=JSON.parse(r)}catch{e=null}}return e}},Xe=(r,n)=>!Wi(r,n),Vt={attribute:!0,type:String,converter:ze,reflect:!1,useDefault:!1,hasChanged:Xe};Symbol.metadata??=Symbol("metadata"),Ze.litPropertyMetadata??=new WeakMap;var le=class extends HTMLElement{static addInitializer(n){this._$Ei(),(this.l??=[]).push(n)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(n,e=Vt){if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(n)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(n,e),!e.noAccessor){let t=Symbol(),i=this.getPropertyDescriptor(n,t,e);i!==void 0&&Vi(this.prototype,n,i)}}static getPropertyDescriptor(n,e,t){let{get:i,set:a}=Ki(this.prototype,n)??{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(n,c,t)},configurable:!0,enumerable:!0}}static getPropertyOptions(n){return this.elementProperties.get(n)??Vt}static _$Ei(){if(this.hasOwnProperty(Pe("elementProperties")))return;let n=Qi(this);n.finalize(),n.l!==void 0&&(this.l=[...n.l]),this.elementProperties=new Map(n.elementProperties)}static finalize(){if(this.hasOwnProperty(Pe("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(Pe("properties"))){let e=this.properties,t=[...Yi(e),...Gi(e)];for(let i of t)this.createProperty(i,e[i])}let n=this[Symbol.metadata];if(n!==null){let e=litPropertyMetadata.get(n);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(n){let e=[];if(Array.isArray(n)){let t=new Set(n.flat(1/0).reverse());for(let i of t)e.unshift($t(i))}else n!==void 0&&e.push($t(n));return e}static _$Eu(n,e){let t=e.attribute;return t===!1?void 0:typeof t=="string"?t:typeof n=="string"?n.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(n=>this.enableUpdating=n),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(n=>n(this))}addController(n){(this._$EO??=new Set).add(n),this.renderRoot!==void 0&&this.isConnected&&n.hostConnected?.()}removeController(n){this._$EO?.delete(n)}_$E_(){let n=new Map,e=this.constructor.elementProperties;for(let t of e.keys())this.hasOwnProperty(t)&&(n.set(t,this[t]),delete this[t]);n.size>0&&(this._$Ep=n)}createRenderRoot(){let n=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return Bt(n,this.constructor.elementStyles),n}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(n=>n.hostConnected?.())}enableUpdating(n){}disconnectedCallback(){this._$EO?.forEach(n=>n.hostDisconnected?.())}attributeChangedCallback(n,e,t){this._$AK(n,t)}_$ET(n,e){let t=this.constructor.elementProperties.get(n),i=this.constructor._$Eu(n,t);if(i!==void 0&&t.reflect===!0){let a=(t.converter?.toAttribute!==void 0?t.converter:ze).toAttribute(e,t.type);this._$Em=n,a==null?this.removeAttribute(i):this.setAttribute(i,a),this._$Em=null}}_$AK(n,e){let t=this.constructor,i=t._$Eh.get(n);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:ze;this._$Em=i;let c=l.fromAttribute(e,a.type);this[i]=c??this._$Ej?.get(i)??c,this._$Em=null}}requestUpdate(n,e,t,i=!1,a){if(n!==void 0){let l=this.constructor;if(i===!1&&(a=this[n]),t??=l.getPropertyOptions(n),!((t.hasChanged??Xe)(a,e)||t.useDefault&&t.reflect&&a===this._$Ej?.get(n)&&!this.hasAttribute(l._$Eu(n,t))))return;this.C(n,e,t)}this.isUpdatePending===!1&&(this._$ES=this._$EP())}C(n,e,{useDefault:t,reflect:i,wrapped:a},l){t&&!(this._$Ej??=new Map).has(n)&&(this._$Ej.set(n,l??e??this[n]),a!==!0||l!==void 0)||(this._$AL.has(n)||(this.hasUpdated||t||(e=void 0),this._$AL.set(n,e)),i===!0&&this._$Em!==n&&(this._$Eq??=new Set).add(n))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}let n=this.scheduleUpdate();return n!=null&&await n,!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 n=!1,e=this._$AL;try{n=this.shouldUpdate(e),n?(this.willUpdate(e),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(e)):this._$EM()}catch(t){throw n=!1,this._$EM(),t}n&&this._$AE(e)}willUpdate(n){}_$AE(n){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(n)),this.updated(n)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(n){return!0}update(n){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(n){}firstUpdated(n){}};le.elementStyles=[],le.shadowRootOptions={mode:"open"},le[Pe("elementProperties")]=new Map,le[Pe("finalized")]=new Map,Zi?.({ReactiveElement:le}),(Ze.reactiveElementVersions??=[]).push("2.1.2");var jt=globalThis,Kt=r=>r,et=jt.trustedTypes,Yt=et?et.createPolicy("lit-html",{createHTML:r=>r}):void 0,ei="$lit$",_e=`lit$${Math.random().toFixed(9).slice(2)}$`,ti="?"+_e,Xi=`<${ti}>`,ye=document,He=()=>ye.createComment(""),Oe=r=>r===null||typeof r!="object"&&typeof r!="function",Ct=Array.isArray,es=r=>Ct(r)||typeof r?.[Symbol.iterator]=="function",wt=`[ -\f\r]`,De=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,Gt=/-->/g,Qt=/>/g,be=RegExp(`>|${wt}(?:([^\\s"'>=/]+)(${wt}*=${wt}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`,"g"),Jt=/'/g,Zt=/"/g,ii=/^(?:script|style|textarea|title)$/i,Mt=r=>(n,...e)=>({_$litType$:r,strings:n,values:e}),o=Mt(1),z=Mt(2),ma=Mt(3),ce=Symbol.for("lit-noChange"),p=Symbol.for("lit-nothing"),Xt=new WeakMap,fe=ye.createTreeWalker(ye,129);function si(r,n){if(!Ct(r)||!r.hasOwnProperty("raw"))throw Error("invalid template strings array");return Yt!==void 0?Yt.createHTML(n):n}var ts=(r,n)=>{let e=r.length-1,t=[],i,a=n===2?"":n===3?"":"",l=De;for(let c=0;c"?(l=i??De,m=-1):g[1]===void 0?m=-2:(m=l.lastIndex-g[2].length,u=g[1],l=g[3]===void 0?be:g[3]==='"'?Zt:Jt):l===Zt||l===Jt?l=be:l===Gt||l===Qt?l=De:(l=be,i=void 0);let f=l===be&&r[c+1].startsWith("/>")?" ":"";a+=l===De?h+Xi:m>=0?(t.push(u),h.slice(0,m)+ei+h.slice(m)+_e+f):h+_e+(m===-2?c:f)}return[si(r,a+(r[e]||"")+(n===2?"":n===3?"":"")),t]},qe=class r{constructor({strings:n,_$litType$:e},t){let i;this.parts=[];let a=0,l=0,c=n.length-1,h=this.parts,[u,g]=ts(n,e);if(this.el=r.createElement(u,t),fe.currentNode=this.el.content,e===2||e===3){let m=this.el.content.firstChild;m.replaceWith(...m.childNodes)}for(;(i=fe.nextNode())!==null&&h.length0){i.textContent=et?et.emptyScript:"";for(let f=0;f2||t[0]!==""||t[1]!==""?(this._$AH=Array(t.length-1).fill(new String),this.strings=t):this._$AH=p}_$AI(n,e=this,t,i){let a=this.strings,l=!1;if(a===void 0)n=Te(this,n,e,0),l=!Oe(n)||n!==this._$AH&&n!==ce,l&&(this._$AH=n);else{let c=n,h,u;for(n=a[0],h=0;h{let t=e?.renderBefore??n,i=t._$litPart$;if(i===void 0){let a=e?.renderBefore??null;t._$litPart$=i=new Ne(n.insertBefore(He(),a),a,void 0,e??{})}return i._$AI(r),i};var Rt=globalThis,S=class extends le{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){let n=super.createRenderRoot();return this.renderOptions.renderBefore??=n.firstChild,n}update(n){let e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(n),this._$Do=ai(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return ce}};S._$litElement$=!0,S.finalized=!0,Rt.litElementHydrateSupport?.({LitElement:S});var ss=Rt.litElementPolyfillSupport;ss?.({LitElement:S});(Rt.litElementVersions??=[]).push("4.2.2");var ni=r=>(n,e)=>{e!==void 0?e.addInitializer(()=>{customElements.define(r,n)}):customElements.define(r,n)};var as={attribute:!0,type:String,converter:ze,reflect:!1,hasChanged:Xe},ns=(r=as,n,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"&&((r=Object.create(r)).wrapped=!0),a.set(e.name,r),t==="accessor"){let{name:l}=e;return{set(c){let h=n.get.call(this);n.set.call(this,c),this.requestUpdate(l,h,r,!0,c)},init(c){return c!==void 0&&this.C(l,void 0,r,c),c}}}if(t==="setter"){let{name:l}=e;return function(c){let h=this[l];n.call(this,c),this.requestUpdate(l,h,r,!0,c)}}throw Error("Unsupported decorator location: "+t)};function y(r){return(n,e)=>typeof e=="object"?ns(r,n,e):((t,i,a)=>{let l=i.hasOwnProperty(a);return i.constructor.createProperty(a,t),l?Object.getOwnPropertyDescriptor(i,a):void 0})(r,n,e)}function _(r){return y({...r,state:!0,attribute:!1})}var ri={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 +var Gi=Object.defineProperty;var Qi=Object.getOwnPropertyDescriptor;var d=(n,r,e,t)=>{for(var i=t>1?void 0:t?Qi(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&&Gi(r,e,i),i};var st=globalThis,at=st.ShadowRoot&&(st.ShadyCSS===void 0||st.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,St=Symbol(),Yt=new WeakMap,Oe=class{constructor(r,e,t){if(this._$cssResult$=!0,t!==St)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(at&&r===void 0){let t=e!==void 0&&e.length===1;t&&(r=Yt.get(e)),r===void 0&&((this.o=r=new CSSStyleSheet).replaceSync(this.cssText),t&&Yt.set(e,r))}return r}toString(){return this.cssText}},Gt=n=>new Oe(typeof n=="string"?n:n+"",void 0,St),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 Oe(e,n,St)},Qt=(n,r)=>{if(at)n.adoptedStyleSheets=r.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(let e of r){let t=document.createElement("style"),i=st.litNonce;i!==void 0&&t.setAttribute("nonce",i),t.textContent=e.cssText,n.appendChild(t)}},At=at?n=>n:n=>n instanceof CSSStyleSheet?(r=>{let e="";for(let t of r.cssRules)e+=t.cssText;return Gt(e)})(n):n;var{is:Zi,defineProperty:Ji,getOwnPropertyDescriptor:Xi,getOwnPropertyNames:es,getOwnPropertySymbols:ts,getPrototypeOf:is}=Object,rt=globalThis,Zt=rt.trustedTypes,ss=Zt?Zt.emptyScript:"",as=rt.reactiveElementPolyfillSupport,qe=(n,r)=>n,Ne={toAttribute(n,r){switch(r){case Boolean:n=n?ss: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}},nt=(n,r)=>!Zi(n,r),Jt={attribute:!0,type:String,converter:Ne,reflect:!1,useDefault:!1,hasChanged:nt};Symbol.metadata??=Symbol("metadata"),rt.litPropertyMetadata??=new WeakMap;var ue=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=Jt){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&&Ji(this.prototype,r,i)}}static getPropertyDescriptor(r,e,t){let{get:i,set:a}=Xi(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)??Jt}static _$Ei(){if(this.hasOwnProperty(qe("elementProperties")))return;let r=is(this);r.finalize(),r.l!==void 0&&(this.l=[...r.l]),this.elementProperties=new Map(r.elementProperties)}static finalize(){if(this.hasOwnProperty(qe("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(qe("properties"))){let e=this.properties,t=[...es(e),...ts(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(At(i))}else r!==void 0&&e.push(At(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 Qt(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:Ne).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:Ne;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??nt)(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){}};ue.elementStyles=[],ue.shadowRootOptions={mode:"open"},ue[qe("elementProperties")]=new Map,ue[qe("finalized")]=new Map,as?.({ReactiveElement:ue}),(rt.reactiveElementVersions??=[]).push("2.1.2");var Pt=globalThis,Xt=n=>n,ot=Pt.trustedTypes,ei=ot?ot.createPolicy("lit-html",{createHTML:n=>n}):void 0,ni="$lit$",fe=`lit$${Math.random().toFixed(9).slice(2)}$`,oi="?"+fe,rs=`<${oi}>`,Ee=document,Ue=()=>Ee.createComment(""),Be=n=>n===null||typeof n!="object"&&typeof n!="function",zt=Array.isArray,ns=n=>zt(n)||typeof n?.[Symbol.iterator]=="function",jt=`[ +\f\r]`,Fe=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,ti=/-->/g,ii=/>/g,we=RegExp(`>|${jt}(?:([^\\s"'>=/]+)(${jt}*=${jt}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`,"g"),si=/'/g,ai=/"/g,li=/^(?:script|style|textarea|title)$/i,Ht=n=>(r,...e)=>({_$litType$:n,strings:r,values:e}),o=Ht(1),D=Ht(2),wa=Ht(3),_e=Symbol.for("lit-noChange"),p=Symbol.for("lit-nothing"),ri=new WeakMap,ke=Ee.createTreeWalker(Ee,129);function ci(n,r){if(!zt(n)||!n.hasOwnProperty("raw"))throw Error("invalid template strings array");return ei!==void 0?ei.createHTML(r):r}var os=(n,r)=>{let e=n.length-1,t=[],i,a=r===2?"":r===3?"":"",l=Fe;for(let c=0;c"?(l=i??Fe,m=-1):g[1]===void 0?m=-2:(m=l.lastIndex-g[2].length,u=g[1],l=g[3]===void 0?we:g[3]==='"'?ai:si):l===ai||l===si?l=we:l===ti||l===ii?l=Fe:(l=we,i=void 0);let b=l===we&&n[c+1].startsWith("/>")?" ":"";a+=l===Fe?h+rs:m>=0?(t.push(u),h.slice(0,m)+ni+h.slice(m)+fe+b):h+fe+(m===-2?c:b)}return[ci(n,a+(n[e]||"")+(r===2?"":r===3?"":"")),t]},Ve=class n{constructor({strings:r,_$litType$:e},t){let i;this.parts=[];let a=0,l=0,c=r.length-1,h=this.parts,[u,g]=os(r,e);if(this.el=n.createElement(u,t),ke.currentNode=this.el.content,e===2||e===3){let m=this.el.content.firstChild;m.replaceWith(...m.childNodes)}for(;(i=ke.nextNode())!==null&&h.length0){i.textContent=ot?ot.emptyScript:"";for(let b=0;b2||t[0]!==""||t[1]!==""?(this._$AH=Array(t.length-1).fill(new String),this.strings=t):this._$AH=p}_$AI(r,e=this,t,i){let a=this.strings,l=!1;if(a===void 0)r=Ie(this,r,e,0),l=!Be(r)||r!==this._$AH&&r!==_e,l&&(this._$AH=r);else{let c=r,h,u;for(r=a[0],h=0;h{let t=e?.renderBefore??r,i=t._$litPart$;if(i===void 0){let a=e?.renderBefore??null;t._$litPart$=i=new We(r.insertBefore(Ue(),a),a,void 0,e??{})}return i._$AI(n),i};var Dt=globalThis,S=class extends ue{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=di(e,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return _e}};S._$litElement$=!0,S.finalized=!0,Dt.litElementHydrateSupport?.({LitElement:S});var cs=Dt.litElementPolyfillSupport;cs?.({LitElement:S});(Dt.litElementVersions??=[]).push("4.2.2");function ne(n){return!!n&&/^https?:\/\//i.test(n)}var pi=n=>(r,e)=>{e!==void 0?e.addInitializer(()=>{customElements.define(n,r)}):customElements.define(n,r)};var ds={attribute:!0,type:String,converter:Ne,reflect:!1,hasChanged:nt},ps=(n=ds,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 h=r.get.call(this);r.set.call(this,c),this.requestUpdate(l,h,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 h=this[l];r.call(this,c),this.requestUpdate(l,h,n,!0,c)}}throw Error("Unsupported decorator location: "+t)};function y(n){return(r,e)=>typeof e=="object"?ps(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 hi={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",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"};var $e="\u20AC",st={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)"},at={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"},It="en",li=(()=>{let r=window;return r.__msLocales||(r.__msLocales={store:{},inflight:{}}),r.__msLocales})(),xe=li.store;xe.en||(xe.en=ri);var os=new Set(["de","nl","fr","it","es","pt","ru","uk","pl","cs","sv","zh","da","fi","nb","ja","hi"]),ls="/maintenance_supporter_locales",Fe=li.inflight;function Lt(r){return(r||It).substring(0,2).toLowerCase()}function s(r,n){let e=Lt(n);return xe[e]?.[r]??xe.en[r]??r}function nt(r){let n=Lt(r);return n===It||n in xe}function ne(r){let n=Lt(r);return n===It||n in xe||!os.has(n)?Promise.resolve():(n in Fe||(Fe[n]=fetch(`${ls}/${n}.json`).then(e=>e.ok?e.json():null).then(e=>{e?xe[n]=e:delete Fe[n]}).catch(()=>{delete Fe[n]})),Fe[n])}function ci(r){let n=(r||"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"}[n]??"en-US"}function W(r,n){if(!r)return"\u2014";try{let e=r.includes("T")?r:r+"T00:00:00";return new Date(e).toLocaleDateString(ci(n),{day:"2-digit",month:"2-digit",year:"numeric"})}catch{return r}}function rt(r,n){if(!r)return"\u2014";try{let e=ci(n),t=new Date(r);return t.toLocaleDateString(e,{day:"2-digit",month:"2-digit",year:"numeric"})+" "+t.toLocaleTimeString(e,{hour:"2-digit",minute:"2-digit"})}catch{return r}}function Ae(r,n){if(r==null)return"\u2014";let e=n||"en";return r<0?`${Math.abs(r)} ${s("d_overdue",e)}`:r===0?s("today",e):`${r} ${s(r===1?"day":"days",e)}`}function oi(r,n,e){return r==null?"\u2014":`${r} ${s("unit_"+(n||"days"),e)}`}function it(r,n,e="long"){let t=(n||"en").substring(0,2);return new Date(Date.UTC(2024,0,1+r)).toLocaleDateString(t,{weekday:e,timeZone:"UTC"})}function Ue(r,n){let e=r.schedule,t=e?.offset?` ${e.offset>0?"+":"\u2212"}${Math.abs(e.offset)}d`:"";switch(e?.kind){case"weekdays":return((e.weekdays||[]).map(i=>it(i,n,"short")).join(" & ")||"\u2014")+t;case"nth_weekday":return e.weekday==null||e.nth==null?"\u2014":`${e.nth===-1?s("ord_last",n):s("ord_"+e.nth,n)} ${it(e.weekday,n,"long")}${t}`;case"day_of_month":return e.day==null?"\u2014":(e.day===-1?s(e.business?"last_business_day_month":"last_day_month",n):`${s("day_word",n)} ${e.day}`)+t;case"one_time":return r.due_date?W(r.due_date,n):s("one_time",n);case"manual":return s("manual",n);case"interval":return oi(e.every,e.unit,n)}return r.schedule_type==="one_time"?r.due_date?W(r.due_date,n):s("one_time",n):r.schedule_type==="manual"?s("manual",n):r.schedule_type==="sensor_based"?s("sensor_based",n):r.interval_days!=null?oi(r.interval_days,r.interval_unit,n):"\u2014"}function we(r,n){r.currentTarget.dispatchEvent(new CustomEvent("hass-more-info",{detail:{entityId:n},bubbles:!0,composed:!0}))}var ot=j` +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."};var Ke={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)"},Ye={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 Se="\u20AC",Ot="en",_i=(()=>{let n=window;return n.__msLocales||(n.__msLocales={store:{},inflight:{}}),n.__msLocales})(),Te=_i.store;Te.en||(Te.en=hi);var us=new Set(["de","nl","fr","it","es","pt","ru","uk","pl","cs","sv","zh","da","fi","nb","ja","hi"]),_s="/maintenance_supporter_locales",Ge=_i.inflight;function qt(n){return(n||Ot).substring(0,2).toLowerCase()}function s(n,r){let e=qt(r);return Te[e]?.[n]??Te.en[n]??n}function dt(n){let r=qt(n);return r===Ot||r in Te}function Y(n){let r=qt(n);return r===Ot||r in Te||!us.has(r)?Promise.resolve():(r in Ge||(Ge[r]=fetch(`${_s}/${r}.json`).then(e=>e.ok?e.json():null).then(e=>{e?Te[r]=e:delete Ge[r]}).catch(()=>{delete Ge[r]})),Ge[r])}function gi(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"}[r]??"en-US"}function W(n,r){if(!n)return"\u2014";try{let e=n.includes("T")?n:n+"T00:00:00";return new Date(e).toLocaleDateString(gi(r),{day:"2-digit",month:"2-digit",year:"numeric"})}catch{return n}}function pt(n,r){if(!n)return"\u2014";try{let e=gi(r),t=new Date(n);return t.toLocaleDateString(e,{day:"2-digit",month:"2-digit",year:"numeric"})+" "+t.toLocaleTimeString(e,{hour:"2-digit",minute:"2-digit"})}catch{return n}}function Re(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 ui(n,r,e){return n==null?"\u2014":`${n} ${s("unit_"+(r||"days"),e)}`}function ct(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 Qe(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=>ct(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)} ${ct(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?W(n.due_date,r):s("one_time",r);case"manual":return s("manual",r);case"interval":return ui(e.every,e.unit,r)}return n.schedule_type==="one_time"?n.due_date?W(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?ui(n.interval_days,n.interval_unit,r):"\u2014"}function Ae(n,r){n.currentTarget.dispatchEvent(new CustomEvent("hass-more-info",{detail:{entityId:r},bubbles:!0,composed:!0}))}var ht=A` + .field { display: flex; flex-direction: column; gap: 4px; } + .field-label { font-size: 12px; color: var(--secondary-text-color); } + .field-input { + padding: 8px 10px; font-size: 14px; + background: var(--secondary-background-color, rgba(0,0,0,0.06)); + color: var(--primary-text-color); + border: 1px solid var(--divider-color); border-radius: 6px; + font-family: inherit; width: 100%; box-sizing: border-box; + } + .field-input:focus { outline: none; border-color: var(--primary-color); } +`,ut=A` :host { --maint-ok-color: var(--success-color, #4caf50); --maint-due-soon-color: var(--warning-color, #ff9800); --maint-overdue-color: var(--error-color, #f44336); - --maint-triggered-color: #ff5722; + /* Theme-token first so it follows dark/custom themes (was a bare #ff5722, + inconsistent with STATUS_COLORS.triggered which already tokenised it). */ + --maint-triggered-color: var(--deep-orange-color, #ff5722); } .status-badge { @@ -29,14 +42,17 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" /* Shape icon so status is not conveyed by colour alone (accessibility). */ .status-badge ha-icon { --mdc-icon-size: 14px; margin-left: -1px; } - .status-badge.ok { background-color: var(--maint-ok-color); } - .status-badge.due_soon { background-color: var(--maint-due-soon-color); } + /* Light-background statuses (green/orange/grey) carry DARK text: white on + them fails even the 3:1 WCAG UI-contrast floor (2.2–2.8:1), while the + saturated statuses below keep white (≥3.1:1). Matches the calendar pills. */ + .status-badge.ok { background-color: var(--maint-ok-color); color: #000; } + .status-badge.due_soon { background-color: var(--maint-due-soon-color); color: #000; } .status-badge.overdue { background-color: var(--maint-overdue-color); } .status-badge.triggered { background-color: var(--maint-triggered-color); } /* Completed one-time task ("done") — muted blue-grey. */ .status-badge.done { background-color: var(--maint-done-color, #78909c); } /* v2.10.0: archived (retire-but-retain) — neutral grey, clearly inert. */ - .status-badge.archived { background-color: var(--disabled-color, #9e9e9e); } + .status-badge.archived { background-color: var(--disabled-color, #9e9e9e); color: #000; } /* v2.20 (N3): paused — frozen but present, info blue. */ .status-badge.paused { background-color: var(--info-color, #2196f3); } @@ -1130,17 +1146,17 @@ Test pressure`,checklist_help:"One step per line. Max 100 items.",err_too_long:" .stat-item { min-width: 60px; } .stat-value { font-size: 20px; } } -`;var cs={days:1,weeks:7,months:30.4368,years:365.25};function Pt(r,n){return!r||r<=0?0:r*(cs[n||"days"]??1)}function lt(r,n,e){let t=Pt(r,e);if(t<=0||n==null)return{pct:0,overflow:!1};let i=(t-n)/t*100;return{pct:Math.max(0,Math.min(100,i)),overflow:i>100}}function P(r){return String(r??"").replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n])}function di(r,n,e,t,i,a){let l=[[e.manufacturer,r.manufacturer],[e.model,r.model],[e.serial,r.serial_number],[e.installed,r.installation_date?t(r.installation_date):null],[e.warranty,r.warranty_expiry?t(r.warranty_expiry):null]].filter(([,u])=>!!u),c=n.map(u=>{let g=e.scheduleLabel(u);return` - ${P(u.name)} - ${P(e.typeLabel(u.type))} - ${P(e.statusLabel(u.status))} - ${P(g)} - ${P(u.last_performed?t(u.last_performed):e.none)} - ${P(u.next_due?t(u.next_due):e.none)} +`;var B={overviewTab:"msp-overview-tab",collapsedSections:"msp-collapsed-sections",chartRange:"msp-chart-range",chartHideOutliers:"msp-chart-hide-outliers",taskSort:"maintenance_supporter_sort",objectSort:"maintenance_supporter_object_sort",groupBy:"maintenance_supporter_groupby",objectView:"maintenance_supporter_object_view"};var gs={days:1,weeks:7,months:30.4368,years:365.25};function Nt(n,r){return!n||n<=0?0:n*(gs[r||"days"]??1)}function _t(n,r,e){let t=Nt(n,e);if(t<=0||r==null)return{pct:0,overflow:!1};let i=(t-r)/t*100;return{pct:Math.max(0,Math.min(100,i)),overflow:i>100}}function z(n){return String(n??"").replace(/[&<>"']/g,r=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[r])}function mi(n,r,e,t,i,a){let l=[[e.manufacturer,n.manufacturer],[e.model,n.model],[e.serial,n.serial_number],[e.installed,n.installation_date?t(n.installation_date):null],[e.warranty,n.warranty_expiry?t(n.warranty_expiry):null]].filter(([,u])=>!!u),c=r.map(u=>{let g=e.scheduleLabel(u);return` + ${z(u.name)} + ${z(e.typeLabel(u.type))} + ${z(e.statusLabel(u.status))} + ${z(g)} + ${z(u.last_performed?t(u.last_performed):e.none)} + ${z(u.next_due?t(u.next_due):e.none)} ${u.times_performed??0} - ${(u.total_cost??0).toFixed(2)} ${P(i)} - `}).join(""),h=n.reduce((u,g)=>u+(g.total_cost??0),0);return` -${P(e.title)} \u2014 ${P(r.name)} + ${(u.total_cost??0).toFixed(2)} ${z(i)} + `}).join(""),h=r.reduce((u,g)=>u+(g.total_cost??0),0);return` +${z(e.title)} \u2014 ${z(n.name)} -

${P(r.name)}

-

${P(e.title)} \xB7 ${P(e.generated)}: ${P(t(a))}

- ${l.length?`
${l.map(([u,g])=>`
${P(u)}
${P(g)}
`).join("")}
`:""} -

${P(e.tasksHeading)} (${n.length})

+

${z(n.name)}

+

${z(e.title)} \xB7 ${z(e.generated)}: ${z(t(a))}

+ ${l.length?`
${l.map(([u,g])=>`
${z(u)}
${z(g)}
`).join("")}
`:""} +

${z(e.tasksHeading)} (${r.length})

- - - + + + - ${c||``} - + ${c||``} +
${P(e.colTask)}${P(e.colType)}${P(e.colStatus)}${P(e.colSchedule)}${P(e.colLastDone)}${P(e.colNextDue)}${P(e.colTimes)}${P(e.colCost)}${z(e.colTask)}${z(e.colType)}${z(e.colStatus)}${z(e.colSchedule)}${z(e.colLastDone)}${z(e.colNextDue)}${z(e.colTimes)}${z(e.colCost)}
${P(e.none)}
${P(e.totalCost)}${h.toFixed(2)} ${P(i)}
${z(e.none)}
${z(e.totalCost)}${h.toFixed(2)} ${z(i)}
- ${r.notes?`
${P(e.notes)}: -${P(r.notes)}
`:""} -`}function zt(r,n=new Date){if(!r)return{kind:"none",days:null,date:null};let e=new Date(`${r}T00:00:00`);if(isNaN(e.getTime()))return{kind:"none",days:null,date:null};let t=Date.UTC(n.getFullYear(),n.getMonth(),n.getDate()),i=Date.UTC(e.getFullYear(),e.getMonth(),e.getDate()),a=Math.round((i-t)/864e5);return a<0?{kind:"expired",days:a,date:r}:a<=60?{kind:"expiring",days:a,date:r}:{kind:"valid",days:a,date:r}}var je=[{key:"name",labelKey:"name",required:!0},{key:"manufacturer",labelKey:"manufacturer"},{key:"model",labelKey:"model"},{key:"serial_number",labelKey:"serial_number_label"},{key:"installation_date",labelKey:"installed"},{key:"warranty_expiry",labelKey:"warranty"},{key:"area_id",labelKey:"area"},{key:"documentation_url",labelKey:"documentation_url_label"},{key:"notes",labelKey:"object_notes_label"},{key:"task_count",labelKey:"tasks"},{key:"actions",labelKey:"actions"}],ds=je.map(r=>r.key),ct=["name","manufacturer","model","serial_number","installation_date","warranty_expiry","area_id","task_count","actions"];function Be(r){if(!Array.isArray(r))return[...ct];let n=new Set,e=[];for(let t of r)typeof t=="string"&&ds.includes(t)&&!n.has(t)&&(n.add(t),e.push(t));return e.length?(e.includes("name")||e.unshift("name"),e):[...ct]}function dt(r,n,e){let t=new Blob([r],{type:e}),i=URL.createObjectURL(t),a=document.createElement("a");a.href=i,a.download=n,a.target="_blank",a.rel="noopener",a.style.display="none",document.body.appendChild(a),a.dispatchEvent(new MouseEvent("click")),document.body.removeChild(a),setTimeout(()=>URL.revokeObjectURL(i),6e4)}function pt(r,n){let e=document.createElement("a");e.href=r,e.download=n,e.target="_blank",e.rel="noopener",e.style.display="none",document.body.appendChild(e),e.dispatchEvent(new MouseEvent("click")),document.body.removeChild(e)}var D=r=>String(r??"").replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]);function pi(r,n,e,t,i,a,l,c,h){let u=[[e.object,D(n)],[e.type,D(e.typeLabel(r.type))],[e.interval,D(i(r))],[e.nextDue,r.next_due?D(t(r.next_due)):"\u2014"],[e.lastDone,r.last_performed?D(t(r.last_performed)):D(e.never)]];r.priority&&r.priority!=="normal"&&u.push([e.priority,D(r.priority)]);let g=(r.checklist||[]).map(v=>`
  • ${D(v)}
  • `).join(""),m=(v,f)=>v?`
    ${D(f)}
    `:"";return` -${D(r.name)} \u2014 ${D(e.title)} + ${n.notes?`
    ${z(e.notes)}: +${z(n.notes)}
    `:""} +`}function Ft(n,r=new Date){if(!n)return{kind:"none",days:null,date:null};let e=new Date(`${n}T00:00:00`);if(isNaN(e.getTime()))return{kind:"none",days:null,date:null};let t=Date.UTC(r.getFullYear(),r.getMonth(),r.getDate()),i=Date.UTC(e.getFullYear(),e.getMonth(),e.getDate()),a=Math.round((i-t)/864e5);return a<0?{kind:"expired",days:a,date:n}:a<=60?{kind:"expiring",days:a,date:n}:{kind:"valid",days:a,date:n}}var Le=[{key:"name",labelKey:"name",required:!0},{key:"manufacturer",labelKey:"manufacturer"},{key:"model",labelKey:"model"},{key:"serial_number",labelKey:"serial_number_label"},{key:"installation_date",labelKey:"installed"},{key:"warranty_expiry",labelKey:"warranty"},{key:"area_id",labelKey:"area"},{key:"documentation_url",labelKey:"documentation_url_label"},{key:"notes",labelKey:"object_notes_label"},{key:"task_count",labelKey:"tasks"},{key:"actions",labelKey:"actions"}],ms=Le.map(n=>n.key),gt=["name","manufacturer","model","serial_number","installation_date","warranty_expiry","area_id","task_count","actions"];function Ze(n){if(!Array.isArray(n))return[...gt];let r=new Set,e=[];for(let t of n)typeof t=="string"&&ms.includes(t)&&!r.has(t)&&(r.add(t),e.push(t));return e.length?(e.includes("name")||e.unshift("name"),e):[...gt]}function mt(n,r,e){let t=new Blob([n],{type:e}),i=URL.createObjectURL(t),a=document.createElement("a");a.href=i,a.download=r,a.target="_blank",a.rel="noopener",a.style.display="none",document.body.appendChild(a),a.dispatchEvent(new MouseEvent("click")),document.body.removeChild(a),setTimeout(()=>URL.revokeObjectURL(i),6e4)}function vt(n,r){let e=document.createElement("a");e.href=n,e.download=r,e.target="_blank",e.rel="noopener",e.style.display="none",document.body.appendChild(e),e.dispatchEvent(new MouseEvent("click")),document.body.removeChild(e)}var O=n=>String(n??"").replace(/[&<>"']/g,r=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[r]);function vi(n,r,e,t,i,a,l,c,h,u=[]){let g=[[e.object,O(r)],[e.type,O(e.typeLabel(n.type))],[e.interval,O(i(n))],[e.nextDue,n.next_due?O(t(n.next_due)):"\u2014"],[e.lastDone,n.last_performed?O(t(n.last_performed)):O(e.never)]];n.priority&&n.priority!=="normal"&&g.push([e.priority,O(n.priority)]);let m=(n.checklist||[]).map(b=>`
  • ${O(b)}
  • `).join(""),v=(b,$)=>b?`
    ${O($)}
    `:"";return` +${O(n.name)} \u2014 ${O(e.title)} -

    ${h}

    +

    ${p}

    ${m?`
    ${m}
    `:""}
    - QR Info -
    ${y}
    + QR Info +
    ${v}
    ${u?`
    - QR Complete -
    ${I}
    + QR Complete +
    ${f}
    `:""}
    -
    ${U(this._viewResult.url)}
    +
    ${z(this._viewResult.url)}