updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
+1 -1
View File
@@ -1 +1 @@
2026.7.1 2026.7.2
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

+1 -1
View File
@@ -1 +1 @@
{"pid": 71, "version": 1, "ha_version": "2026.7.1", "start_ts": 1783610735.5698383} {"pid": 71, "version": 1, "ha_version": "2026.7.2", "start_ts": 1784075696.7721176}
+996 -75
View File
File diff suppressed because it is too large Load Diff
@@ -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 %}"
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: <click> action'
default: []
selector:
action: {}
double_click_action_ch1:
name: 'Channel 1: <double-click> action'
default: []
selector:
action: {}
triple_click_action_ch1:
name: 'Channel 1: <triple-click> action'
default: []
selector:
action: {}
long_click_action_ch1:
name: 'Channel 1: <long-click> action'
description: ''
default: []
selector:
action: {}
on_hold_action_ch1:
name: 'Channel 1: <on-hold> 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 <user defined> 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 <dynamic> 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: '<relaxed>: one action call after scrolling
<instant>: many action calls while scrolling
Beware:
for <instant> 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 <user defined> only
Exposed variables:
- <scroll_clicks>: for <instant> mode always 1, for <relaxed> mode number
between 1 to 8
- <scroll_direction>: ''left'' or ''right''
'
default: []
selector:
action: {}
channel2_section:
name: Channel 2
collapsed: true
input:
click_action_ch2:
name: 'Channel 2: <click> action'
default: []
selector:
action: {}
double_click_action_ch2:
name: 'Channel 2: <double-click> action'
default: []
selector:
action: {}
triple_click_action_ch2:
name: 'Channel 2: <triple-click> action'
default: []
selector:
action: {}
long_click_action_ch2:
name: 'Channel 2: <long-click> action'
description: ''
default: []
selector:
action: {}
on_hold_action_ch2:
name: 'Channel 2: <on-hold> 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 <user defined> 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 <dynamic> 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: '- <relaxed>: one action call after scrolling
- <instant>: many action calls while scrolling
Beware:
- for <instant> 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 <user defined> only
Exposed variables:
- <scroll_clicks>: for <instant> mode always 1, for <relaxed> mode number
between 1 to 8
- <scroll_direction>: ''left'' or ''right''
'
default: []
selector:
action: {}
channel3_section:
name: Channel 3
collapsed: true
input:
click_action_ch3:
name: 'Channel 3: <click> action'
default: []
selector:
action: {}
double_click_action_ch3:
name: 'Channel 3: <double-click> action'
default: []
selector:
action: {}
triple_click_action_ch3:
name: 'Channel 3: <triple-click> action'
default: []
selector:
action: {}
long_click_action_ch3:
name: 'Channel 3: <long-click> action'
description: ''
default: []
selector:
action: {}
on_hold_action_ch3:
name: 'Channel 3: <on-hold> 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 <user defined> 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 <dynamic> 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: '- <relaxed>: one action call after scrolling
- <instant>: many action calls while scrolling
Beware:
- for <instant> 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 <user defined> only
Exposed variables:
- <scroll_clicks>: for <instant> mode always 1, for <relaxed> mode number
between 1 to 8
- <scroll_direction>: ''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 <instant> mode'
description: "<instant> mode uses standard actions volume_up/volume_down
instead of <volume step percentage> by default \n\nif this is to coarse
disable this option and adjust the <volume step percentage> 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 %}"
@@ -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
@@ -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
@@ -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 }}'
+64 -92
View File
@@ -148,12 +148,6 @@ class AlbumSlideshowCamera(Camera):
self._framebuffer: bytes | None = None 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. # Monotonic counter incremented every time a new still is committed.
# Exposed as the ``frame_id`` state attribute so the Lovelace card # Exposed as the ``frame_id`` state attribute so the Lovelace card
# has an unambiguous "new frame ready" signal even when other # has an unambiguous "new frame ready" signal even when other
@@ -244,6 +238,7 @@ class AlbumSlideshowCamera(Camera):
"latitude": getattr(cur, "latitude", None), "latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None), "longitude": getattr(cur, "longitude", None),
"location": getattr(cur, "location", None), "location": getattr(cur, "location", None),
"description": getattr(cur, "description", None),
# Structured per-image caption metadata. A single-element list for # Structured per-image caption metadata. A single-element list for
# normal slides; two elements (top/left first) for paired slides, # normal slides; two elements (top/left first) for paired slides,
# so the card can overlay an accurate date/location on each half. # 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, "portrait_mode": self.store.portrait_mode,
"order_mode": self.store.order_mode, "order_mode": self.store.order_mode,
"date_filter": self.store.date_filter, "date_filter": self.store.date_filter,
"missing_date_mode": self.store.missing_date_mode,
"paused": bool(self.store.paused), "paused": bool(self.store.paused),
"refresh_hours": int(self.store.refresh_hours), "refresh_hours": int(self.store.refresh_hours),
"aspect_ratio": self.store.aspect_ratio, "aspect_ratio": self.store.aspect_ratio,
@@ -282,6 +278,7 @@ class AlbumSlideshowCamera(Camera):
"location": getattr(cur, "location", None), "location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None), "latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None), "longitude": getattr(cur, "longitude", None),
"description": getattr(cur, "description", None),
} }
] ]
@@ -318,6 +315,7 @@ class AlbumSlideshowCamera(Camera):
cache_key = ( cache_key = (
id(raw), id(raw),
self.store.date_filter, self.store.date_filter,
self.store.missing_date_mode,
self.store.order_mode, self.store.order_mode,
) )
if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key): 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( filtered = playlist.filter_items(
raw, raw,
mode=self.store.date_filter, mode=self.store.date_filter,
missing_date=self.store.missing_date_mode,
) )
ordered = playlist.order_items(filtered, self.store.order_mode) ordered = playlist.order_items(filtered, self.store.order_mode)
self._effective_cache = (hash(cache_key), ordered) self._effective_cache = (hash(cache_key), ordered)
@@ -343,67 +342,32 @@ class AlbumSlideshowCamera(Camera):
return self._framebuffer return self._framebuffer
async def handle_async_mjpeg_stream(self, request): 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 This is what the more-info dialog and picture-glance live view use
pushes JPEG payloads into when a new still is committed. Visible (the camera advertises no live stream, so the frontend falls back to
transitions are now handled by the Lovelace card on the client ``/api/camera_proxy_stream``). We delegate to HA's still-stream
side, so this stream just emits the latest still per slide change. 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 # Imported lazily so the module still loads in test environments
# that stub out homeassistant without installing aiohttp. # that stub out homeassistant.
from aiohttp import web from homeassistant.components.camera import async_get_still_stream
boundary = "frame" return await async_get_still_stream(
response = web.StreamResponse( request,
status=200, self.async_camera_image,
reason="OK", self.content_type,
headers={ self.frame_interval,
"Content-Type": f"multipart/x-mixed-replace;boundary={boundary}",
"Cache-Control": "no-cache, private",
"Pragma": "no-cache",
},
) )
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. # Older HA cores may dispatch via the alt name; alias for compatibility.
async def async_handle_async_mjpeg_stream(self, request): 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: async def _wait_or_interrupt(self, timeout: float) -> bool:
"""Wait up to ``timeout`` seconds, returning True if interrupted. """Wait up to ``timeout`` seconds, returning True if interrupted.
Safe wrapper around clear() + wait_for() - callers don't have to Does NOT clear ``_interrupt_event`` - the render loop clears it once
worry about the ordering of the two operations. The clear() runs per cycle, before rendering, so a signal that arrives while we're
synchronously before the awaitable is created, so no interrupt can rendering (a "next slide" press, a coordinator/store change) survives
be lost on the single-threaded event loop. 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: try:
await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout) await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout)
return True return True
@@ -433,6 +402,11 @@ class AlbumSlideshowCamera(Camera):
raise raise
should_advance = False # Don't advance on the very first render should_advance = False # Don't advance on the very first render
while True: 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: try:
await self._render_cycle(advance=should_advance) await self._render_cycle(advance=should_advance)
self._consecutive_failures = 0 self._consecutive_failures = 0
@@ -453,9 +427,14 @@ class AlbumSlideshowCamera(Camera):
continue continue
interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval))) interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
if interrupted: if self._force_next:
should_advance = self._force_next # Explicit "next slide" request: always advance.
should_advance = True
self._force_next = False 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: else:
# Paused slideshows hold the current frame until the user # Paused slideshows hold the current frame until the user
# un-pauses or hits "next slide" explicitly. # 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_frames = meta.get("pair_frames") if meta else None
self._last_pair_orientation = meta.get("pair_orientation") 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() self.async_write_ha_state()
@property @property
@@ -528,27 +506,6 @@ class AlbumSlideshowCamera(Camera):
domain_data["compose_semaphore"] = sem domain_data["compose_semaphore"] = sem
return 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: def _do_advance(self, count: int, items: list) -> None:
"""Advance _index to the next slide and commit random-order position.""" """Advance _index to the next slide and commit random-order position."""
if count <= 0: if count <= 0:
@@ -651,12 +608,14 @@ class AlbumSlideshowCamera(Camera):
"location": getattr(cur, "location", None), "location": getattr(cur, "location", None),
"latitude": getattr(cur, "latitude", None), "latitude": getattr(cur, "latitude", None),
"longitude": getattr(cur, "longitude", None), "longitude": getattr(cur, "longitude", None),
"description": getattr(cur, "description", None),
}, },
{ {
"captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)), "captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)),
"location": getattr(other_item, "location", None), "location": getattr(other_item, "location", None),
"latitude": getattr(other_item, "latitude", None), "latitude": getattr(other_item, "latitude", None),
"longitude": getattr(other_item, "longitude", None), "longitude": getattr(other_item, "longitude", None),
"description": getattr(other_item, "description", None),
}, },
] ]
pair_meta = [f["captured_at"] for f in pair_frames] pair_meta = [f["captured_at"] for f in pair_frames]
@@ -876,11 +835,24 @@ class AlbumSlideshowCamera(Camera):
self._download_cache.put(url, data) self._download_cache.put(url, data)
return 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: async def _http_get(self, url: str) -> bytes | None:
session = async_get_clientsession(self.hass) session = async_get_clientsession(self.hass)
try: try:
async with async_timeout.timeout(30): 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() resp.raise_for_status()
content_type = resp.headers.get("Content-Type", "") content_type = resp.headers.get("Content-Type", "")
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import re import re
from typing import Any from typing import Any
@@ -7,6 +8,11 @@ import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult 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 ( from .const import (
DOMAIN, DOMAIN,
@@ -14,11 +20,23 @@ from .const import (
CONF_ALBUM_NAME, CONF_ALBUM_NAME,
CONF_ALBUM_URL, CONF_ALBUM_URL,
CONF_LOCAL_PATH, CONF_LOCAL_PATH,
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE, CONF_RECURSIVE,
CONF_REVERSE_GEOCODE, 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, DEFAULT_REVERSE_GEOCODE,
PROVIDER_GOOGLE_SHARED, PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER, PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE,
PROVIDER_IMMICH,
DEFAULT_RECURSIVE, DEFAULT_RECURSIVE,
) )
@@ -52,6 +70,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self) -> None: def __init__(self) -> None:
self._provider: str | None = 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 @staticmethod
@callback @callback
@@ -79,6 +103,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._provider = user_input[CONF_PROVIDER] self._provider = user_input[CONF_PROVIDER]
if self._provider == PROVIDER_LOCAL_FOLDER: if self._provider == PROVIDER_LOCAL_FOLDER:
return await self.async_step_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() return await self.async_step_google_shared()
schema = vol.Schema( schema = vol.Schema(
@@ -86,6 +114,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
vol.Required(CONF_PROVIDER, default=PROVIDER_GOOGLE_SHARED): vol.In({ vol.Required(CONF_PROVIDER, default=PROVIDER_GOOGLE_SHARED): vol.In({
PROVIDER_GOOGLE_SHARED: "Google Photos", PROVIDER_GOOGLE_SHARED: "Google Photos",
PROVIDER_LOCAL_FOLDER: "Local Folder", 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) 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): class LocalFolderOptionsFlow(config_entries.OptionsFlow):
"""Options for local-folder entries. """Options for local-folder entries.
@@ -6,6 +6,43 @@ CONF_ALBUM_NAME = "album_name"
CONF_LOCAL_PATH = "local_path" CONF_LOCAL_PATH = "local_path"
CONF_RECURSIVE = "recursive" CONF_RECURSIVE = "recursive"
CONF_IMAGE_CACHE_MB = "image_cache_mb" 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 # Local-folder option: when True (default) the coordinator does best-effort
# reverse geocoding of EXIF GPS coordinates via the public Nominatim # reverse geocoding of EXIF GPS coordinates via the public Nominatim
# (OpenStreetMap) endpoint and exposes a human-readable ``location`` # (OpenStreetMap) endpoint and exposes a human-readable ``location``
@@ -16,6 +53,8 @@ DEFAULT_REVERSE_GEOCODE = True
PROVIDER_GOOGLE_SHARED = "google_shared" PROVIDER_GOOGLE_SHARED = "google_shared"
PROVIDER_LOCAL_FOLDER = "local_folder" PROVIDER_LOCAL_FOLDER = "local_folder"
PROVIDER_MEDIA_SOURCE = "media_source"
PROVIDER_IMMICH = "immich"
FILL_COVER = "cover" FILL_COVER = "cover"
FILL_CONTAIN = "contain" FILL_CONTAIN = "contain"
@@ -60,6 +99,21 @@ DATE_FILTER_OPTIONS = [
] ]
DEFAULT_DATE_FILTER = DATE_FILTER_OFF 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_SLIDE_INTERVAL = 60
DEFAULT_REFRESH_HOURS = 24 DEFAULT_REFRESH_HOURS = 24
DEFAULT_FILL_MODE = FILL_BLUR DEFAULT_FILL_MODE = FILL_BLUR
+595 -12
View File
@@ -6,6 +6,7 @@ from datetime import timedelta
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
import re
from typing import Any from typing import Any
import async_timeout import async_timeout
@@ -20,12 +21,22 @@ from .const import (
CONF_PROVIDER, CONF_PROVIDER,
CONF_ALBUM_URL, CONF_ALBUM_URL,
CONF_LOCAL_PATH, CONF_LOCAL_PATH,
CONF_MEDIA_CONTENT_ID,
CONF_RECURSIVE, CONF_RECURSIVE,
CONF_REVERSE_GEOCODE, 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, DEFAULT_REVERSE_GEOCODE,
DOMAIN, DOMAIN,
PROVIDER_GOOGLE_SHARED, PROVIDER_GOOGLE_SHARED,
PROVIDER_LOCAL_FOLDER, PROVIDER_LOCAL_FOLDER,
PROVIDER_MEDIA_SOURCE,
PROVIDER_IMMICH,
) )
from .store import SlideshowStore from .store import SlideshowStore
@@ -53,6 +64,13 @@ class MediaItem:
latitude: float | None = None latitude: float | None = None
longitude: float | None = None longitude: float | None = None
location: str | 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. # True once the local-folder EXIF reader has visited this file.
# Prevents re-reading EXIF on every coordinator refresh and lets the # Prevents re-reading EXIF on every coordinator refresh and lets the
# background enrichment task skip already-processed files even after # background enrichment task skip already-processed files even after
@@ -63,6 +81,73 @@ class MediaItem:
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"} _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
_VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm", ".3gp", ".mts", ".m2ts"} _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 = (".", "@", "#") _SKIP_DIR_PREFIXES = (".", "@", "#")
@@ -74,6 +159,66 @@ def _pick_url(item: dict[str, Any]) -> str | None:
return 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/<id>=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 ``<id>`` 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: def _pick_int(d: dict[str, Any], *path: str) -> int | None:
cur: Any = d cur: Any = d
for p in path: 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_DATETIME_ORIGINAL = 36867 # DateTimeOriginal
_EXIF_TAG_OFFSET_TIME_ORIGINAL = 36881 # OffsetTimeOriginal (e.g. "+02:00") _EXIF_TAG_OFFSET_TIME_ORIGINAL = 36881 # OffsetTimeOriginal (e.g. "+02:00")
_EXIF_TAG_DATETIME = 306 # DateTime (modification time) _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_TAG_GPS_IFD = 34853 # Pointer to the GPS IFD
_EXIF_GPS_LAT_REF = 1 # "N" / "S" _EXIF_GPS_LAT_REF = 1 # "N" / "S"
_EXIF_GPS_LAT = 2 # rational tuple _EXIF_GPS_LAT = 2 # rational tuple
@@ -351,6 +497,128 @@ def _parse_exif_datetime(raw: Any, offset_raw: Any) -> int | None:
return 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]: def _read_local_exif(path: Path) -> dict[str, Any]:
"""Read EXIF metadata for a local file. """Read EXIF metadata for a local file.
@@ -382,17 +650,25 @@ def _read_local_exif(path: Path) -> dict[str, Any]:
try: try:
with Image.open(path) as img: with Image.open(path) as img:
exif = img.getexif() 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: if not exif:
return out 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 gps = None
try: try:
gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None gps = exif.get_ifd(_EXIF_TAG_GPS_IFD) or None
@@ -550,13 +826,18 @@ def _merge_prior_enrichment(
item.longitude = prev.longitude item.longitude = prev.longitude
if prev.location and not item.location: if prev.location and not item.location:
item.location = prev.location item.location = prev.location
if prev.description and not item.description:
item.description = prev.description
if prev.exif_scanned: if prev.exif_scanned:
item.exif_scanned = True item.exif_scanned = True
class AlbumCoordinator(DataUpdateCoordinator): class AlbumCoordinator(DataUpdateCoordinator):
# Bump when the persisted item shape changes incompatibly. # 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 # Bump independently of the items cache - the geocode cache is
# keyed by coordinate and is safe to keep across item-shape changes. # keyed by coordinate and is safe to keep across item-shape changes.
_GEOCODE_CACHE_VERSION = 1 _GEOCODE_CACHE_VERSION = 1
@@ -570,6 +851,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
self.album_url: str | None = entry.data.get(CONF_ALBUM_URL) self.album_url: str | None = entry.data.get(CONF_ALBUM_URL)
self.local_path: str | None = entry.data.get(CONF_LOCAL_PATH) self.local_path: str | None = entry.data.get(CONF_LOCAL_PATH)
self.recursive: bool = bool(entry.data.get(CONF_RECURSIVE, True)) 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 # Persist the most recent successful album fetch so that a transient
# network/Google failure doesn't blank the slideshow on restart. # network/Google failure doesn't blank the slideshow on restart.
@@ -631,6 +916,10 @@ class AlbumCoordinator(DataUpdateCoordinator):
data = await self._update_local_folder() data = await self._update_local_folder()
elif self.provider == PROVIDER_GOOGLE_SHARED: elif self.provider == PROVIDER_GOOGLE_SHARED:
data = await self._update_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: else:
raise UpdateFailed(f"Unsupported provider: {self.provider}") raise UpdateFailed(f"Unsupported provider: {self.provider}")
except UpdateFailed: except UpdateFailed:
@@ -644,9 +933,9 @@ class AlbumCoordinator(DataUpdateCoordinator):
raise raise
items = data.get("items") or [] items = data.get("items") or []
if self.provider == PROVIDER_LOCAL_FOLDER and items: if self.provider in (PROVIDER_LOCAL_FOLDER, PROVIDER_IMMICH) and items:
# Carry forward EXIF/geocode metadata for files we've already # Carry forward EXIF/geocode metadata for items we've already
# scanned this session; new files get filled in by the # scanned this session; new items get filled in by the
# background worker below. # background worker below.
prior_items = (self.data or {}).get("items") if isinstance(self.data, dict) else None prior_items = (self.data or {}).get("items") if isinstance(self.data, dict) else None
if prior_items: if prior_items:
@@ -740,6 +1029,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
latitude=raw.get("latitude"), latitude=raw.get("latitude"),
longitude=raw.get("longitude"), longitude=raw.get("longitude"),
location=raw.get("location"), location=raw.get("location"),
description=raw.get("description"),
source_id=raw.get("source_id"),
exif_scanned=bool(raw.get("exif_scanned", False)), exif_scanned=bool(raw.get("exif_scanned", False)),
)) ))
except Exception: except Exception:
@@ -770,6 +1061,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
"latitude": it.latitude, "latitude": it.latitude,
"longitude": it.longitude, "longitude": it.longitude,
"location": it.location, "location": it.location,
"description": it.description,
"source_id": it.source_id,
"exif_scanned": it.exif_scanned, "exif_scanned": it.exif_scanned,
} }
for it in items for it in items
@@ -839,6 +1132,261 @@ class AlbumCoordinator(DataUpdateCoordinator):
"items": items, "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: async def _enrich_items_background(self, data: dict[str, Any]) -> None:
"""Read EXIF for unscanned local files, then reverse-geocode. """Read EXIF for unscanned local files, then reverse-geocode.
@@ -865,6 +1413,24 @@ class AlbumCoordinator(DataUpdateCoordinator):
await asyncio.sleep(0) await asyncio.sleep(0)
continue 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 url = item.url
if not url.startswith("file://"): if not url.startswith("file://"):
item.exif_scanned = True item.exif_scanned = True
@@ -883,6 +1449,8 @@ class AlbumCoordinator(DataUpdateCoordinator):
if "captured_at" in info: if "captured_at" in info:
item.captured_at = info["captured_at"] item.captured_at = info["captured_at"]
if "description" in info:
item.description = info["description"]
if "latitude" in info and "longitude" in info: if "latitude" in info and "longitude" in info:
item.latitude = info["latitude"] item.latitude = info["latitude"]
item.longitude = info["longitude"] item.longitude = info["longitude"]
@@ -1202,6 +1770,21 @@ class AlbumCoordinator(DataUpdateCoordinator):
byte_size=byte_size, 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 # Pick the source with more items; prefer publicalbum.org on a tie
# because its URLs come pre-decorated with size hints. # because its URLs come pre-decorated with size hints.
if len(scraped_items) > len(api_items): if len(scraped_items) > len(api_items):
+375
View File
@@ -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}")
@@ -3,10 +3,10 @@
"name": "Album Slideshow Camera", "name": "Album Slideshow Camera",
"codeowners": ["@eyalgal"], "codeowners": ["@eyalgal"],
"config_flow": true, "config_flow": true,
"dependencies": ["http", "frontend"], "dependencies": ["http", "frontend", "media_source"],
"documentation": "https://github.com/eyalgal/album_slideshow", "documentation": "https://github.com/eyalgal/album_slideshow",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/eyalgal/album_slideshow/issues", "issue_tracker": "https://github.com/eyalgal/album_slideshow/issues",
"requirements": ["Pillow"], "requirements": ["Pillow"],
"version": "1.0.0" "version": "1.2.2"
} }
+31 -6
View File
@@ -17,6 +17,9 @@ from .const import (
DATE_FILTER_ON_THIS_DAY, DATE_FILTER_ON_THIS_DAY,
DATE_FILTER_THIS_MONTH, DATE_FILTER_THIS_MONTH,
DATE_FILTER_THIS_YEAR, DATE_FILTER_THIS_YEAR,
DEFAULT_MISSING_DATE_MODE,
MISSING_DATE_EXCLUDE,
MISSING_DATE_USE_UPLOADED,
ORDER_ALBUM, ORDER_ALBUM,
ORDER_NEWEST_ADDED, ORDER_NEWEST_ADDED,
ORDER_NEWEST_TAKEN, ORDER_NEWEST_TAKEN,
@@ -72,12 +75,20 @@ def filter_items(
items: Iterable[T], items: Iterable[T],
*, *,
mode: str, mode: str,
missing_date: str = DEFAULT_MISSING_DATE_MODE,
now: datetime | None = None, now: datetime | None = None,
) -> list[T]: ) -> 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 ``missing_date`` decides what happens to photos that have no EXIF
``on_this_day`` (treated as a strict filter). ``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. ``now`` is overridable for deterministic tests.
""" """
@@ -93,11 +104,25 @@ def filter_items(
out: list[T] = [] out: list[T] = []
for it in items: for it in items:
ts = getattr(it, "captured_at", None) ts = getattr(it, "captured_at", None)
if not isinstance(ts, int): if isinstance(ts, int):
if not strict: if pred(ts):
out.append(it) out.append(it)
continue 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) out.append(it)
return out return out
@@ -19,6 +19,8 @@ from .const import (
ORDER_RANDOM, ORDER_RANDOM,
DATE_FILTER_OPTIONS, DATE_FILTER_OPTIONS,
DATE_FILTER_OFF, DATE_FILTER_OFF,
MISSING_DATE_OPTIONS,
MISSING_DATE_USE_UPLOADED,
) )
from .store import SlideshowStore from .store import SlideshowStore
@@ -39,6 +41,7 @@ async def async_setup_entry(
AspectRatioSelect(entry, store), AspectRatioSelect(entry, store),
MaxResolutionSelect(entry, store), MaxResolutionSelect(entry, store),
DateFilterSelect(entry, store), DateFilterSelect(entry, store),
MissingDateModeSelect(entry, store),
] ]
) )
@@ -239,3 +242,31 @@ class DateFilterSelect(_BaseSelect):
if old and old.state in self.options: if old and old.state in self.options:
self.store.date_filter = old.state self.store.date_filter = old.state
self.store.notify() 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()
@@ -15,6 +15,7 @@ from .const import (
DEFAULT_IMAGE_CACHE_MB, DEFAULT_IMAGE_CACHE_MB,
DEFAULT_MAX_RESOLUTION, DEFAULT_MAX_RESOLUTION,
DEFAULT_DATE_FILTER, 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 mode (preset windows like this_year / on_this_day).
date_filter: str = DEFAULT_DATE_FILTER 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. # Pause toggle - when True, the slideshow holds on the current frame.
paused: bool = False paused: bool = False
+36 -1
View File
@@ -24,11 +24,46 @@
"local_path": "Folder path", "local_path": "Folder path",
"recursive": "Include subfolders" "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": { "error": {
"invalid_album_url": "That does not look like a Google Photos shared album link.", "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": { "options": {
@@ -24,11 +24,46 @@
"local_path": "Folder path", "local_path": "Folder path",
"recursive": "Include subfolders" "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": { "error": {
"invalid_album_url": "That does not look like a Google Photos shared album link.", "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": { "options": {
@@ -26,7 +26,7 @@
* tap_action: none # none | more-info * tap_action: none # none | more-info
*/ */
const VERSION = "1.0.0"; const VERSION = "1.2.2";
const ANIMATED_TRANSITIONS = [ const ANIMATED_TRANSITIONS = [
"fade", "fade",
@@ -46,10 +46,10 @@ const TRANSITIONS = new Set(["random", "none", ...ANIMATED_TRANSITIONS]);
const FIT_MODES = new Set(["auto", "cover", "contain"]); const FIT_MODES = new Set(["auto", "cover", "contain"]);
// Caption overlay (date / location). ``show`` is an ordered subset of // Caption overlay (date / location / description). ``show`` is an ordered
// these fields; ``position`` is one of a 3x3 anchor grid; ``date_format`` // subset of these fields; ``position`` is one of a 3x3 anchor grid;
// is one of the named presets below or a custom token string. // ``date_format`` is one of the named presets below or a custom token string.
const CAPTION_FIELDS = ["date", "location"]; const CAPTION_FIELDS = ["date", "location", "description"];
const CAPTION_POSITIONS = new Set([ const CAPTION_POSITIONS = new Set([
"top-left", "top-left",
"top-center", "top-center",
@@ -492,6 +492,7 @@ function createAlbumSlideshowCardClass(Base) {
location: attrs.location, location: attrs.location,
latitude: attrs.latitude, latitude: attrs.latitude,
longitude: attrs.longitude, longitude: attrs.longitude,
description: attrs.description,
} }
: null; : null;
this._loadAndSwap(url, fit, blurBackdrop, captionData); this._loadAndSwap(url, fit, blurBackdrop, captionData);
@@ -644,6 +645,7 @@ function createAlbumSlideshowCardClass(Base) {
location: data.location ?? null, location: data.location ?? null,
latitude: data.latitude ?? null, latitude: data.latitude ?? null,
longitude: data.longitude ?? null, longitude: data.longitude ?? null,
description: data.description ?? null,
}, },
]; ];
} }
@@ -656,6 +658,8 @@ function createAlbumSlideshowCardClass(Base) {
if (txt) lines.push(txt); if (txt) lines.push(txt);
} else if (field === "location") { } else if (field === "location") {
if (frame.location) lines.push(String(frame.location)); if (frame.location) lines.push(String(frame.location));
} else if (field === "description") {
if (frame.description) lines.push(String(frame.description));
} }
} }
return lines; return lines;
@@ -890,6 +894,7 @@ const TAP_OPTIONS = [
const CAPTION_SHOW_OPTIONS = [ const CAPTION_SHOW_OPTIONS = [
{ value: "date", label: "Date" }, { value: "date", label: "Date" },
{ value: "location", label: "Location" }, { value: "location", label: "Location" },
{ value: "description", label: "Description" },
]; ];
const CAPTION_POSITION_OPTIONS = [ const CAPTION_POSITION_OPTIONS = [
@@ -952,6 +957,7 @@ const CAPTION_DEFAULTS = {
const LIVE_FIELDS = [ const LIVE_FIELDS = [
"paused", "paused",
"date_filter", "date_filter",
"missing_date_mode",
"portrait_mode", "portrait_mode",
"order_mode", "order_mode",
"slide_interval", "slide_interval",
@@ -962,6 +968,7 @@ const LIVE_FIELDS = [
const LIVE_SUFFIX = { const LIVE_SUFFIX = {
paused: "_paused", paused: "_paused",
date_filter: "_date_filter", date_filter: "_date_filter",
missing_date_mode: "_missing_date_mode",
portrait_mode: "_portrait_mode", portrait_mode: "_portrait_mode",
order_mode: "_order_mode", order_mode: "_order_mode",
slide_interval: "_interval", slide_interval: "_interval",
@@ -974,6 +981,7 @@ const LIVE_SUFFIX = {
const LIVE_LABELS = { const LIVE_LABELS = {
live_paused: "Pause slideshow", live_paused: "Pause slideshow",
live_date_filter: "Date filter", live_date_filter: "Date filter",
live_missing_date_mode: "Missing capture date",
live_portrait_mode: "Orientation mismatch mode", live_portrait_mode: "Orientation mismatch mode",
live_order_mode: "Order mode", live_order_mode: "Order mode",
live_slide_interval: "Slide interval (seconds)", live_slide_interval: "Slide interval (seconds)",
@@ -1130,6 +1138,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
} }
for (const [field, id] of [ for (const [field, id] of [
["date_filter", s.date_filter], ["date_filter", s.date_filter],
["missing_date_mode", s.missing_date_mode],
["portrait_mode", s.portrait_mode], ["portrait_mode", s.portrait_mode],
["order_mode", s.order_mode], ["order_mode", s.order_mode],
]) { ]) {
@@ -1263,7 +1272,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
}, },
{ {
type: "expandable", type: "expandable",
title: "Caption (date & location)", title: "Caption (date, location & description)",
icon: "mdi:format-text", icon: "mdi:format-text",
schema: [ schema: [
{ name: "caption_enabled", selector: { boolean: {} } }, { name: "caption_enabled", selector: { boolean: {} } },
@@ -1377,7 +1386,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
const e = st(s.paused); const e = st(s.paused);
out.live_paused = !!e && e.state === "on"; 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]) { if (s[f]) {
const e = st(s[f]); const e = st(s[f]);
out[`live_${f}`] = e ? e.state : ""; 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.", "How long the card freezes its slide after a tap. 0 disables it.",
caption_date_format: caption_date_format:
"Pick a preset or type a custom format (YYYY, MMMM, MMM, MM, DD, D).", "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: 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_color: "CSS color, e.g. #ffffff or white.",
caption_font_size: "CSS size, e.g. 14px, 1.1em.", caption_font_size: "CSS size, e.g. 14px, 1.1em.",
live_paused: live_paused:
"These control the Album Slideshow integration directly and apply everywhere this album is shown, not only this card.", "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] || ""; return helpers[s.name] || "";
}; };
@@ -1558,6 +1571,7 @@ function createAlbumSlideshowCardEditorClass(Base) {
}); });
} else if ( } else if (
field === "date_filter" || field === "date_filter" ||
field === "missing_date_mode" ||
field === "portrait_mode" || field === "portrait_mode" ||
field === "order_mode" field === "order_mode"
) { ) {
@@ -1636,7 +1650,9 @@ function createAlbumSlideshowCardEditorClass(Base) {
if (data.caption_enabled) { if (data.caption_enabled) {
let show = data.caption_show; let show = data.caption_show;
if (!Array.isArray(show)) show = show ? [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) { if (show.length > 0) {
const cap = { show }; const cap = { show };
const pos = data.caption_position || CAPTION_DEFAULTS.position; const pos = data.caption_position || CAPTION_DEFAULTS.position;
+13 -14
View File
@@ -6,8 +6,12 @@ import logging
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_RESOURCES from homeassistant.const import CONF_RESOURCES
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import (
from homeassistant.helpers import device_registry as dr config_validation as cv,
)
from homeassistant.helpers import (
device_registry as dr,
)
from . import const from . import const
from .const import ( from .const import (
@@ -103,6 +107,9 @@ __all__ = [
_LOGGER = logging.getLogger(__name__) _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 async def async_setup(hass: HomeAssistant, config_entry: MailAndPackagesConfigEntry): # pylint: disable=unused-argument
"""Disallow configuration via YAML.""" """Disallow configuration via YAML."""
return True return True
@@ -133,21 +140,13 @@ async def async_setup_entry(
# Setup the data coordinator # Setup the data coordinator
coordinator = MailDataUpdateCoordinator(hass, config, config_entry) 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=[]) 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) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True return True
@@ -78,6 +78,8 @@ class PackagesBinarySensor(CoordinatorEntity, BinarySensorEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if the image is updated.""" """Return True if the image is updated."""
if self.coordinator.data is None:
return False
if self._type in self.coordinator.data: if self._type in self.coordinator.data:
_LOGGER.debug( _LOGGER.debug(
"binary_sensor: %s value: %s", "binary_sensor: %s value: %s",
+11 -5
View File
@@ -182,7 +182,13 @@ class MailCam(CoordinatorEntity, Camera):
def _read_file(path: str) -> bytes: def _read_file(path: str) -> bytes:
with Path(path).open("rb") as f: 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: try:
image_bytes = await self.hass.async_add_executor_job( image_bytes = await self.hass.async_add_executor_job(
@@ -273,7 +279,7 @@ class MailCam(CoordinatorEntity, Camera):
if required_keys.issubset(self.coordinator.data): if required_keys.issubset(self.coordinator.data):
image = self.coordinator.data[ATTR_USPS_IMAGE] image = self.coordinator.data[ATTR_USPS_IMAGE]
path = self.coordinator.data[ATTR_IMAGE_PATH] 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) self._is_generic = not self.coordinator.data.get("usps_update", False)
_LOGGER.debug( _LOGGER.debug(
"usps_camera camera - file path set to: %s", "usps_camera camera - file path set to: %s",
@@ -313,7 +319,7 @@ class MailCam(CoordinatorEntity, Camera):
return return
image_path = self.coordinator.data.get(ATTR_IMAGE_PATH, "") 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") gif_path = str(full_storage_path / "generic_deliveries.gif")
resized_images = await self.hass.async_add_executor_job( resized_images = await self.hass.async_add_executor_job(
@@ -383,7 +389,7 @@ class MailCam(CoordinatorEntity, Camera):
image = self.coordinator.data[image_attr] image = self.coordinator.data[image_attr]
path = f"{self.coordinator.data[ATTR_IMAGE_PATH]}{path_suffix}" 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( is_no_mail = image.startswith(
no_mail_check, no_mail_check,
@@ -448,7 +454,7 @@ class MailCam(CoordinatorEntity, Camera):
image = self.coordinator.data[image_attr] image = self.coordinator.data[image_attr]
image_path = self.coordinator.data[ATTR_IMAGE_PATH].rstrip("/") + "/" image_path = self.coordinator.data[ATTR_IMAGE_PATH].rstrip("/") + "/"
path = f"{image_path}{base_name}/" 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( _LOGGER.debug(
"=== %s CAMERA UPDATE === coordinator %s = '%s'", "=== %s CAMERA UPDATE === coordinator %s = '%s'",
@@ -57,6 +57,7 @@ from .const import (
CONF_STORAGE, CONF_STORAGE,
CONF_UPS_CUSTOM_IMG, CONF_UPS_CUSTOM_IMG,
CONF_UPS_CUSTOM_IMG_FILE, CONF_UPS_CUSTOM_IMG_FILE,
CONF_USPS_PLACEHOLDER,
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
CONF_WALMART_CUSTOM_IMG, CONF_WALMART_CUSTOM_IMG,
CONF_WALMART_CUSTOM_IMG_FILE, CONF_WALMART_CUSTOM_IMG_FILE,
@@ -89,6 +90,7 @@ from .const import (
DEFAULT_STORAGE, DEFAULT_STORAGE,
DEFAULT_UPS_CUSTOM_IMG, DEFAULT_UPS_CUSTOM_IMG,
DEFAULT_UPS_CUSTOM_IMG_FILE, DEFAULT_UPS_CUSTOM_IMG_FILE,
DEFAULT_USPS_PLACEHOLDER,
DEFAULT_WALMART_CUSTOM_IMG, DEFAULT_WALMART_CUSTOM_IMG,
DEFAULT_WALMART_CUSTOM_IMG_FILE, DEFAULT_WALMART_CUSTOM_IMG_FILE,
DOMAIN, DOMAIN,
@@ -198,7 +200,9 @@ async def _check_forwarded_emails(user_input: dict[str, Any]) -> list[str]:
return errors 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.""" """Validate path and file inputs."""
# List of (Toggle Key, File Key, Error Key) # List of (Toggle Key, File Key, Error Key)
file_checks = [ 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: for toggle, file_key, error_key in file_checks:
if user_input.get(toggle) and file_key in user_input: 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" errors[error_key] = "file_not_found"
if CONF_STORAGE in user_input: 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" 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] 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. """Validate user input from config flow.
Returns tuple with error messages and modified user_input 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" errors[CONF_GENERATE_MP4] = "ffmpeg_not_found"
# Validate file paths # 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 # Normalize CONF_FOLDER: if it has exactly 1 folder, store as string
if CONF_FOLDER in user_input: if CONF_FOLDER in user_input:
@@ -555,6 +567,10 @@ async def _get_schema_step_2(
CONF_GENERATE_MP4, CONF_GENERATE_MP4,
default=_get_default(CONF_GENERATE_MP4, False), default=_get_default(CONF_GENERATE_MP4, False),
): cv.boolean, ): cv.boolean,
vol.Optional(
CONF_USPS_PLACEHOLDER,
default=_get_default(CONF_USPS_PLACEHOLDER, DEFAULT_USPS_PLACEHOLDER),
): cv.boolean,
vol.Optional( vol.Optional(
CONF_ALLOW_EXTERNAL, CONF_ALLOW_EXTERNAL,
default=_get_default(CONF_ALLOW_EXTERNAL, False), default=_get_default(CONF_ALLOW_EXTERNAL, False),
@@ -975,7 +991,7 @@ class MailAndPackagesFlowHandler(
"""Configure form step 2.""" """Configure form step 2."""
self._errors = {} self._errors = {}
if user_input is not None: 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) self._data.update(user_input)
_LOGGER.debug("RESOURCES: %s", self._data[CONF_RESOURCES]) _LOGGER.debug("RESOURCES: %s", self._data[CONF_RESOURCES])
if len(self._errors) == 0: if len(self._errors) == 0:
@@ -1012,12 +1028,13 @@ class MailAndPackagesFlowHandler(
CONF_FOLDER: DEFAULT_FOLDER, CONF_FOLDER: DEFAULT_FOLDER,
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
CONF_CUSTOM_DAYS: DEFAULT_CUSTOM_DAYS, 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_DURATION: DEFAULT_GIF_DURATION,
CONF_IMAGE_SECURITY: DEFAULT_IMAGE_SECURITY, CONF_IMAGE_SECURITY: DEFAULT_IMAGE_SECURITY,
CONF_IMAP_TIMEOUT: DEFAULT_IMAP_TIMEOUT, CONF_IMAP_TIMEOUT: DEFAULT_IMAP_TIMEOUT,
CONF_GENERATE_GRID: False, CONF_GENERATE_GRID: False,
CONF_GENERATE_MP4: False, CONF_GENERATE_MP4: False,
CONF_USPS_PLACEHOLDER: DEFAULT_USPS_PLACEHOLDER,
CONF_ALLOW_EXTERNAL: DEFAULT_ALLOW_EXTERNAL, CONF_ALLOW_EXTERNAL: DEFAULT_ALLOW_EXTERNAL,
CONF_CUSTOM_IMG: DEFAULT_CUSTOM_IMG, CONF_CUSTOM_IMG: DEFAULT_CUSTOM_IMG,
CONF_AMAZON_CUSTOM_IMG: DEFAULT_AMAZON_CUSTOM_IMG, CONF_AMAZON_CUSTOM_IMG: DEFAULT_AMAZON_CUSTOM_IMG,
@@ -1045,7 +1062,7 @@ class MailAndPackagesFlowHandler(
self._errors = {} self._errors = {}
if user_input is not None: if user_input is not None:
self._data.update(user_input) 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 len(self._errors) == 0:
return await self.async_step_config_storage() return await self.async_step_config_storage()
return await self._show_config_3(user_input) return await self._show_config_3(user_input)
@@ -1076,7 +1093,7 @@ class MailAndPackagesFlowHandler(
self._errors = {} self._errors = {}
if user_input is not None: if user_input is not None:
self._data.update(user_input) 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 len(self._errors) == 0:
if ( if (
self._data.get(CONF_CUSTOM_IMG) self._data.get(CONF_CUSTOM_IMG)
@@ -1117,7 +1134,7 @@ class MailAndPackagesFlowHandler(
self._errors = {} self._errors = {}
if user_input is not None: if user_input is not None:
self._data.update(user_input) 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 len(self._errors) == 0:
if any( if any(
sensor in self._data[CONF_RESOURCES] for sensor in AMAZON_SENSORS sensor in self._data[CONF_RESOURCES] for sensor in AMAZON_SENSORS
@@ -1150,7 +1167,7 @@ class MailAndPackagesFlowHandler(
self._errors = {} self._errors = {}
if user_input is not None: if user_input is not None:
self._data.update(user_input) 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 len(self._errors) == 0:
return self.async_create_entry( return self.async_create_entry(
title=f"Mail and Packages ({self._data[CONF_HOST]})", title=f"Mail and Packages ({self._data[CONF_HOST]})",
@@ -1254,7 +1271,7 @@ class MailAndPackagesFlowHandler(
_LOGGER.debug("Loading step 2...") _LOGGER.debug("Loading step 2...")
if user_input is not None: if user_input is not None:
self._data.update(user_input) 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 len(self._errors) == 0:
if self._data.get(CONF_ALLOW_FORWARDED_EMAILS, False): if self._data.get(CONF_ALLOW_FORWARDED_EMAILS, False):
return await self.async_step_reconfig_forwarded_emails() return await self.async_step_reconfig_forwarded_emails()
@@ -1300,7 +1317,7 @@ class MailAndPackagesFlowHandler(
self._errors = {} self._errors = {}
if user_input is not None: if user_input is not None:
self._data.update(user_input) 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 len(self._errors) == 0:
return await self.async_step_reconfig_storage() return await self.async_step_reconfig_storage()
@@ -1332,7 +1349,7 @@ class MailAndPackagesFlowHandler(
self._errors = {} self._errors = {}
if user_input is not None: if user_input is not None:
self._data.update(user_input) 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 len(self._errors) == 0:
has_custom_image = ( has_custom_image = (
self._data.get(CONF_CUSTOM_IMG) self._data.get(CONF_CUSTOM_IMG)
@@ -1375,7 +1392,7 @@ class MailAndPackagesFlowHandler(
self._errors = {} self._errors = {}
if user_input is not None: if user_input is not None:
self._data.update(user_input) 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 len(self._errors) == 0:
if any( if any(
sensor in self._data.get(CONF_RESOURCES, []) sensor in self._data.get(CONF_RESOURCES, [])
@@ -1404,7 +1421,7 @@ class MailAndPackagesFlowHandler(
self._errors = {} self._errors = {}
if user_input is not None: if user_input is not None:
self._data.update(user_input) 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 len(self._errors) == 0:
self.hass.config_entries.async_update_entry( self.hass.config_entries.async_update_entry(
self._entry, self._entry,
+13 -1
View File
@@ -12,7 +12,7 @@ from .entity import MailandPackagesBinarySensorEntityDescription
DOMAIN = "mail_and_packages" DOMAIN = "mail_and_packages"
DOMAIN_DATA = f"{DOMAIN}_data" DOMAIN_DATA = f"{DOMAIN}_data"
VERSION = "0.5.9" VERSION = "0.5.15"
ISSUE_URL = "http://github.com/moralmunky/Home-Assistant-Mail-And-Packages" ISSUE_URL = "http://github.com/moralmunky/Home-Assistant-Mail-And-Packages"
PLATFORM = "sensor" PLATFORM = "sensor"
PLATFORMS = ["binary_sensor", "camera", "sensor"] PLATFORMS = ["binary_sensor", "camera", "sensor"]
@@ -83,6 +83,7 @@ CONF_ALLOW_FORWARDED_EMAILS = "allow_forwarded_emails"
CONF_FORWARDED_EMAILS = "forwarded_emails" CONF_FORWARDED_EMAILS = "forwarded_emails"
CONF_FORWARDING_HEADER = "forwarding_header" CONF_FORWARDING_HEADER = "forwarding_header"
CONF_CUSTOM_DAYS = "custom_days" CONF_CUSTOM_DAYS = "custom_days"
CONF_USPS_PLACEHOLDER = "usps_placeholder"
# Defaults # Defaults
DEFAULT_CAMERA_NAME = "Mail USPS Camera" DEFAULT_CAMERA_NAME = "Mail USPS Camera"
@@ -130,6 +131,7 @@ DEFAULT_STORAGE = "custom_components/mail_and_packages/images/"
DEFAULT_ALLOW_FORWARDED_EMAILS = False DEFAULT_ALLOW_FORWARDED_EMAILS = False
DEFAULT_FORWARDED_EMAILS = "(none)" DEFAULT_FORWARDED_EMAILS = "(none)"
DEFAULT_FORWARDING_HEADER = "(none)" DEFAULT_FORWARDING_HEADER = "(none)"
DEFAULT_USPS_PLACEHOLDER = True
# Amazon # Amazon
AMAZON_DOMAINS = [ AMAZON_DOMAINS = [
@@ -173,6 +175,7 @@ AMAZON_SHIPMENT_SUBJECT = [
"Shipped:", "Shipped:",
"Enviado:", "Enviado:",
"Out for delivery:", "Out for delivery:",
"Spedito:",
] ]
AMAZON_ORDERED_SUBJECT = ["Ordered:", "Pedido efetuado:"] AMAZON_ORDERED_SUBJECT = ["Ordered:", "Pedido efetuado:"]
AMAZON_EMAIL = [ AMAZON_EMAIL = [
@@ -218,6 +221,7 @@ AMAZON_TIME_PATTERN = [
"Chega ", "Chega ",
"Verwachte bezorgdatum:", "Verwachte bezorgdatum:",
"Votre date de livraison prévue est :", "Votre date de livraison prévue est :",
"In arrivo",
] ]
AMAZON_TIME_PATTERN_END = [ AMAZON_TIME_PATTERN_END = [
"Previously expected:", "Previously expected:",
@@ -252,6 +256,10 @@ AMAZON_TIME_PATTERN_REGEX = [
"Wordt bezorgd op (\\w+ \\d+ \\w+)", "Wordt bezorgd op (\\w+ \\d+ \\w+)",
"Wordt bezorgd op (\\w+ \\d+)", "Wordt bezorgd op (\\w+ \\d+)",
"Wordt (\\w+) bezorgd", "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_SUBJECT = "Delivery update:"
AMAZON_EXCEPTION_BODY = "running late" AMAZON_EXCEPTION_BODY = "running late"
@@ -435,6 +443,7 @@ SENSOR_DATA = {
"donotreply_odd@dhl.com", "donotreply_odd@dhl.com",
"NoReply.ODD@dhl.com", "NoReply.ODD@dhl.com",
"noreply@dhl.de", "noreply@dhl.de",
"no-reply@dhl.de",
"pl.no.reply@dhl.com", "pl.no.reply@dhl.com",
"support@dhl.com", "support@dhl.com",
"noreply@dhlecommerce.nl", "noreply@dhlecommerce.nl",
@@ -448,6 +457,7 @@ SENSOR_DATA = {
"liegt am gewünschten Ablageort", "liegt am gewünschten Ablageort",
"Ihre Sendung liegt im Briefkasten", "Ihre Sendung liegt im Briefkasten",
"Zustellung an Ablageort", "Zustellung an Ablageort",
"Ablageort",
"Sendung zugestellt", "Sendung zugestellt",
"Paket wurde zugestellt", "Paket wurde zugestellt",
"Ihre AliExpress Sendung liegt im Briefkasten", "Ihre AliExpress Sendung liegt im Briefkasten",
@@ -474,6 +484,7 @@ SENSOR_DATA = {
"donotreply_odd@dhl.com", "donotreply_odd@dhl.com",
"NoReply.ODD@dhl.com", "NoReply.ODD@dhl.com",
"noreply@dhl.de", "noreply@dhl.de",
"no-reply@dhl.de",
"pl.no.reply@dhl.com", "pl.no.reply@dhl.com",
"support@dhl.com", "support@dhl.com",
"noreply@dhlecommerce.nl", "noreply@dhlecommerce.nl",
@@ -500,6 +511,7 @@ SENSOR_DATA = {
"scheduled for delivery TODAY", "scheduled for delivery TODAY",
"zostanie dziś do Państwa doręczona", "zostanie dziś do Państwa doręczona",
"wird Ihnen heute", "wird Ihnen heute",
"wird Ihnen voraussichtlich",
"heute zwischen", "heute zwischen",
" - Shipment is out with courier for delivery - ", " - Shipment is out with courier for delivery - ",
"Shipment is scheduled for delivery", "Shipment is scheduled for delivery",
@@ -23,6 +23,7 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import (
ConfigEntryAuthFailed, ConfigEntryAuthFailed,
DataUpdateCoordinator, DataUpdateCoordinator,
@@ -249,21 +250,35 @@ class MailDataUpdateCoordinator(DataUpdateCoordinator):
) )
except InvalidAuth as err: except InvalidAuth as err:
_LOGGER.error("Authentication failed: %s", 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 raise ConfigEntryAuthFailed from err
except Exception as err: except Exception as err:
_LOGGER.error("Error logging into IMAP: %s", err) _LOGGER.error("Error logging into IMAP: %s", err)
raise UpdateFailed(f"Login failed: {err}") from 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) folders = config.get(CONF_FOLDER)
if not folders: if isinstance(folders, str):
folders = ["INBOX"]
elif isinstance(folders, str):
folders = [folders] folders = [folders]
elif isinstance(folders, (list, tuple, set)): elif isinstance(folders, (list, tuple, set)):
folders = [f for f in folders if isinstance(f, str) and f] folders = [f for f in folders if isinstance(f, str) and f]
if not folders:
folders = ["INBOX"]
else: else:
folders = []
if not folders:
folders = ["INBOX"] folders = ["INBOX"]
account._folders = folders # noqa: SLF001 account._folders = folders # noqa: SLF001
account._current_folder = None # noqa: SLF001 account._current_folder = None # noqa: SLF001
@@ -365,9 +380,34 @@ class MailDataUpdateCoordinator(DataUpdateCoordinator):
) )
in_transit = self._in_transit_tracking.get(prefix, {}) 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}_tracking"] = list(in_transit.keys())
data[f"{prefix}_delivering"] = len(in_transit) data[f"{prefix}_delivering"] = len(in_transit)
if in_transit:
delivered_count = data.get(f"{prefix}_delivered", 0) delivered_count = data.get(f"{prefix}_delivered", 0)
data[f"{prefix}_packages"] = len(in_transit) + ( data[f"{prefix}_packages"] = len(in_transit) + (
delivered_count if isinstance(delivered_count, int) else 0 delivered_count if isinstance(delivered_count, int) else 0
@@ -523,7 +563,7 @@ class MailDataUpdateCoordinator(DataUpdateCoordinator):
path = f"{image_path}{base_name}/" path = f"{image_path}{base_name}/"
# Use absolute path for file existence check # Use absolute path for file existence check
delivery_image_relative = f"{path}{image}" 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( _LOGGER.debug(
"Full %s image path: %s", "Full %s image path: %s",
base_name.title(), base_name.title(),
@@ -114,7 +114,7 @@ def get_resources(hass: HomeAssistant | None = None) -> dict:
def copy_images(hass: HomeAssistant, config: ConfigEntry) -> None: def copy_images(hass: HomeAssistant, config: ConfigEntry) -> None:
"""Copy processed images to www directory.""" """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" www_path = Path(hass.config.path()) / "www" / "mail_and_packages"
if not www_path.is_dir(): if not www_path.is_dir():
@@ -18,5 +18,5 @@
"dateparser", "dateparser",
"aioimaplib" "aioimaplib"
], ],
"version": "0.5.9" "version": "0.5.15"
} }
@@ -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}")
+15 -32
View File
@@ -12,6 +12,7 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_RESOURCES from homeassistant.const import CONF_HOST, CONF_RESOURCES
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import MailAndPackagesConfigEntry from . import MailAndPackagesConfigEntry
@@ -118,6 +119,8 @@ class PackagesSensor(CoordinatorEntity, SensorEntity):
@property @property
def native_value(self) -> Any: def native_value(self) -> Any:
"""Return the state of the sensor.""" """Return the state of the sensor."""
if self.coordinator.data is None:
return None
value = self.coordinator.data.get(self.type) value = self.coordinator.data.get(self.type)
if self.type == "mail_updated": if self.type == "mail_updated":
@@ -146,6 +149,8 @@ class PackagesSensor(CoordinatorEntity, SensorEntity):
"""Return device specific state attributes.""" """Return device specific state attributes."""
attr = {} attr = {}
data = self.coordinator.data data = self.coordinator.data
if data is None:
return attr
if any( if any(
sensor in self.type sensor in self.type
@@ -154,10 +159,6 @@ class PackagesSensor(CoordinatorEntity, SensorEntity):
if tracking := data.get(self._tracking_key): if tracking := data.get(self._tracking_key):
attr[ATTR_TRACKING_NUM] = tracking attr[ATTR_TRACKING_NUM] = tracking
# Catch no data entries
if self.data is None:
return attr
if "Amazon" in self._name: if "Amazon" in self._name:
self._add_amazon_attributes(attr, data) self._add_amazon_attributes(attr, data)
elif self._name == "Mail USPS Mail": elif self._name == "Mail USPS Mail":
@@ -225,6 +226,9 @@ class ImagePathSensors(CoordinatorEntity, SensorEntity):
@property @property
def native_value(self) -> str | None: def native_value(self) -> str | None:
"""Return the state of the sensor.""" """Return the state of the sensor."""
if self.coordinator.data is None:
return None
image = "" image = ""
the_path = None the_path = None
@@ -239,10 +243,10 @@ class ImagePathSensors(CoordinatorEntity, SensorEntity):
if self.type == "usps_mail_image_system_path" and image: if self.type == "usps_mail_image_system_path" and image:
_LOGGER.debug("Updating system image path to: %s", path) _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: elif self.type == "usps_mail_grid_image_path" and grid_image:
_LOGGER.debug("Updating grid image path to: %s", path) _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: elif self.type == "usps_mail_image_url" and image:
url = self._get_base_url() url = self._get_base_url()
if url: if url:
@@ -250,33 +254,12 @@ class ImagePathSensors(CoordinatorEntity, SensorEntity):
return the_path return the_path
def _get_base_url(self) -> str | None: def _get_base_url(self) -> str | None:
"""Return the best available base URL for building image links. """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.
try: try:
from homeassistant.components.cloud import ( # noqa: PLC0415 return get_url(self.hass, prefer_external=True)
CloudNotAvailable, except NoURLAvailableError:
async_remote_ui_url, _LOGGER.debug("No URL available for image link.")
) return None
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
@property @property
def should_poll(self) -> bool: def should_poll(self) -> bool:
@@ -104,12 +104,23 @@ class AmazonShipper(Shipper):
days = self.config.get(CONF_AMAZON_DAYS, DEFAULT_AMAZON_DAYS) days = self.config.get(CONF_AMAZON_DAYS, DEFAULT_AMAZON_DAYS)
domain = self.config.get(CONF_AMAZON_DOMAIN) domain = self.config.get(CONF_AMAZON_DOMAIN)
if sensor_type in [AMAZON_PACKAGES, AMAZON_ORDER]: if sensor_type == AMAZON_PACKAGES:
param = "count" if sensor_type == AMAZON_PACKAGES else "order" count = await self._parse_amazon_emails(
result = await self._parse_amazon_emails( account, "count", fwds, days, domain, cache, forwarding_header
account, param, 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: if sensor_type == AMAZON_HUB:
return await self._amazon_hub(account, fwds, cache, forwarding_header) return await self._amazon_hub(account, fwds, cache, forwarding_header)
@@ -199,7 +210,17 @@ class AmazonShipper(Shipper):
if param == "count": if param == "count":
return final_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( async def _process_amazon_email(
self, self,
@@ -297,7 +297,14 @@ class GenericShipper(Shipper):
tracking = set(sensor_res.get(ATTR_TRACKING, [])) tracking = set(sensor_res.get(ATTR_TRACKING, []))
if sensor.endswith("_delivered"): 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")): elif sensor.endswith(("_delivering", "_exception")):
shippers[prefix]["delivering"].update(tracking) shippers[prefix]["delivering"].update(tracking)
shippers[prefix]["update_targets"].append((sensor, sensor_res)) shippers[prefix]["update_targets"].append((sensor, sensor_res))
@@ -662,7 +669,11 @@ class GenericShipper(Shipper):
msg_parts = (await email_fetch(account, eid, "(RFC822)"))[1] msg_parts = (await email_fetch(account, eid, "(RFC822)"))[1]
for response_part in msg_parts: for response_part in msg_parts:
if isinstance(response_part, (bytes, bytearray)): 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, response_part,
s_config["image_path"], s_config["image_path"],
s_config["image_name"], s_config["image_name"],
@@ -27,6 +27,7 @@ from custom_components.mail_and_packages.const import (
CONF_FORWARDING_HEADER, CONF_FORWARDING_HEADER,
CONF_GENERATE_GRID, CONF_GENERATE_GRID,
CONF_GENERATE_MP4, CONF_GENERATE_MP4,
CONF_USPS_PLACEHOLDER,
DEFAULT_CUSTOM_IMG_FILE, DEFAULT_CUSTOM_IMG_FILE,
SENSOR_DATA, SENSOR_DATA,
) )
@@ -103,32 +104,17 @@ class USPSShipper(Shipper):
images = await self._process_usps_images(all_msg_content, images) images = await self._process_usps_images(all_msg_content, images)
image_count = len(images) image_count = len(images)
if image_count > 0: # Generate filtered list for GIF/MP4/Grid
await self._generate_mail_image( gif_images = images.copy()
images, if not config.get("usps_placeholder", True):
config["image_output_path"], placeholder_str = str(
config["image_name"], Path(__file__).parent.parent / "image-no-mailpieces700.jpg"
config["gif_duration"],
images_delete,
)
elif image_count == 0:
await self._copy_nomail_image(
config["image_output_path"],
config["image_name"],
config["custom_img"],
) )
if placeholder_str in gif_images:
gif_images.remove(placeholder_str)
if config["gen_mp4"]: # Generate camera media
await self._generate_mp4_video( await self._create_camera_media(gif_images, config, images_delete)
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,
)
return { return {
ATTR_COUNT: image_count, ATTR_COUNT: image_count,
@@ -159,6 +145,40 @@ class USPSShipper(Shipper):
return res 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): async def _generate_mp4_video(self, path: str, name: str):
"""Generate MP4 video from images.""" """Generate MP4 video from images."""
await self.hass.async_add_executor_job(_generate_mp4, path, name) 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) "custom_img": self.config.get(CONF_CUSTOM_IMG_FILE)
or DEFAULT_CUSTOM_IMG_FILE, or DEFAULT_CUSTOM_IMG_FILE,
"gen_grid": self.config.get(CONF_GENERATE_GRID), "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: async def _search_informed_delivery(self, account: IMAP4_SSL) -> tuple:
@@ -48,6 +48,7 @@
"generate_grid": "Create image grid for LLM vision models", "generate_grid": "Create image grid for LLM vision models",
"generate_mp4": "Create mp4 from images", "generate_mp4": "Create mp4 from images",
"allow_external": "Create image for notification apps", "allow_external": "Create image for notification apps",
"usps_placeholder": "Include USPS no-image placeholder in GIF?",
"custom_img": "Use custom USPS 'no mail' image?", "custom_img": "Use custom USPS 'no mail' image?",
"amazon_custom_img": "Use custom 'no Amazon delivery' image?", "amazon_custom_img": "Use custom 'no Amazon delivery' image?",
"ups_custom_img": "Use custom 'no UPS delivery' image?", "ups_custom_img": "Use custom 'no UPS delivery' image?",
@@ -121,6 +122,7 @@
"resources": "Sensors List", "resources": "Sensors List",
"imap_timeout": "Mailbox scan time limit (seconds, minimum 10)", "imap_timeout": "Mailbox scan time limit (seconds, minimum 10)",
"allow_external": "Create image for notification apps", "allow_external": "Create image for notification apps",
"usps_placeholder": "Include USPS no-image placeholder in GIF?",
"custom_img": "Use custom USPS 'no mail' image?", "custom_img": "Use custom USPS 'no mail' image?",
"amazon_custom_img": "Use custom 'no Amazon delivery' image?", "amazon_custom_img": "Use custom 'no Amazon delivery' image?",
"ups_custom_img": "Use custom 'no UPS delivery' image?", "ups_custom_img": "Use custom 'no UPS delivery' image?",
@@ -195,5 +197,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Utilitza la imatge personalitzada genèrica 'no hi ha lliurament'?", "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", "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)", "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.", "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)" "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'?", "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", "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)", "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ó.", "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)" "title": "Correu i paquets (pas 2 de 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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ó."
}
}
}
}
} }
} }
@@ -40,7 +40,8 @@
"generic_custom_img": "Použít vlastní obrázek obecný 'bez dodání'?", "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)", "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", "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.", "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)" "title": "Mail and Packages (Krok 2 ze 3)"
@@ -152,7 +153,8 @@
"walmart_custom_img": "Use custom 'no Walmart delivery' image?", "walmart_custom_img": "Use custom 'no Walmart delivery' image?",
"fedex_custom_img": "Use custom 'no FedEx 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)", "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.", "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)" "title": "Mail and Packages (Step 2 of 2)"
@@ -265,5 +267,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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í."
}
}
}
}
} }
} }
@@ -37,7 +37,8 @@
"post_de_custom_img": "Benutzerdefiniertes Post DE 'keine Post' Bild verwenden?", "post_de_custom_img": "Benutzerdefiniertes Post DE 'keine Post' Bild verwenden?",
"generate_grid": "Erstellen Sie ein Bildraster für LLM-Vision-Modelle", "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)", "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.", "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)" "title": "Briefe und Pakete (Schritt 2 von 2)"
@@ -83,7 +84,8 @@
"post_de_custom_img": "Benutzerdefiniertes Post DE 'keine Post' Bild verwenden?", "post_de_custom_img": "Benutzerdefiniertes Post DE 'keine Post' Bild verwenden?",
"generate_grid": "Erstellen Sie ein Bildraster für LLM-Vision-Modelle", "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)", "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.", "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)" "title": "Briefe und Pakete (Schritt 2 von 2)"
@@ -203,5 +205,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -48,6 +48,7 @@
"imap_timeout": "Mailbox scan time limit (seconds, minimum 10)", "imap_timeout": "Mailbox scan time limit (seconds, minimum 10)",
"generate_mp4": "Create mp4 from images", "generate_mp4": "Create mp4 from images",
"allow_external": "Create image for notification apps", "allow_external": "Create image for notification apps",
"usps_placeholder": "Include USPS no-image placeholder in GIF?",
"custom_img": "Use custom USPS 'no mail' image?", "custom_img": "Use custom USPS 'no mail' image?",
"amazon_custom_img": "Use custom 'no Amazon delivery' image?", "amazon_custom_img": "Use custom 'no Amazon delivery' image?",
"ups_custom_img": "Use custom 'no UPS delivery' image?", "ups_custom_img": "Use custom 'no UPS delivery' image?",
@@ -116,6 +117,7 @@
"resources": "Sensors List", "resources": "Sensors List",
"imap_timeout": "Mailbox scan time limit (seconds, minimum 10)", "imap_timeout": "Mailbox scan time limit (seconds, minimum 10)",
"allow_external": "Create image for notification apps", "allow_external": "Create image for notification apps",
"usps_placeholder": "Include USPS no-image placeholder in GIF?",
"custom_img": "Use custom USPS 'no mail' image?", "custom_img": "Use custom USPS 'no mail' image?",
"amazon_custom_img": "Use custom 'no Amazon delivery' image?", "amazon_custom_img": "Use custom 'no Amazon delivery' image?",
"ups_custom_img": "Use custom 'no UPS delivery' image?", "ups_custom_img": "Use custom 'no UPS delivery' image?",
@@ -209,5 +211,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?", "generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?",
"generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM", "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)", "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.", "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)" "title": "Correo y paquetes (Paso 2 de 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?", "generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?",
"generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM", "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)", "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.", "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)" "title": "Correo y paquetes (Paso 2 de 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?", "generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?",
"generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM", "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)", "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.", "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)" "title": "Correo y paquetes (Paso 2 de 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?", "generic_custom_img": "¿Usar imagen personalizada genérica 'sin entrega'?",
"generate_grid": "Crear cuadrícula de imágenes para modelos de visión LLM", "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)", "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.", "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)" "title": "Correo y paquetes (Paso 2 de 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Käytä mukautettua yleistä 'ei toimituksia' kuvaa?", "generic_custom_img": "Käytä mukautettua yleistä 'ei toimituksia' kuvaa?",
"generate_grid": "Luo kuvaverkko LLM-näkömalleille", "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)", "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.", "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)" "title": "Posti ja paketit (vaihe 2/2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Käytä mukautettua yleistä 'ei toimituksia' kuvaa?", "generic_custom_img": "Käytä mukautettua yleistä 'ei toimituksia' kuvaa?",
"generate_grid": "Luo kuvaverkko LLM-näkömalleille", "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)", "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.", "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)" "title": "Posti ja paketit (vaihe 2/2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Utiliser une image personnalisée générique 'pas de livraison' ?", "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", "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)", "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.", "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)" "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' ?", "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", "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)", "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.", "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)" "title": "Courrier et colis (étape 2 sur 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Használjon egyéni általános 'nincs szállítás' képet?", "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", "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)", "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.", "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)" "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?", "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", "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)", "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.", "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)" "title": "Levél és csomagok (2. lépés a 2-ből)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Usare l'immagine personalizzata generica 'nessuna consegna'?", "generic_custom_img": "Usare l'immagine personalizzata generica 'nessuna consegna'?",
"generate_grid": "Crea una griglia di immagini per i modelli di visione LLM", "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)", "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.", "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)" "title": "Posta e pacchi (passaggio 2 di 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Usare l'immagine personalizzata generica 'nessuna consegna'?", "generic_custom_img": "Usare l'immagine personalizzata generica 'nessuna consegna'?",
"generate_grid": "Crea una griglia di immagini per i modelli di visione LLM", "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)", "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.", "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)" "title": "Posta e pacchi (passaggio 2 di 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "사용자 정의 일반 '배송 없음' 이미지를 사용하시겠습니까?", "generic_custom_img": "사용자 정의 일반 '배송 없음' 이미지를 사용하시겠습니까?",
"generate_grid": "LLM 비전 모델을 위한 이미지 그리드 생성", "generate_grid": "LLM 비전 모델을 위한 이미지 그리드 생성",
"allow_forwarded_emails": "서비스 기본값 외에 전달된 이메일 허용 (예: no-reply@usps.com)", "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)", "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 단계)" "title": "메일 및 패키지 (2/2 단계)"
@@ -80,7 +81,8 @@
"generic_custom_img": "사용자 정의 일반 '배송 없음' 이미지를 사용하시겠습니까?", "generic_custom_img": "사용자 정의 일반 '배송 없음' 이미지를 사용하시겠습니까?",
"generate_grid": "LLM 비전 모델을 위한 이미지 그리드 생성", "generate_grid": "LLM 비전 모델을 위한 이미지 그리드 생성",
"allow_forwarded_emails": "서비스 기본값 외에 전달된 이메일 허용 (예: no-reply@usps.com)", "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)을 입력하십시오.", "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 단계)" "title": "메일 및 패키지 (2/2 단계)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "oauth2_google": "OAuth2 - Google (Gmail)"
} }
} }
},
"issues": {
"auth_failed": {
"title": "Mail and Packages 인증 실패",
"fix_flow": {
"step": {
"confirm": {
"title": "메일 서버 재인증",
"description": "IMAP 메일 서버 인증에 실패했습니다. 제출을 클릭하여 재인증 흐름을 시작하십시오."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Gebruik aangepaste generieke 'geen levering' afbeelding?", "generic_custom_img": "Gebruik aangepaste generieke 'geen levering' afbeelding?",
"generate_grid": "Maak een afbeeldingsraster voor LLM-visiemodellen", "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)", "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.", "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)" "title": "Mail en pakketten (stap 2 van 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Gebruik aangepaste generieke 'geen levering' afbeelding?", "generic_custom_img": "Gebruik aangepaste generieke 'geen levering' afbeelding?",
"generate_grid": "Maak een afbeeldingsraster voor LLM-visiemodellen", "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)", "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.", "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)" "title": "Mail en pakketten (stap 2 van 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Bruk tilpasset generisk 'ingen levering' bilde?", "generic_custom_img": "Bruk tilpasset generisk 'ingen levering' bilde?",
"generate_grid": "Opprett bildegitter for LLM-visjonsmodeller", "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)", "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.", "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)" "title": "E-post og pakker (trinn 2 av 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Bruk tilpasset generisk 'ingen levering' bilde?", "generic_custom_img": "Bruk tilpasset generisk 'ingen levering' bilde?",
"generate_grid": "Opprett bildegitter for LLM-visjonsmodeller", "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)", "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.", "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)" "title": "E-post og pakker (trinn 2 av 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Użyć niestandardowego obrazu ogólnego 'brak dostawy'?", "generic_custom_img": "Użyć niestandardowego obrazu ogólnego 'brak dostawy'?",
"generate_grid": "Utwórz siatkę obrazów dla modeli wizji LLM", "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)", "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.", "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)" "title": "Poczta i paczki (krok 2 z 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Użyć niestandardowego obrazu ogólnego 'brak dostawy'?", "generic_custom_img": "Użyć niestandardowego obrazu ogólnego 'brak dostawy'?",
"generate_grid": "Utwórz siatkę obrazów dla modeli wizji LLM", "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)", "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.", "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)" "title": "Poczta i paczki (krok 2 z 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?", "generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?",
"generate_grid": "Criar grade de imagens para modelos de visão LLM", "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)", "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.", "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)" "title": "Correio e pacotes (Etapa 2 de 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?", "generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?",
"generate_grid": "Criar grade de imagens para modelos de visão LLM", "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)", "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.", "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)" "title": "Correio e pacotes (Etapa 2 de 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?", "generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?",
"generate_grid": "Criar grade de imagens para modelos de visão LLM", "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)", "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.", "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)" "title": "Correio e pacotes (Etapa 2 de 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?", "generic_custom_img": "Usar imagem personalizada genérica 'sem entrega'?",
"generate_grid": "Criar grade de imagens para modelos de visão LLM", "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)", "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.", "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)" "title": "Correio e pacotes (Etapa 2 de 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Использовать пользовательское изображение общее 'нет доставки'?", "generic_custom_img": "Использовать пользовательское изображение общее 'нет доставки'?",
"generate_grid": "Создать сетку изображений для моделей зрения LLM", "generate_grid": "Создать сетку изображений для моделей зрения LLM",
"allow_forwarded_emails": "Разрешить переадресованные электронные письма в дополнение к значениям по умолчанию службы (например, no-reply@usps.com)", "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.", "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)" "title": "Почта и пакеты (шаг 2 из 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Использовать пользовательское изображение общее 'нет доставки'?", "generic_custom_img": "Использовать пользовательское изображение общее 'нет доставки'?",
"generate_grid": "Создать сетку изображений для моделей зрения LLM", "generate_grid": "Создать сетку изображений для моделей зрения LLM",
"allow_forwarded_emails": "Разрешить переадресованные электронные письма в дополнение к значениям по умолчанию службы (например, no-reply@usps.com)", "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), чтобы очистить эту настройку.", "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)" "title": "Почта и пакеты (шаг 2 из 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "oauth2_google": "OAuth2 - Google (Gmail)"
} }
} }
},
"issues": {
"auth_failed": {
"title": "Ошибка авторизации Mail and Packages",
"fix_flow": {
"step": {
"confirm": {
"title": "Повторная авторизация почтового сервера",
"description": "Не удалось авторизоваться на вашем почтовом сервере IMAP. Нажмите «Отправить», чтобы начать процесс повторной авторизации."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Použiť vlastný obrázok všeobecný 'bez dodávky'?", "generic_custom_img": "Použiť vlastný obrázok všeobecný 'bez dodávky'?",
"generate_grid": "Vytvoriť mriežku obrázkov pre modely videnia LLM", "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)", "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.", "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)" "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'?", "generic_custom_img": "Použiť vlastný obrázok všeobecný 'bez dodávky'?",
"generate_grid": "Vytvoriť mriežku obrázkov pre modely videnia LLM", "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)", "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.", "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)" "title": "Pošta a balíky (krok 2 z 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Uporabite prilagojeno splošno sliko 'brez dostave'?", "generic_custom_img": "Uporabite prilagojeno splošno sliko 'brez dostave'?",
"generate_grid": "Ustvari mrežo slik za vizualne modele LLM", "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)", "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.", "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)" "title": "Pošta in paketi (2. korak od 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Uporabite prilagojeno splošno sliko 'brez dostave'?", "generic_custom_img": "Uporabite prilagojeno splošno sliko 'brez dostave'?",
"generate_grid": "Ustvari mrežo slik za vizualne modele LLM", "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)", "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.", "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)" "title": "Pošta in paketi (2. korak od 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "Använd anpassad generisk 'ingen leverans' bild?", "generic_custom_img": "Använd anpassad generisk 'ingen leverans' bild?",
"generate_grid": "Skapa bildrutnät för LLM-visionsmodeller", "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)", "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.", "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)" "title": "Mail och paket (steg 2 av 2)"
@@ -80,7 +81,8 @@
"generic_custom_img": "Använd anpassad generisk 'ingen leverans' bild?", "generic_custom_img": "Använd anpassad generisk 'ingen leverans' bild?",
"generate_grid": "Skapa bildrutnät för LLM-visionsmodeller", "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)", "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.", "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)" "title": "Mail och paket (steg 2 av 2)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "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."
}
}
}
}
} }
} }
@@ -36,7 +36,8 @@
"generic_custom_img": "使用自訂的通用「無送貨」圖像?", "generic_custom_img": "使用自訂的通用「無送貨」圖像?",
"generate_grid": "為LLM視覺模型創建圖像網格", "generate_grid": "為LLM視覺模型創建圖像網格",
"allow_forwarded_emails": "允許轉發的電郵,除了服務的預設值(例如 no-reply@usps.com", "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)。", "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步)" "title": "郵件和包裹(第2步,共2步)"
@@ -80,7 +81,8 @@
"generic_custom_img": "使用自訂的通用「無送貨」圖像?", "generic_custom_img": "使用自訂的通用「無送貨」圖像?",
"generate_grid": "為LLM視覺模型創建圖像網格", "generate_grid": "為LLM視覺模型創建圖像網格",
"allow_forwarded_emails": "允許轉發的電郵,除了服務的預設值(例如 no-reply@usps.com", "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)以清除此設定。", "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步)" "title": "郵件和包裹(第2步,共2步)"
@@ -199,5 +201,18 @@
"oauth2_google": "OAuth2 - Google (Gmail)" "oauth2_google": "OAuth2 - Google (Gmail)"
} }
} }
},
"issues": {
"auth_failed": {
"title": "Mail and Packages 認證失敗",
"fix_flow": {
"step": {
"confirm": {
"title": "重新認證郵件伺服器",
"description": "認證 IMAP 郵件伺服器失敗。請按提交以啟動重新認證流程。"
}
}
}
}
} }
} }

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