updated apps
@@ -1 +1 @@
|
|||||||
2026.7.1
|
2026.7.2
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
@@ -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}
|
||||||
@@ -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 }}'
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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 郵件伺服器失敗。請按提交以啟動重新認證流程。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||